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. - -![Vaseinator main window](docs/images/main_window.png) - -![The ready-made vases](docs/images/gallery.png) - -| Advanced: draw how things change up the vase | Section: the printed wall | -|---|---| -| ![Advanced mode with curves](docs/images/detailed.png) | ![Section view](docs/images/section.png) | -| **Overhangs: green is easy, red needs support** | **Watch it print, in real print time** | -| ![Overhang check](docs/images/overhangs.png) | ![Print layout and animation](docs/images/print_layout.png) | -| **Draw any cross-section, sharp corners and all** | **Draw one lobe, repeat it: three bulbs become six** | -| ![The cross-section editor](docs/images/drawn_claw_shell.png) | ![Repeat around](docs/images/drawn_bulb_swirl.png) | - -## 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. + +![Vaseinator main window](docs/images/main_window.png) + +![The ready-made vases](docs/images/gallery.png) + +| Advanced: draw how things change up the vase | Section: the printed wall | +|---|---| +| ![Advanced mode with curves](docs/images/detailed.png) | ![Section view](docs/images/section.png) | +| **Overhangs: green is easy, red needs support** | **Watch it print, in real print time** | +| ![Overhang check](docs/images/overhangs.png) | ![Print layout and animation](docs/images/print_layout.png) | +| **Draw any cross-section, sharp corners and all** | **Draw one lobe, repeat it: three bulbs become six** | +| ![The cross-section editor](docs/images/drawn_claw_shell.png) | ![Repeat around](docs/images/drawn_bulb_swirl.png) | + +## 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(" 1 else None) +"""Put the web version together in site/: the page, the Python it runs, and the gallery pictures. + + .venv\\Scripts\\python tools\\build_web.py then, to try it: + .venv\\Scripts\\python -m http.server 8000 -d site and open http://localhost:8000 + +The page runs the same vaseinator package as the desktop app - the parts of it that need no Qt - in the +browser with Pyodide. GitHub Pages publishes site/ (see .github/workflows/pages.yml). +""" + +import hashlib +import json +import os +import re +import shutil +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +# the package's modules that need no Qt: the geometry, the designs, the settings, the report and the exports +MODULES = ("__init__", "choices", "expr", "geometry", "hollow", "lid", "params", "presets", "report", "sections", + "settings", "shapes", "stl", "threemf", "workspace") + + +def main(out=None): + out = out or os.path.join(ROOT, "site") + if os.path.isdir(out): + shutil.rmtree(out) + os.makedirs(os.path.join(out, "py", "vaseinator")) + os.makedirs(os.path.join(out, "gallery")) + for name in sorted(os.listdir(os.path.join(ROOT, "web"))): # the page: everything but its Python + if name.endswith((".html", ".css", ".js")): + shutil.copy2(os.path.join(ROOT, "web", name), os.path.join(out, name)) + shutil.copy2(os.path.join(ROOT, "assets", "icon.png"), os.path.join(out, "icon.png")) + files = ["bridge.py"] + shutil.copy2(os.path.join(ROOT, "web", "bridge.py"), os.path.join(out, "py", "bridge.py")) + for m in MODULES: + src = os.path.join(ROOT, "vaseinator", m + ".py") + with open(src, encoding="utf-8") as fh: + text = fh.read() + if "PySide6" in text.split("def ", 1)[0]: # imported at the top: it would not load + sys.exit(f"vaseinator/{m}.py needs Qt, so it can't run in the browser") + shutil.copy2(src, os.path.join(out, "py", "vaseinator", m + ".py")) + files.append(f"vaseinator/{m}.py") + with open(os.path.join(out, "py", "files.json"), "w", encoding="utf-8") as fh: + json.dump(files, fh) + pictures = os.path.join(ROOT, "assets", "gallery") + for name in sorted(os.listdir(pictures)): + if name.endswith(".png"): + shutil.copy2(os.path.join(pictures, name), os.path.join(out, "gallery", name)) + open(os.path.join(out, ".nojekyll"), "w").close() # serve every file as it is + stamp(out) + print("built", out) + + +def stamp(out): + """Give the page's own files a version in their links, from what they hold, so that after an update a + browser never mixes files it kept from before with new ones.""" + digest = hashlib.sha1() + for folder, _, names in sorted(os.walk(out)): + for name in sorted(names): + if name.endswith((".html", ".css", ".js", ".py", ".json")): + with open(os.path.join(folder, name), "rb") as fh: + digest.update(fh.read()) + v = digest.hexdigest()[:10] + for name in os.listdir(out): + if not name.endswith((".html", ".js")): + continue + path = os.path.join(out, name) + with open(path, encoding="utf-8") as fh: + text = fh.read() + def versioned(m): + ours = os.path.exists(os.path.join(out, f"{m[2]}.{m[3]}")) + return f"{m[1]}{m[2]}.{m[3]}?v={v}{m[4]}" if ours else m[0] + + text = re.sub(r"""(["'](?:\./)?)(\w+)\.(js|css)(["'])""", versioned, text) + with open(path, "w", encoding="utf-8") as fh: + fh.write(text) + + +if __name__ == "__main__": + main(sys.argv[1] if len(sys.argv) > 1 else None) diff --git a/vaseinator/app.py b/vaseinator/app.py index d963806..87c16a0 100644 --- a/vaseinator/app.py +++ b/vaseinator/app.py @@ -1,1849 +1,1875 @@ -"""Vaseinator - main window.""" - -from __future__ import annotations - -import math -import os -import random -import sys -import time - -import numpy as np -from PySide6.QtCore import QSettings, QSize, Qt, QTimer, QUrl, Signal -from PySide6.QtGui import (QAction, QColor, QDesktopServices, QFontDatabase, QIcon, QKeySequence, QPainter, - QPalette, QPen, QPixmap, QSurfaceFormat) -from PySide6.QtWidgets import (QApplication, QCheckBox, QColorDialog, QComboBox, QDialog, QDialogButtonBox, - QDoubleSpinBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel, - QLineEdit, QMainWindow, QMenu, QMessageBox, QPlainTextEdit, QPushButton, - QScrollArea, QSlider, QSpinBox, QSplitter, QStackedWidget, QStyle, - QTextBrowser, QToolButton, QVBoxLayout, QWidget) - -from . import sections, slicers -from .curves import CurveEditor -from .expr import EXAMPLES -from .geometry import HEAD_SCALED, VaseDesign, duration, fit_head -from .outline_editor import CLOSE_UP_FROM, OutlineEditor, OutlineWindow -from .params import (DRAWN, FILAMENTS, ONE_COLOUR, OWN_PROFILE, POLYGON, REAL_VASE, VaseParams, colour_style_of, - load_design, save_design) -from .plots import ShapePlots -from .lid import Lid -from .report import ABOUT, APP_NAME, DRAWING_HELP, PRINTING_HELP, function_help, info_html, lid_steps, print_steps -from .settings import (COLOUR_STYLES, REAL_COLOUR_STYLES, REMEMBERED, SPEC, TEXT_KEYS, change as apply_change, - outline_from_shape, wanted) -from .presets import GALLERY, gallery_design, outline, palette, plain_design, slug, surprise -from .simple_panel import GalleryDialog, SimplePanel, asset, profile_icon -from .spinbox import ComboBox, DoubleSpinBox, Slider, SpinBox -from .shapes import PALETTES, SHAPE_LABELS -from .stl import write_stl -from .threemf import write_3mf -from .viewer import Viewer3D, spin_transform - -FILE_EXT = ".vase" -KOFI_URL = "https://ko-fi.com/rickapps" -VASE, SECTION, OVERHANGS, PRINT_LAYOUT = 0, 1, 2, 3 -VIEW_NAMES = ("Vase", "Section", "Overhangs", "Print layout") -SPIN_SPEED = 30.0 # degrees a second, on the turntable - - - -def swatch(color: str, size: int = 14) -> QIcon: - pm = QPixmap(size, size) - pm.fill(Qt.transparent) - p = QPainter(pm) - p.setPen(QPen(QColor(20, 22, 26), 1)) - p.setBrush(QColor(color)) - p.drawRect(0, 0, size - 1, size - 1) - p.end() - return QIcon(pm) - - -def palette_icon(colours) -> QIcon: - pm = QPixmap(56, 14) - pm.fill(Qt.transparent) - p = QPainter(pm) - for k, c in enumerate(colours): - p.setPen(QPen(QColor(20, 22, 26), 1)) - p.setBrush(QColor(c)) - p.drawRect(k * 14, 0, 13, 13) - p.end() - return QIcon(pm) - - -def glyph_icon(kind: str) -> QIcon: - """Small light line icons that read well on the dark theme.""" - from PySide6.QtCore import QPointF, QRectF - - pm = QPixmap(64, 64) - pm.fill(Qt.transparent) - p = QPainter(pm) - p.setRenderHint(QPainter.Antialiasing) - app = QApplication.instance() - ink = app.palette().color(QPalette.WindowText) if app is not None else QColor(225, 230, 236) - pen = QPen(ink, 5, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin) - p.setPen(pen) - if kind == "export": - p.drawLine(QPointF(32, 8), QPointF(32, 40)) - p.drawPolyline([QPointF(19, 28), QPointF(32, 41), QPointF(45, 28)]) - p.drawPolyline([QPointF(10, 40), QPointF(10, 54), QPointF(54, 54), QPointF(54, 40)]) - elif kind == "picture": - p.drawRoundedRect(QRectF(8, 14, 48, 38), 4, 4) - pen.setColor(QColor(240, 163, 86)) - p.setPen(pen) - p.drawPolyline([QPointF(14, 44), QPointF(26, 30), QPointF(34, 38), QPointF(42, 26), QPointF(50, 38)]) - elif kind == "dice": - p.drawRoundedRect(QRectF(10, 10, 44, 44), 9, 9) - p.setBrush(QColor(240, 163, 86)) - p.setPen(Qt.NoPen) - for x, y in ((22, 22), (42, 22), (32, 32), (22, 42), (42, 42)): - p.drawEllipse(QPointF(x, y), 4.5, 4.5) - elif kind == "gallery": - pen.setColor(QColor(240, 163, 86)) - p.setPen(pen) - p.drawPolyline([QPointF(20, 8), QPointF(20, 16), QPointF(12, 34), QPointF(16, 56), QPointF(48, 56), - QPointF(52, 34), QPointF(44, 16), QPointF(44, 8)]) - p.drawLine(QPointF(16, 8), QPointF(48, 8)) - elif kind in ("undo", "redo"): - sx = 1 if kind == "undo" else -1 - cx = 32 - p.drawArc(QRectF(14, 18, 36, 32), (0 if sx > 0 else 180) * 16, (180 if sx > 0 else -180) * 16) - tip = QPointF(cx - sx * 18, 34) - p.drawPolyline([QPointF(tip.x() - sx * 7, 25), tip, QPointF(tip.x() + sx * 9, 28)]) - elif kind == "coffee": - p.drawRoundedRect(QRectF(14, 26, 30, 26), 5, 5) # cup - p.drawArc(QRectF(36, 30, 16, 16), -90 * 16, 180 * 16) # handle, ends on the cup - pen.setColor(QColor(240, 163, 86)) - p.setPen(pen) - for x in (21, 29, 37): # steam - p.drawLine(QPointF(x, 10), QPointF(x, 20)) - p.end() - return QIcon(pm) - - -def make_app_icon() -> QIcon: - here = os.path.dirname(os.path.abspath(__file__)) - for cand in (os.path.join(here, "..", "assets", "icon.png"), - os.path.join(getattr(sys, "_MEIPASS", here), "assets", "icon.png")): - if os.path.exists(cand): - return QIcon(cand) - return QIcon() - - -class FunctionHelp(QDialog): - """What a formula can use, with examples to try.""" - - def __init__(self, parent, insert): - super().__init__(parent) - self.setWindowTitle("Your own function") - self.resize(640, 560) - text = QTextBrowser() - text.setHtml(function_help()) - box = QVBoxLayout(self) - box.addWidget(text) - row = QHBoxLayout() - row.addWidget(QLabel("Try an example:")) - pick = ComboBox() - for name, formula in EXAMPLES: - pick.addItem(name, formula) - row.addWidget(pick, 1) - use = QPushButton("Use it") - use.clicked.connect(lambda: (insert(pick.currentData()), self.accept())) - row.addWidget(use) - box.addLayout(row) - buttons = QDialogButtonBox(QDialogButtonBox.Close) - buttons.rejected.connect(self.reject) - box.addWidget(buttons) - - -class PrintSteps(QDialog): - """After Save for printing: where the file is, and plain steps to print it - the vase's, or the steps - given (a lid's, say, as steps(where)).""" - open_slicer = Signal() - - def __init__(self, parent, d, path, parts: int, slicer, steps=None): - super().__init__(parent) - self.setWindowTitle("How to print it") - where = slicers.slicer_name(slicer) if slicer else "your slicer" - text = QLabel(steps(where) if steps else print_steps(d, path, parts, where)) - text.setWordWrap(True) - text.setTextInteractionFlags(Qt.TextSelectableByMouse) - box = QVBoxLayout(self) - box.addWidget(text) - row = QHBoxLayout() - if slicer: - go = QPushButton(f"Open in {where}") - go.setObjectName("saveButton") - go.setMinimumHeight(36) - go.clicked.connect(lambda: (self.open_slicer.emit(), self.accept())) - row.addWidget(go) - show = QPushButton("Show the file") - show.clicked.connect(lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(os.path.dirname(path)))) - row.addWidget(show) - done = QPushButton("Close") - done.clicked.connect(self.accept) - row.addWidget(done) - box.addLayout(row) - self.resize(560, 320) - - -class MainWindow(QMainWindow): - def __init__(self, settings: QSettings | None = None): - super().__init__() - self.settings = settings or QSettings("Vaseinator", "Vaseinator") - if self.settings.value("big_text", False, type=bool) or self.settings.value("light_theme", False, type=bool): - apply_theme(QApplication.instance(), light=self.settings.value("light_theme", False, type=bool), - scale=1.3 if self.settings.value("big_text", False, type=bool) else 1.0) - self.simple_mode = self.settings.value("simple_mode", True, type=bool) - self.params = self._fresh_params() - self.design: VaseDesign | None = None - self.current_file = None - self.view = VASE - self._free_camera = False - self._fitted = None - self._dirty = False - self.widgets = {} - self.labels = {} - self.outline_menus = {} # Start from, for each drawn cross-section - self.close_up_buttons = {} - self.outline_windows = {} # the cross-section editors opened full window - self.slider_notes = {} - self.history: list[dict] = [] - self.future: list[dict] = [] - self.spin_angle = 0.0 - self.spinning = self.settings.value("spin", True, type=bool) - self.printing = None # the print animation: its start time - self._last_full_ms = 0.0 - self._note = "" # said with the next "Built in ..." message - self._last_tick = time.perf_counter() - - self.regen_timer = QTimer(self) - self.regen_timer.setSingleShot(True) - self.regen_timer.timeout.connect(self.regenerate) - self.draft_timer = QTimer(self) - self.draft_timer.setSingleShot(True) - self.draft_timer.timeout.connect(lambda: self.regenerate(draft=True)) - self.anim_timer = QTimer(self) - self.anim_timer.setInterval(16) - self.anim_timer.timeout.connect(self._anim_tick) - - self.setWindowIcon(make_app_icon()) - self._build_ui() - self._build_actions() - self.resize(1480, 940) - geo = self.settings.value("geometry") - if geo is not None: - self.restoreGeometry(geo) - self.regenerate() - self._update_anim() - - # ================================================================== UI - def _build_ui(self): - panel = QWidget() - pl = QVBoxLayout(panel) - pl.setContentsMargins(8, 8, 8, 8) - pl.setSpacing(8) - - mode_row = QHBoxLayout() - mode_row.setSpacing(0) - self.simple_btn = QPushButton("Simple") - self.detail_btn = QPushButton("Advanced") - self.simple_btn.setToolTip("A few choices made from pictures: size, shape, twist, pattern and colour.") - self.detail_btn.setToolTip("Every setting, with curves to draw how things change up the vase.") - for b in (self.simple_btn, self.detail_btn): - b.setCheckable(True) - b.setAutoExclusive(True) - b.setMinimumHeight(28) - b.setObjectName("modeButton") - mode_row.addWidget(b) - (self.simple_btn if self.simple_mode else self.detail_btn).setChecked(True) - self.simple_btn.clicked.connect(lambda: self.set_simple_mode(True)) - self.detail_btn.clicked.connect(lambda: self.set_simple_mode(False)) - - name_row = QHBoxLayout() - name_row.addWidget(QLabel("Name")) - self.name_edit = QLineEdit(self.params.name) - self.name_edit.setToolTip("Design name - used for exported file names.") - self.name_edit.textChanged.connect(self._on_name) - name_row.addWidget(self.name_edit) - dice = self.dice_btn = QToolButton() - dice.setIcon(glyph_icon("dice")) - dice.setToolTip("Surprise me: a new random vase that still prints (Ctrl+R)") - dice.clicked.connect(self.surprise_me) - name_row.addWidget(dice) - pl.addLayout(name_row) - - self._rows = {} - self._groups = [] - for title, items in SPEC: - box = QGroupBox(title) - form = QFormLayout(box) - form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) - form.setContentsMargins(8, 12, 8, 8) - form.setHorizontalSpacing(10) - form.setVerticalSpacing(5) - for key, label, kind, lo, hi, step, extra, tip in items: - w = self._make_widget(key, kind, lo, hi, step, extra) - if tip: - w.setToolTip(tip) - if label: - lab = QLabel(label) - if tip: - lab.setToolTip(tip) - self.labels[key] = lab - form.addRow(lab, w) - else: - form.addRow(w) - self._rows[key] = (form, w) - self._groups.append((box, [it[0] for it in items])) - pl.addWidget(box) - pl.addStretch(1) - - scroll = QScrollArea() - scroll.setWidget(panel) - scroll.setWidgetResizable(True) - scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - # Simple: a few choices made from pictures; Advanced: every setting - self.simple = SimplePanel() - self.simple.param.connect(lambda k, v: self._on_param(k, v)) - self.simple.live.connect(lambda k, v: self._on_param(k, v, live=True)) - self.simple.changes.connect(self.apply_changes) - self.simple.choose_design.connect(self.choose_design) - self.simple.surprise.connect(lambda: self.surprise_me()) - self.simple.draw_own.connect(self.draw_own_shape) - self.simple.save.connect(self.save_for_printing) - self.simple.save_lid.connect(self.save_lid) - self.simple.advanced.connect(lambda: self.set_simple_mode(False)) - simple_scroll = QScrollArea() - simple_scroll.setWidget(self.simple) - simple_scroll.setWidgetResizable(True) - simple_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.panels = QStackedWidget() - self.panels.addWidget(simple_scroll) - self.panels.addWidget(scroll) - self.panels.setCurrentIndex(0 if self.simple_mode else 1) - left = QWidget() - lv = QVBoxLayout(left) - lv.setContentsMargins(8, 8, 0, 0) - lv.setSpacing(6) - lv.addLayout(mode_row) - lv.addWidget(self.panels, 1) - left.setMinimumWidth(370) - left.setMaximumWidth(480) - - self.viewer = Viewer3D() - self.viewer.dragged.connect(self._on_dragged) - self.viewer.reset_requested.connect(lambda: self.show_view(self.view)) - self.viewer.resized.connect(self._on_resized) - self.plots = ShapePlots() - self.info = QTextBrowser() - self.info.setOpenExternalLinks(False) - self.info.setMinimumWidth(280) - - bottom = QSplitter(Qt.Horizontal) - bottom.addWidget(self.plots) - bottom.addWidget(self.info) - bottom.setStretchFactor(0, 3) - bottom.setStretchFactor(1, 2) - bottom.setSizes([560, 500]) - - right = QSplitter(Qt.Vertical) - right.addWidget(self._make_view_bar()) - right.addWidget(self.viewer) - right.addWidget(bottom) - right.setStretchFactor(1, 3) - right.setStretchFactor(2, 2) - right.setSizes([36, 560, 300]) - right.setCollapsible(0, False) - right.handle(1).setEnabled(False) - - main = QSplitter(Qt.Horizontal) - main.addWidget(left) - main.addWidget(right) - main.setStretchFactor(1, 1) - main.setSizes([380, 1100]) - self.setCentralWidget(main) - self.statusBar().showMessage("Ready") - self._refresh_widgets() - - def _make_view_bar(self): - bar = QWidget() - bar.setMaximumHeight(40) - h = QHBoxLayout(bar) - h.setContentsMargins(6, 4, 6, 4) - h.addWidget(QLabel("Show")) - row = QHBoxLayout() - row.setSpacing(0) - self.view_btns = [] - tips = ("The vase as it will look (Ctrl+1)", - "Cut in half through the middle: the single printed wall and the solid bottom (Ctrl+2)", - "How far each part of the wall leans: green is easy, orange near the limit, red needs " - "support (Ctrl+3)", - "On the printer bed, inside the space the printer can reach (Ctrl+4)") - for idx, text in enumerate(VIEW_NAMES): - b = QPushButton(text) - b.setCheckable(True) - b.setAutoExclusive(True) - b.setMinimumHeight(28) - b.setObjectName("modeButton") - b.setToolTip(tips[idx]) - b.clicked.connect(lambda _=False, i=idx: self.show_view(i)) - row.addWidget(b) - self.view_btns.append(b) - self.view_btns[self.view].setChecked(True) - h.addLayout(row) - h.addSpacing(10) - self.spin_btn = QPushButton("⟳ Spin") - self.spin_btn.setCheckable(True) - self.spin_btn.setChecked(self.spinning) - self.spin_btn.setObjectName("modeButton") - self.spin_btn.setMinimumHeight(28) - self.spin_btn.setToolTip("Turn the vase slowly on a turntable (S)") - self.spin_btn.toggled.connect(self.set_spin) - h.addWidget(self.spin_btn) - self.print_btn = QPushButton("▶ Watch it print") - self.print_btn.setCheckable(True) - self.print_btn.setObjectName("modeButton") - self.print_btn.setMinimumHeight(28) - self.print_btn.setToolTip("Build the vase up as the printer would, keeping to the real print time " - "(wide layers take longer) (F5)") - self.print_btn.toggled.connect(self.set_printing) - h.addWidget(self.print_btn) - h.addSpacing(10) - self.hint = QLabel("Drag to turn · wheel to zoom") - self.hint.setStyleSheet("color: #9aa3ad;") - h.addWidget(self.hint) - h.addStretch(1) - fit = QToolButton() - fit.setText("Reset view") - fit.setToolTip("Back to the chosen view (double-click the 3D view, or press F)") - fit.clicked.connect(lambda: self.show_view(self.view)) - h.addWidget(fit) - return bar - - def _make_widget(self, key, kind, lo, hi, step, extra): - def row(*parts): - box = QWidget() - hl = QHBoxLayout(box) - hl.setContentsMargins(0, 0, 0, 0) - hl.setSpacing(6) - for part in parts: - hl.addWidget(part, 0 if isinstance(part, QToolButton) else 1) - return box - - if kind == "widest": - s = DoubleSpinBox() - s.setDecimals(1) - s.setRange(lo, hi) - s.setSingleStep(step) - s.setSuffix(extra) - s.setKeyboardTracking(False) - s.setValue(self._widest()) - s.valueChanged.connect(lambda v: self._on_param("widest", float(v))) - self.widgets[key] = s - return s - if kind == "head": # not a setting of its own: it scales the design to fit - s = DoubleSpinBox() - s.setDecimals(1) - s.setRange(lo, hi) - s.setSingleStep(step) - s.setSuffix(extra) - s.setKeyboardTracking(False) - s.setValue(57.0) - s.valueChanged.connect(lambda v: self._on_param("head", float(v))) - self.widgets[key] = s - return s - if kind == "palette": - b = QToolButton() - b.setText("Choose a palette") - b.setPopupMode(QToolButton.InstantPopup) - b.setToolButtonStyle(Qt.ToolButtonTextOnly) - m = QMenu(b) - for name, cols in PALETTES: - a = m.addAction(palette_icon(cols), name) - a.triggered.connect(lambda _=False, n=name: self._apply_palette(n)) - b.setMenu(m) - b.setMinimumHeight(24) - self.widgets[key] = b - return b - if kind == "outline": # a drawn cross-section, and where to start one from - k = key[-1] - box = QWidget() - vl = QVBoxLayout(box) - vl.setContentsMargins(0, 0, 0, 0) - vl.setSpacing(4) - ed = OutlineEditor() - ed.set_outline(getattr(self.params, key), getattr(self.params, f"repeat{k}")) - ed.changed.connect(lambda pts, kk=int(k), e=ed: self._outline_edited(kk, pts, e, live=True)) - ed.finished.connect(lambda pts, kk=int(k), e=ed: self._outline_edited(kk, pts, e)) - start = QToolButton() - start.setText("Start from") - start.setPopupMode(QToolButton.InstantPopup) - m = QMenu(start) - for name, pts, repeat in sections.OUTLINES: - m.addAction(name).triggered.connect( - lambda _=False, kk=k, pp=pts, rr=repeat: self._use_outline(kk, pp, rr)) - m.addSeparator() - m.addAction("Trace a picture…").triggered.connect(lambda _=False, kk=k: self._trace_outline(kk)) - start.setMenu(m) - start.setToolTip("Start again from a ready-made outline, or trace one from a picture: dark lines " - "drawn round a shape on a light or see-through ground.") - close = QToolButton() - close.setText("Close up") - close.setCheckable(True) - close.setToolTip("Show one repeat close up, turned to point upwards - or the whole outline.\n" - f"Above {CLOSE_UP_FROM} repeats it starts close up.") - close.toggled.connect(ed.set_close_up) - ed.view_changed.connect(lambda kk=int(k): self._show_close_up(kk)) - big = QToolButton() - big.setText("Full window…") - big.setToolTip("Draw it in a window of its own, as big as the screen: the wheel zooms, a right-drag " - "moves it.") - big.clicked.connect(lambda _=False, kk=int(k): self._open_outline_window(kk)) - self.outline_menus[int(k)] = m - self.close_up_buttons[int(k)] = close - buttons = QHBoxLayout() - buttons.setContentsMargins(0, 0, 0, 0) - buttons.addWidget(start) - buttons.addWidget(close) - buttons.addWidget(big) - buttons.addStretch(1) - vl.addWidget(ed) - vl.addLayout(buttons) - self.widgets[key] = ed - return box - val = getattr(self.params, key) - if kind == "float": - suffix, _, special = extra.partition("|") - s = DoubleSpinBox() - s.setDecimals(2) - s.setRange(lo, hi) - s.setSingleStep(step) - s.setSuffix(suffix) - if special: - s.setSpecialValueText(special) - s.setValue(val) - s.setKeyboardTracking(False) - s.valueChanged.connect(lambda v, k=key: self._on_param(k, float(v))) - s.setMinimumWidth(110) - w = s - elif kind == "int" or kind == "seed": - suffix, _, special = extra.partition("|") - s = SpinBox() - s.setRange(int(lo), int(hi)) - s.setSingleStep(int(step)) - s.setSuffix(suffix) - if special: - s.setSpecialValueText(special) - s.setValue(int(val)) - s.setKeyboardTracking(False) - s.valueChanged.connect(lambda v, k=key: self._on_param(k, int(v))) - self.widgets[key] = s - if kind == "seed": - roll = QToolButton() - roll.setIcon(glyph_icon("dice")) - roll.setToolTip("Another one") - roll.clicked.connect(lambda _=False, ss=s: ss.setValue(random.randint(0, 99999))) - return row(s, roll) - return s - elif kind == "pct": - s = DoubleSpinBox() - s.setDecimals(0) - s.setRange(lo, hi) - s.setSingleStep(step) - s.setSuffix(extra) - s.setValue(100.0 * val) - s.setKeyboardTracking(False) - s.valueChanged.connect(lambda v, k=key: self._on_param(k, float(v) / 100.0)) - w = s - elif kind == "bool": - w = QCheckBox() - w.setChecked(val) - w.toggled.connect(lambda v, k=key: self._on_param(k, bool(v))) - elif kind == "choice": - w = ComboBox() - for value, label in lo: - w.addItem(label, value) - w.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon) - w.setMinimumContentsLength(12) - w.setCurrentIndex(max(0, w.findData(val))) - w.currentIndexChanged.connect(lambda i, k=key, ww=w: self._on_param(k, ww.itemData(i))) - elif kind == "color": - w = QPushButton() - w.setMinimumHeight(24) - w.clicked.connect(lambda _=False, k=key: self._pick_color(k)) - elif kind == "slider": - box = QWidget() - vl = QVBoxLayout(box) - vl.setContentsMargins(0, 0, 0, 0) - vl.setSpacing(1) - s = Slider(Qt.Horizontal) - s.setRange(int(round(lo * 100)), int(round(hi * 100))) - s.setPageStep(10) - s.setTickInterval(100) - s.setTickPosition(QSlider.TicksBelow) - s.setValue(int(round(val * 100))) - s.valueChanged.connect(lambda v, k=key: self._on_param(k, v / 100.0, live=True)) - s.sliderReleased.connect(lambda: self._on_param(key, s.value() / 100.0)) - self.slider_notes[key] = QLabel() - self.slider_notes[key].setStyleSheet("color: #9aa3ad;") - vl.addWidget(s) - vl.addWidget(self.slider_notes[key]) - self.widgets[key] = s - return box - elif kind == "curve": - w = CurveEditor(**lo) - w.set_points(val) - w.changed.connect(lambda pts, k=key: self._on_param(k, pts, live=True)) - w.finished.connect(lambda pts, k=key: self._on_param(k, pts)) - elif kind == "expr": - box = QWidget() - vl = QVBoxLayout(box) - vl.setContentsMargins(0, 0, 0, 0) - vl.setSpacing(4) - t = QPlainTextEdit(val) - mono = QFontDatabase.systemFont(QFontDatabase.FixedFont) - t.setFont(mono) - t.setPlaceholderText("e.g. 1.5 * sin(6*theta + 4*pi*h)") - t.setTabChangesFocus(True) - t.setFixedHeight(54) - t.textChanged.connect(lambda k=key, tt=t: self._on_param(k, tt.toPlainText())) - self.widgets[key] = t - ex = QToolButton() - ex.setText("Examples") - ex.setPopupMode(QToolButton.InstantPopup) - m = QMenu(ex) - for name, formula in EXAMPLES: - a = m.addAction(f"{name}: {formula}") - a.triggered.connect(lambda _=False, f=formula: self._set_expression(f)) - m.addSeparator() - m.addAction("Clear").triggered.connect(lambda: self._set_expression("")) - ex.setMenu(m) - helpb = QToolButton() - helpb.setText("Help…") - helpb.clicked.connect(self.function_help) - buttons = QHBoxLayout() - buttons.setContentsMargins(0, 0, 0, 0) - buttons.addWidget(ex) - buttons.addWidget(helpb) - buttons.addStretch(1) - vl.addWidget(t) - vl.addLayout(buttons) - return box - else: - raise ValueError(kind) - self.widgets[key] = w - return w - - def _widest(self) -> float: - return max(d for _, d in self.params.silhouette) - - def _refresh_widgets(self): - """Push self.params into the widgets without triggering updates.""" - for key, w in self.widgets.items(): - w.blockSignals(True) - if key == "widest": - w.setValue(self._widest()) - elif key in ("palette", "head"): # the head size follows the design once it is built - pass - elif isinstance(w, QComboBox): - w.setCurrentIndex(max(0, w.findData(getattr(self.params, key)))) - elif isinstance(w, QCheckBox): - w.setChecked(bool(getattr(self.params, key))) - elif isinstance(w, QPlainTextEdit): - if w.toPlainText() != getattr(self.params, key): - w.setPlainText(getattr(self.params, key)) - elif isinstance(w, QSpinBox): - w.setValue(int(getattr(self.params, key))) - elif isinstance(w, QDoubleSpinBox): - v = getattr(self.params, key) - w.setValue(100.0 * v if w.suffix() == " %" else v) - elif isinstance(w, QSlider): - w.setValue(int(round(getattr(self.params, key) * 100))) - elif isinstance(w, CurveEditor): - w.set_points(getattr(self.params, key)) - elif isinstance(w, OutlineEditor): - w.set_outline(getattr(self.params, key), getattr(self.params, f"repeat{key[-1]}")) - w.blockSignals(False) - for k in (1, 2): - self._push_outline(k) - self.name_edit.blockSignals(True) - self.name_edit.setText(self.params.name) - self.name_edit.blockSignals(False) - self._update_title() - self._sync_widgets() - - def _fresh_params(self) -> VaseParams: - """Default design, using the printer settings remembered from last time.""" - p = VaseParams() - # a first run borrows the printer from Maze Tube Designer or Lenticular Sign Maker - others = (QSettings("MazeTube", "MazeTubeDesigner"), QSettings("LenticularSign", "LenticularSignMaker")) - for key in REMEMBERED: - for v in (self.settings.value(f"default_{key}"),) + tuple(o.value(f"default_{key}") for o in others): - try: - if v is not None: - setattr(p, key, float(v)) - break - except (TypeError, ValueError): - pass - return p - - def _sync_widgets(self): - """Colour swatches, labels and greyed-out settings that follow other settings.""" - p = self.params - for k in FILAMENTS: - color = getattr(p, f"color{k}") - btn = self.widgets[f"color{k}"] - dark = QColor(color).lightnessF() < 0.55 - btn.setText(color.upper()) - btn.setStyleSheet(f"background: {color}; color: {'white' if dark else 'black'}; " - f"border: 1px solid #3b4049; border-radius: 3px;") - for k in (1, 2): - kind = getattr(p, f"shape{k}") - names = SHAPE_LABELS.get(kind, (None, None, None)) - for key, name in zip((f"count{k}", f"depth{k}", f"sharp{k}"), names): - if name: - self.labels[key].setText(name) - self.widgets[f"count{k}"].setMinimum(3 if kind == POLYGON else 1) - # a real spiral vase prints one filament: only the colour styles that need no change of filament - real = p.vase_mode == REAL_VASE - styles = REAL_COLOUR_STYLES if real else COLOUR_STYLES - combo = self.widgets["colour_style"] - combo.blockSignals(True) - if [(combo.itemData(i), combo.itemText(i)) for i in range(combo.count())] != styles: - combo.clear() - for value, text in styles: - combo.addItem(text, value) - combo.setCurrentIndex(max(0, combo.findData(colour_style_of(p)))) - combo.blockSignals(False) - self.labels["color1"].setText("Colour" if colour_style_of(p) == ONE_COLOUR else "Filament 1") - s = p.band_spacing - self.slider_notes["band_spacing"].setText( - "Even" if abs(s) < 0.05 else ("Thicker towards the top" if s > 0 else "Thicker towards the bottom")) - for k, win in self.outline_windows.items(): # a shape no longer drawn has nothing to draw - if win.isVisible() and getattr(p, f"shape{k}") != DRAWN: - win.close() - if hasattr(self, "simple"): - titles = {slug(t): t for t, _, _ in GALLERY} - titles["plain_cylinder"] = "Plain cylinder" - self.simple.refresh(p, self._widest(), titles.get(p.name, "")) - if hasattr(self, "a_lid"): - self.a_lid.setEnabled(p.box) - self._apply_mode() - - def _wanted(self, key) -> bool: - return wanted(self.params, key) - - def _apply_mode(self): - for key, (form, field) in self._rows.items(): - form.setRowVisible(field, self._wanted(key)) - for box, keys in self._groups: - box.setVisible(any(self._wanted(k) for k in keys)) - - def set_simple_mode(self, simple: bool, remember: bool = True): - self.simple_mode = simple - (self.simple_btn if simple else self.detail_btn).setChecked(True) - self.panels.setCurrentIndex(0 if simple else 1) - if remember: - self.settings.setValue("simple_mode", simple) - self._apply_mode() - - def _pick_color(self, key): - c = QColorDialog.getColor(QColor(getattr(self.params, key)), self, "Filament colour") - if c.isValid(): - self._on_param(key, c.name().upper()) - - def _apply_palette(self, name): - for k, v in palette(name).items(): - setattr(self.params, k, v) - self._on_param("color1", self.params.color1) - - def _set_expression(self, formula): - self.widgets["expression"].setPlainText(formula) # its textChanged updates the design - - def function_help(self): - FunctionHelp(self, self._set_expression).exec() - - def _build_actions(self): - st = self.style() - tb = self.addToolBar("Main") - tb.setMovable(False) - tb.setIconSize(QSize(20, 20)) - tb.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - - def act(text, slot, icon=None, shortcut=None, tip=None): - a = QAction(text, self) - if isinstance(icon, QIcon): - a.setIcon(icon) - elif icon is not None: - a.setIcon(st.standardIcon(icon)) - if shortcut: - a.setShortcut(QKeySequence(shortcut)) - if tip: - a.setToolTip(tip) - a.setStatusTip(tip) - a.triggered.connect(slot) - return a - - self.a_new = act("New", self.new_design, QStyle.SP_FileIcon, "Ctrl+N", "Start again from the default vase") - self.a_plain = act("New plain cylinder", self.use_plain, None, "Ctrl+Shift+N", - "Start from a plain cylinder, 200 mm across and 200 mm tall, with everything else off - " - "ready for your own function or a drawn cross-section") - self.a_open = act("Open…", self.open_design, QStyle.SP_DialogOpenButton, "Ctrl+O", "Open a saved vase design") - self.a_save = act("Save", self.save_design, QStyle.SP_DialogSaveButton, "Ctrl+S", "Save the design") - self.a_save_as = act("Save As…", lambda: self.save_design(as_new=True), None, "Ctrl+Shift+S") - self.a_exp_stl = act("Export STL…", self.export_stl, glyph_icon("export"), "Ctrl+E", - "The vase as one solid, watertight STL, for spiral vase mode") - self.a_exp_3mf = act("Export 3MF…", self.export_3mf, None, "Ctrl+Shift+E", - "The vase as a 3MF, in the middle of the bed, set to its first filament") - self.a_snap = act("Save picture…", self.save_snapshot, glyph_icon("picture"), "Ctrl+P", - "Save the 3D view as a PNG image") - self.a_print = act("Save for printing…", self.save_for_printing, glyph_icon("export"), "Ctrl+Shift+P", - "Save the vase for your printer's slicer, with how to print it") - self.a_lid = act("Save the lid for printing…", self.save_lid, None, None, - "A tissue box cover's lid for its open end, to print on its own: it hooks in at one end " - "and clicks in at the other") - self.a_slicer = act("Open in slicer", lambda: self.open_in_slicer(), None, "Ctrl+L", - "Open the vase saved for printing in your slicer") - self.a_pick_slicer = act("Choose slicer program…", lambda: self.choose_slicer(), None, None, - "Which program Open in slicer uses") - self.a_gallery = act("Choose a design…", self.choose_design, glyph_icon("gallery"), "Ctrl+G", - "Pictures of every ready-made vase, and a plain cylinder to start from") - self.a_big = act("Bigger text", lambda on: self._set_look(big=on), None, "Ctrl+=", - "Make all the writing bigger") - self.a_light = act("Light theme", lambda on: self._set_look(light=on), None, None, - "Dark writing on a light background") - for a, key in ((self.a_big, "big_text"), (self.a_light, "light_theme")): - a.setCheckable(True) - a.setChecked(self.settings.value(key, False, type=bool)) - self.a_quit = act("Exit", self.close, None, "Ctrl+Q") - self.a_undo = act("Undo", self.undo, glyph_icon("undo"), "Ctrl+Z", "Undo the last change") - self.a_redo = act("Redo", self.redo, glyph_icon("redo"), "Ctrl+Y", "Redo") - self.a_redo.setShortcuts([QKeySequence("Ctrl+Y"), QKeySequence("Ctrl+Shift+Z")]) - self.a_surprise = act("Surprise me", self.surprise_me, glyph_icon("dice"), "Ctrl+R", - "A new random vase that still prints without supports") - self.a_copy_plan = act("Copy colour changes", self.copy_colour_plan, None, "Ctrl+Shift+C", - "Copy the list of filament changes, to keep beside the slicer") - self.a_about = act("About", self.about) - self.a_printing = act("Printing it watertight…", self.printing_help) - self.a_function = act("Your own function…", self.function_help) - self.a_drawing = act("Drawing a cross-section…", self.drawing_help) - - tb.addAction(self.a_new) - tb.addAction(self.a_open) - tb.addAction(self.a_save) - tb.addSeparator() - tb.addAction(self.a_print) - exp_btn = QToolButton() - exp_btn.setDefaultAction(self.a_exp_stl) - exp_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - exp_btn.setPopupMode(QToolButton.MenuButtonPopup) - m = QMenu(exp_btn) - m.addAction(self.a_exp_stl) - m.addAction(self.a_exp_3mf) - m.addSeparator() - m.addAction(self.a_lid) - exp_btn.setMenu(m) - tb.addWidget(exp_btn) - tb.addAction(self.a_snap) - tb.addSeparator() - tb.addAction(self.a_surprise) - gal_btn = self.gal_btn = QToolButton() - gal_btn.setText("Gallery") - gal_btn.setIcon(glyph_icon("gallery")) - gal_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - gal_btn.setToolTip("Pictures of every ready-made vase to start from (Ctrl+G)") - gal_btn.clicked.connect(self.choose_design) - self.gallery_menu = QMenu(self) - self.gallery_menu.addAction(self.a_gallery) - self.gallery_menu.addSeparator() - self.gallery_menu.addAction(self.a_plain) - self.gallery_menu.addSeparator() - for title, about, _ in GALLERY: - a = self.gallery_menu.addAction(title) - a.setToolTip(about) - a.setStatusTip(about) - a.triggered.connect(lambda _=False, t=title: self.use_gallery(t)) - self.gallery_menu.setToolTipsVisible(True) - tb.addWidget(gal_btn) - tb.addSeparator() - tb.addAction(self.a_undo) - tb.addAction(self.a_redo) - - mb = self.menuBar() - fm = mb.addMenu("&File") - for a in (self.a_new, self.a_plain, self.a_open, self.a_save, self.a_save_as, None, self.a_print, - self.a_lid, self.a_slicer, self.a_pick_slicer, None, self.a_exp_stl, self.a_exp_3mf, self.a_snap, None, - self.a_quit): - fm.addAction(a) if a is not None else fm.addSeparator() - em = mb.addMenu("&Edit") - for a in (self.a_undo, self.a_redo, None, self.a_surprise, None, self.a_copy_plan): - em.addAction(a) if a is not None else em.addSeparator() - dm = mb.addMenu("&Designs") - dm.addActions(self.gallery_menu.actions()) - vm = mb.addMenu("&View") - self.view_actions = [] - for i, name in enumerate(VIEW_NAMES): - a = QAction(name, self) - a.setShortcut(QKeySequence(f"Ctrl+{i + 1}")) - a.triggered.connect(lambda _=False, i=i: self.show_view(i)) - vm.addAction(a) - self.view_actions.append(a) - vm.addSeparator() - a = QAction("Spin", self) - a.setShortcut(QKeySequence("S")) - a.triggered.connect(self.spin_btn.toggle) - vm.addAction(a) - a = QAction("Watch it print", self) - a.setShortcut(QKeySequence("F5")) - a.triggered.connect(self.print_btn.toggle) - vm.addAction(a) - a = QAction("Reset view", self) - a.setShortcut(QKeySequence("Ctrl+0")) - a.triggered.connect(lambda: self.show_view(self.view)) - vm.addAction(a) - vm.addSeparator() - vm.addAction(self.a_big) - vm.addAction(self.a_light) - hm = mb.addMenu("&Help") - hm.addAction(self.a_printing) - hm.addAction(self.a_function) - hm.addAction(self.a_drawing) - hm.addSeparator() - hm.addAction(self.a_about) - - # A coffee cup after Help, for anyone who would like to chip in. - self.a_kofi = QAction(glyph_icon("coffee"), "", self) - self.a_kofi.setToolTip("Vaseinator is free — buy me a coffee") - self.a_kofi.setStatusTip("Vaseinator is free — buy me a coffee") - self.a_kofi.triggered.connect(lambda: QDesktopServices.openUrl(QUrl(KOFI_URL))) - mb.addAction(self.a_kofi) - self._update_undo() - - # ============================================================ params - def _on_param(self, key, value, live=False): - if key == "head": - self._set_head(value) - return - touched, message = apply_change(self.params, key, value) - for k in touched: - self._set_widget(k) - if message: - self.statusBar().showMessage(message, 8000) - if key in REMEMBERED: - self.settings.setValue(f"default_{key}", float(value)) - self._sync_widgets() - self._dirty = True - self._update_title() - d = self.design - if key in ("lid_grip", "lid_open") and d is not None and not d.draft and not self.regen_timer.isActive(): - for q in (d.p, d.asked): # the lid's own: the cover itself stays as it is - setattr(q, key, getattr(self.params, key)) - self._remember() - return - self._schedule(live=live, text=key in TEXT_KEYS) - - def _set_head(self, cm: float): - """Scale the design to fit a head ``cm`` round.""" - QApplication.setOverrideCursor(Qt.WaitCursor) - try: - fitted = fit_head(self.params, 10.0 * cm) - finally: - QApplication.restoreOverrideCursor() - self.apply_changes({k: getattr(fitted, k) for k in ("silhouette",) + HEAD_SCALED}) - self.statusBar().showMessage(f"Sized for a head {cm:g} cm round — Ctrl+Z to go back", 6000) - - def _show_head(self, d: VaseDesign): - """The head the design fits, in the head size boxes.""" - cm = round(d.head_fit / 10.0, 1) - w = self.widgets["head"] - w.blockSignals(True) - w.setValue(cm) - w.blockSignals(False) - self.simple.show_head(cm) - - def _set_widget(self, key): - w = self.widgets[key] - w.blockSignals(True) - if key == "widest": - w.setValue(self._widest()) - elif isinstance(w, CurveEditor): - w.set_points(getattr(self.params, key)) - elif isinstance(w, OutlineEditor): - w.blockSignals(False) - self._push_outline(int(key[-1])) - elif isinstance(w, QComboBox): - w.setCurrentIndex(max(0, w.findData(getattr(self.params, key)))) - elif isinstance(w, QSpinBox): - w.setValue(int(getattr(self.params, key))) - if key in ("repeat1", "repeat2"): - self._push_outline(int(key[-1])) - elif isinstance(w, QDoubleSpinBox): - v = getattr(self.params, key) - w.setValue(100.0 * v if w.suffix() == " %" else v) - elif isinstance(w, QCheckBox): - w.setChecked(bool(getattr(self.params, key))) - w.blockSignals(False) - - # ----------------------------------------------------- drawn outlines - def _outline_edited(self, k: int, pts, source, live=False): - """An editor changed drawn outline k: the vase follows, and so does the other editor.""" - self._on_param(f"drawn{k}", pts, live=live) - self._push_outline(k, source) - - def _push_outline(self, k: int, source=None): - """Show drawn outline k in its editors - the one in the panel and any full window - but the one it came - from.""" - p = self.params - pts, repeat = getattr(p, f"drawn{k}"), getattr(p, f"repeat{k}") - ed = self.widgets.get(f"drawn{k}") - if ed is not None and ed is not source: - ed.set_outline(pts, repeat) - win = self.outline_windows.get(k) - if win is not None and win.editor is not source: - win.set_outline(pts, repeat) - - def _show_close_up(self, k: int): - ed, b = self.widgets[f"drawn{k}"], self.close_up_buttons[k] - b.blockSignals(True) - b.setEnabled(ed.repeat > 1) - b.setChecked(ed.close_up()) - b.blockSignals(False) - - def _open_outline_window(self, k: int): - """The cross-section editor in a window of its own, as big as the screen.""" - win = self.outline_windows.get(k) - if win is None: - title = "Draw the cross-section" if k == 1 else "Draw the cross-section it turns into" - win = OutlineWindow(self, f"{title} — {APP_NAME}", self.outline_menus[k]) - win.editor.changed.connect(lambda pts, kk=k, e=win.editor: self._outline_edited(kk, pts, e, live=True)) - win.editor.finished.connect(lambda pts, kk=k, e=win.editor: self._outline_edited(kk, pts, e)) - win.repeat_changed.connect(lambda v, kk=k: self._on_param(f"repeat{kk}", int(v))) - self.outline_windows[k] = win - win.set_outline(getattr(self.params, f"drawn{k}"), getattr(self.params, f"repeat{k}")) - win.showMaximized() - win.raise_() - win.activateWindow() - - def _outline_from_shape(self, k: int): - outline_from_shape(self.params, k) - self._set_widget(f"repeat{k}") - self._set_widget(f"drawn{k}") - - def _use_outline(self, k: int, pts, repeat: int): - """Start drawn outline k again from given control points.""" - setattr(self.params, f"repeat{k}", int(repeat)) - self._set_widget(f"repeat{k}") - self._on_param(f"drawn{k}", [list(q) for q in pts]) - self._set_widget(f"drawn{k}") - - def _trace_outline(self, k: int): - start = self.settings.value("picture_dir", self._last_dir()) - path, _ = QFileDialog.getOpenFileName(QApplication.activeWindow() or self, "Trace a picture", start, - "Pictures (*.png *.jpg *.jpeg *.bmp *.gif *.webp);;All files (*)") - if not path: - return - self.settings.setValue("picture_dir", os.path.dirname(path)) - try: - pts = sections.trace_picture(path) - except (ValueError, OSError) as exc: - QMessageBox.warning(self, "Can't trace that picture", - f"{os.path.basename(path)}: {exc}. Draw the outline as one closed dark line on a " - "light or see-through ground.") - return - self._note = (f"Traced {os.path.basename(path)}: {len(pts)} points, " - f"{sum(q[2] for q in pts)} sharp corners. ") - self._use_outline(k, pts, 1) - - def _schedule(self, live=False, text=False): - """Rebuild after a pause; while something is being dragged, quick rough builds follow the hand.""" - if text: - self.regen_timer.start(600) - return - slow = self._last_full_ms > 220 - if live or slow: - self.draft_timer.start(15) - self.regen_timer.start(450 if live else 300) - else: - self.regen_timer.start(120) - - def _on_name(self, text): - self.params.name = text.strip() or "vase" - self._dirty = True - self._update_title() - - def _update_title(self): - f = os.path.basename(self.current_file) if self.current_file else "unsaved" - star = " •" if self._dirty else "" - self.setWindowTitle(f"{APP_NAME} — {self.params.name} ({f}){star}") - - # ======================================================= undo / redo - def _remember(self): - d = self.params.to_dict() - if not self.history or self.history[-1] != d: - self.history.append(d) - del self.history[:-200] - self.future.clear() - self._update_undo() - - def _update_undo(self): - if hasattr(self, "a_undo"): - self.a_undo.setEnabled(len(self.history) > 1) - self.a_redo.setEnabled(bool(self.future)) - - def _restore(self, d): - self.params = VaseParams.from_dict(d) - self._dirty = True - self._refresh_widgets() - self.regenerate() - - def _flush(self): - """Build now if a change is still waiting.""" - if self.regen_timer.isActive() or self.draft_timer.isActive(): - self.regen_timer.stop() - self.draft_timer.stop() - self.regenerate() - - def undo(self): - self._flush() - if len(self.history) < 2: - return - self.future.append(self.history.pop()) - self._restore(self.history[-1]) - self._update_undo() - - def redo(self): - self._flush() - if not self.future: - return - d = self.future.pop() - self.history.append(d) - self._restore(d) - self._update_undo() - - def surprise_me(self, seed=None): - self._flush() - seed = random.randint(1, 999_999) if seed is None or seed is False else int(seed) - self.params = surprise(self.params, seed) - self._dirty = True - self._refresh_widgets() - self.regenerate() - self.statusBar().showMessage(f"Surprise #{seed}: {self.params.name.replace('_', ' ')} — Ctrl+Z to go " - "back", 8000) - - def apply_changes(self, changes: dict, live=False): - """Several settings at once - a picture picked in Simple mode.""" - p = self.params - for key, value in changes.items(): - setattr(p, key, value) - if "profile" in changes and p.profile != OWN_PROFILE: - p.silhouette = outline(p.profile, self._widest()) - self._dirty = True - self._refresh_widgets() - self._schedule(live=live) - - def choose_design(self): - """Every ready-made vase as a picture to click.""" - def entry(title, about, p, picture): - pts = np.asarray(p.silhouette, dtype=float) - return title, about, asset("gallery", picture), profile_icon(pts, QColor(p.color1)) - entries = [entry("Plain cylinder", "200 mm across and 200 mm tall, with everything else off: a blank " - "start for your own shape or function", plain_design(), "plain_cylinder.png")] - entries += [entry(t, about, gallery_design(t), slug(t) + ".png") for t, about, _ in GALLERY] - dlg = GalleryDialog(self, entries) - if dlg.exec() == QDialog.Accepted and dlg.chosen: - if dlg.chosen == "Plain cylinder": - self.use_plain() - else: - self.use_gallery(dlg.chosen) - - def draw_own_shape(self): - """Draw the shape from above, in a window of its own, starting from the shape it is.""" - if self.params.shape1 != DRAWN: - self._on_param("shape1", DRAWN) - self._flush() - self._open_outline_window(1) - - def _set_look(self, big=None, light=None): - """Bigger text, and the light or dark theme - remembered for next time.""" - if big is not None: - self.settings.setValue("big_text", bool(big)) - if light is not None: - self.settings.setValue("light_theme", bool(light)) - apply_theme(QApplication.instance(), light=self.settings.value("light_theme", False, type=bool), - scale=1.3 if self.settings.value("big_text", False, type=bool) else 1.0) - # the line icons are drawn in the theme's ink: draw them again - for a, kind in ((self.a_exp_stl, "export"), (self.a_print, "export"), (self.a_snap, "picture"), - (self.a_surprise, "dice"), (self.a_undo, "undo"), (self.a_redo, "redo"), - (self.a_gallery, "gallery"), (self.a_kofi, "coffee")): - a.setIcon(glyph_icon(kind)) - self.gal_btn.setIcon(glyph_icon("gallery")) - self.dice_btn.setIcon(glyph_icon("dice")) - - def use_plain(self): - """A blank start: the plain cylinder, keeping the printer settings.""" - self._flush() - self.params = plain_design(keep=self.params) - self._dirty = True - self._refresh_widgets() - self.regenerate() - self.statusBar().showMessage("Plain cylinder, 200 mm across and 200 mm tall - type your own function, or " - "choose Drawn as the cross-section; Ctrl+Z to go back", 12000) - - def use_gallery(self, title): - self._flush() - self.params = gallery_design(title, keep=self.params) - self._dirty = True - self._refresh_widgets() - self.regenerate() - self.statusBar().showMessage(f"{title} — your printer settings are kept; Ctrl+Z to go back", 8000) - - # ============================================================ build - def regenerate(self, draft=False): - if not draft: - self.draft_timer.stop() - t0 = time.perf_counter() - QApplication.setOverrideCursor(Qt.WaitCursor) - try: - d = VaseDesign(self.params.copy(), draft=draft) - except Exception as exc: # pragma: no cover - safety net for the GUI - self._show_info(None, [f"Internal error: {exc}"], []) - return - finally: - QApplication.restoreOverrideCursor() - if not draft: - self._remember() - if d.errors: - self._show_info(None, d.errors, d.warnings) - self.statusBar().showMessage("This vase cannot be built - see the panel on the right") - return - self.design = d - self._show_design(d) - self._show_head(d) - dt = (time.perf_counter() - t0) * 1000 - if not draft: - self._last_full_ms = dt - self._show_info(d, [], d.warnings) - note, self._note = self._note, "" - self.statusBar().showMessage(f"{note}Built in {dt:.0f} ms — {d.triangles:,} triangles", - 10000 if note else 6000) - - def _show_design(self, d: VaseDesign): - colors = d.p.colors() - phase = d.colour_phase(d.shell_verts[:, 2]) if d.gradient else None - self.viewer.set_mesh("vase", d.shell_verts, d.shell_faces, colors[d.bands[0][1]], - face_vals=d.shell_lean, vert_vals=phase, flat=d.rows is not None) - self.viewer.set_section_source(d.shell_verts, d.shell_faces) - self.viewer.set_bed(d.p.bed_x, d.p.bed_y, d.p.printer_height) - self.plots.set_design(d, colors) - self._apply_view() - if self.printing is not None: - self._anim_tick() - - def _show_info(self, d, errors, warnings): - self.info.setHtml(info_html(d, errors, warnings)) - - - # ============================================================ view - def show_view(self, idx): - self.view = idx - self._free_camera = False - self._fitted = None - self.view_btns[idx].setChecked(True) - self._apply_view() - - def _apply_view(self): - d, v = self.design, self.viewer - if d is None: - return - p = d.p - v.show_bed = self.view == PRINT_LAYOUT - v.section = self.view == SECTION - colors = p.colors() - v.bands = [(z, colors[f]) for z, f in d.bands] - v.gradient = [colors[f] for f in d.gradient_fils] - v.colmode = 3 if self.view == OVERHANGS else (2 if d.gradient else 1) - v.limit = p.max_overhang - v.look = p.look - if self.view == OVERHANGS: - self.hint.setText(f"Green: easy · orange: near {p.max_overhang:g}° · red: over it, needs support") - elif self.printing is None: - self.hint.setText("Drag to turn · wheel to zoom · right-drag to move") - size = (round(float(d.size[0]), 0), round(float(d.size[1]), 0), round(d.H, 0)) - if self._fitted != (self.view, size): - r = 0.5 * math.hypot(d.size[0], d.size[1]) - reach = math.hypot(r, 0.5 * d.H) - aspect = max(v.width(), 1) / max(v.height(), 1) - fov = math.radians(v.fov) * min(1.0, aspect) - v.target = np.array([0.0, 0.0, 0.5 * d.H]) - v.dist = reach / math.sin(fov / 2.0) * (1.75 if self.view == PRINT_LAYOUT else 1.12) - self._fitted = (self.view, size) - if not self._free_camera: - v.yaw, v.pitch = {VASE: (-60.0, 14.0), SECTION: (-90.0, 8.0), OVERHANGS: (-60.0, 14.0), - PRINT_LAYOUT: (-55.0, 32.0)}[self.view] - v.set_model(spin_transform(self.spin_angle)) - v.update() - - def _on_dragged(self): - self._free_camera = True - - def _on_resized(self): - if not self._free_camera: - self._fitted = None - self._apply_view() - - # ------------------------------------------------- spin and printing - def set_spin(self, on: bool): - self.spinning = bool(on) - self.settings.setValue("spin", self.spinning) - self._update_anim() - - def set_printing(self, on: bool): - if on and self.design is not None: - self.printing = time.perf_counter() - else: - self.printing = None - self.viewer.cut_z = None - self.viewer.set_lines(None) - self._apply_view() - self._update_anim() - - def _update_anim(self): - if self.spinning or self.printing is not None: - self._last_tick = time.perf_counter() - self.anim_timer.start() - else: - self.anim_timer.stop() - - def _print_length(self): - d = self.design - return float(np.clip(6.0 + d.H / 30.0, 6.0, 16.0)) - - def _anim_tick(self): - now = time.perf_counter() - dt, self._last_tick = now - self._last_tick, now - d, v = self.design, self.viewer - if d is None: - return - if self.spinning: - self.spin_angle = (self.spin_angle + SPIN_SPEED * min(dt, 0.1)) % 360.0 - v.set_model(spin_transform(self.spin_angle)) - if self.printing is not None: - span = self._print_length() - t = (now - self.printing) / span - if t > 1.25: # hold the finished vase a moment, then again - self.printing = now - t = 0.0 - t = min(t, 1.0) - # the real print time: the filament used up to each height, at an even speed - done = d.filament_at[-1] * t - z = float(np.interp(done, d.filament_at, d.filament_z)) if d.filament_at[-1] > 0 else t * d.H - z = min(max(z, 0.05), d.H) - v.cut_z = z if t < 1.0 else None - v.set_lines(d.contour(z) if t < 1.0 else None) - layer = min(int(math.ceil(z / d.p.layer_height)), d.layers) - self.hint.setText(f"Printing: layer {layer} of {d.layers} · {duration(d.seconds * t)} of " - f"{duration(d.seconds)}" if t < 1.0 else f"Done: {duration(d.seconds)}, " - f"{d.grams:.0f} g of filament") - v.update() - - # ============================================================ files - def _last_dir(self): - return self.settings.value("last_dir", os.path.join(os.path.expanduser("~"), "Documents")) - - def _remember_dir(self, path): - self.settings.setValue("last_dir", os.path.dirname(path)) - - def _safe_name(self): - name = "".join(ch if ch.isalnum() or ch in "-_ " else "_" for ch in self.params.name).strip() - return name or "vase" - - def new_design(self): - if not self._confirm_discard(): - return - self.params = self._fresh_params() - self.current_file = None - self._dirty = False - self._refresh_widgets() - self.regenerate() - - def _confirm_discard(self): - if not self._dirty: - return True - r = QMessageBox.question(self, "Unsaved changes", "Discard unsaved changes to the current design?", - QMessageBox.Discard | QMessageBox.Cancel, QMessageBox.Cancel) - return r == QMessageBox.Discard - - def open_design(self): - if not self._confirm_discard(): - return - path, _ = QFileDialog.getOpenFileName(self, "Open design", self._last_dir(), - f"Vaseinator designs (*{FILE_EXT} *.json);;All files (*)") - if path: - self.load_file(path) - - def load_file(self, path): - try: - params = load_design(path) - except Exception as exc: - QMessageBox.critical(self, "Open failed", f"Could not open the design:\n{exc}") - return - self._remember_dir(path) - self.params = params - self.current_file = path - self._dirty = False - self._refresh_widgets() - self.regenerate() - self.statusBar().showMessage(f"Opened {path}", 6000) - - def save_design(self, as_new=False): - path = self.current_file - if as_new or not path: - default = os.path.join(self._last_dir(), self._safe_name() + FILE_EXT) - path, _ = QFileDialog.getSaveFileName(self, "Save design", default, f"Vaseinator designs (*{FILE_EXT})") - if not path: - return - if not os.path.splitext(path)[1]: - path += FILE_EXT - try: - save_design(path, self.params) - except OSError as exc: - QMessageBox.critical(self, "Save failed", str(exc)) - return - self._remember_dir(path) - self.current_file = path - self._dirty = False - self._update_title() - self.statusBar().showMessage(f"Saved {path}", 6000) - - def _current_design(self): - """The design to export, rebuilt in full first if a change is still waiting.""" - self._flush() - d = self.design - if d is not None and d.draft: - self.regenerate() - d = self.design - if d is None or {**d.asked.to_dict(), "name": ""} != {**self.params.to_dict(), "name": ""}: - QMessageBox.warning(self, "Nothing to export", "The current settings do not make a vase.") - return None - if not d.closed: - QMessageBox.warning(self, "Not exported", "The mesh is not closed, so it would not print as one " - "watertight wall. Please report this design.") - return None - return d - - def export_stl(self): - d = self._current_design() - if d is None: - return - base = self._safe_name() - if not base.endswith("_vase"): - base += "_vase" # make it obvious this is a solid model for spiral vase mode - path, _ = QFileDialog.getSaveFileName(self, "Export STL", os.path.join(self._last_dir(), base + ".stl"), - "STL files (*.stl)") - if not path: - return - if not path.lower().endswith(".stl"): - path += ".stl" - try: - QApplication.setOverrideCursor(Qt.WaitCursor) - try: - verts, faces = d.export_mesh() - finally: - QApplication.restoreOverrideCursor() - write_stl(path, verts, faces, base) - except OSError as exc: - QMessageBox.critical(self, "Export failed", str(exc)) - return - self._remember_dir(path) - if d.p.vase_mode == REAL_VASE: - how = "slice it with spiral vase mode on" - else: - how = ("the hollow pot: slice it with your usual settings (an STL has no colours: Export 3MF has the " - "bands as parts)") - self.statusBar().showMessage( - f"Exported {os.path.basename(path)} ({len(faces):,} triangles, watertight) to " - f"{os.path.dirname(path)} — {how}", 12000) - - def band_parts(self, d): - """The vase for write_3mf: a part per colour band, set to its filament.""" - colors = d.p.colors() - QApplication.setOverrideCursor(Qt.WaitCursor) - try: - parts = d.export_parts() - finally: - QApplication.restoreOverrideCursor() - many = len(parts) > 1 - return [(f"{self._safe_name()} band {k + 1}" if many else self._safe_name(), verts, faces, fil, colors[fil]) - for k, (fil, verts, faces) in enumerate(parts)] - - def export_3mf(self): - """The vase for the slicer: one solid for a real spiral vase, a part per colour band for a fake one.""" - d = self._current_design() - if d is None: - return - p = d.p - base = self._safe_name() - path, _ = QFileDialog.getSaveFileName(self, "Export 3MF", os.path.join(self._last_dir(), base + ".3mf"), - "3MF files (*.3mf)") - if not path: - return - if not path.lower().endswith(".3mf"): - path += ".3mf" - parts = self.band_parts(d) - try: - write_3mf(path, base, parts, (p.bed_x / 2.0, p.bed_y / 2.0, 0.0)) - except OSError as exc: - QMessageBox.critical(self, "Export failed", str(exc)) - return - self._remember_dir(path) - if p.vase_mode == REAL_VASE: - how = "turn on spiral vase" - else: - how = "leave spiral vase off and print with your usual settings" - if len(parts) > 1: - fils = ", ".join(str(f) for f in sorted({f for _, _, _, f, _ in parts})) - what = f"{len(parts)} bands set to filaments {fils}" - else: - what = "the vase, one part" - self.statusBar().showMessage( - f"Exported {os.path.basename(path)}: {what} — in Flash Studio use File ▸ Import (Ctrl+I), then {how}", - 15000) - - def save_for_printing(self): - """The one button Simple mode needs: the vase as a 3MF for the slicer, and plain steps to print it.""" - d = self._current_design() - if d is None: - return - base = self._safe_name() - path, _ = QFileDialog.getSaveFileName(self, "Save for printing", os.path.join(self._last_dir(), base + ".3mf"), - "3MF files, for the slicer (*.3mf)") - if not path: - return - if not path.lower().endswith(".3mf"): - path += ".3mf" - parts = self.band_parts(d) - try: - write_3mf(path, base, parts, (d.p.bed_x / 2.0, d.p.bed_y / 2.0, 0.0)) - except OSError as exc: - QMessageBox.critical(self, "Couldn't save it", str(exc)) - return - self._remember_dir(path) - self.print_file = path - self.statusBar().showMessage(f"Saved {os.path.basename(path)} for printing, in {os.path.dirname(path)}", - 12000) - self.steps = PrintSteps(self, d, path, len(parts), self._slicer(ask=False)) - self.steps.open_slicer.connect(lambda: self.open_in_slicer(path)) - self.steps.show() - - def save_lid(self): - """A tissue box cover's lid, made to fit it, as a 3MF for the slicer - with how to print it and put it on.""" - d = self._current_design() - if d is None: - return - lid = Lid(d) - if lid.errors: - QMessageBox.warning(self, "No lid", " ".join(lid.errors)) - return - base = self._safe_name() + " lid" - path, _ = QFileDialog.getSaveFileName(self, "Save the lid for printing", - os.path.join(self._last_dir(), base + ".3mf"), - "3MF files, for the slicer (*.3mf)") - if not path: - return - if not path.lower().endswith(".3mf"): - path += ".3mf" - try: - write_3mf(path, base, [(base, lid.verts, lid.faces, 1, d.p.colors()[1])], - (d.p.bed_x / 2.0, d.p.bed_y / 2.0, 0.0)) - except OSError as exc: - QMessageBox.critical(self, "Couldn't save it", str(exc)) - return - self._remember_dir(path) - self.print_file = path - self.statusBar().showMessage(f"Saved {os.path.basename(path)} for printing, in {os.path.dirname(path)}", - 12000) - self.steps = PrintSteps(self, d, path, 1, self._slicer(ask=False), - steps=lambda where: lid_steps(lid, path, where)) - self.steps.open_slicer.connect(lambda: self.open_in_slicer(path)) - self.steps.show() - - def _slicer(self, ask=True): - """The slicer program to open a vase in: the one chosen before, else the first one installed.""" - path = self.settings.value("slicer", "", type=str) - if path and os.path.exists(path): - return path - found = slicers.find_slicers() - if found: - self.settings.setValue("slicer", found[0][1]) - return found[0][1] - return self.choose_slicer() if ask else None - - def choose_slicer(self): - found = slicers.find_slicers() - start = os.path.dirname(found[0][1]) if found else os.environ.get("ProgramFiles", os.path.expanduser("~")) - path, _ = QFileDialog.getOpenFileName(QApplication.activeWindow() or self, "Choose your slicer program", - start, "Programs (*.exe);;All files (*)") - if not path: - return None - self.settings.setValue("slicer", path) - self.statusBar().showMessage(f"Open in slicer will use {slicers.slicer_name(path)}", 8000) - return path - - def open_in_slicer(self, path=None): - """Open the vase saved for printing in the slicer (saving it first if it hasn't been).""" - path = path or getattr(self, "print_file", None) - if not path or not os.path.exists(path): - self.save_for_printing() - return - program = self._slicer() - if not program: - return - if slicers.open_in(program, path): - self.statusBar().showMessage(f"Opening {os.path.basename(path)} in {slicers.slicer_name(program)}…", - 10000) - else: - QMessageBox.warning(self, "Couldn't open the slicer", - f"{program} didn't start. Choose your slicer program in the File menu, or open " - f"{path} in it yourself.") - - def save_snapshot(self): - default = os.path.join(self._last_dir(), self._safe_name() + "_3d.png") - path, _ = QFileDialog.getSaveFileName(self, "Save picture", default, "PNG image (*.png)") - if not path: - return - if not path.lower().endswith(".png"): - path += ".png" - self.viewer.grabFramebuffer().save(path) - self._remember_dir(path) - self.statusBar().showMessage(f"Saved picture to {path}", 8000) - - def copy_colour_plan(self): - self._flush() - if self.design is None: - return - QApplication.clipboard().setText(self.design.colour_plan()) - self.statusBar().showMessage("Colour changes copied to the clipboard", 6000) - - def printing_help(self): - QMessageBox.information(self, "Printing it watertight", PRINTING_HELP) - - def drawing_help(self): - QMessageBox.information(self, "Drawing a cross-section", DRAWING_HELP) - - def about(self): - QMessageBox.about(self, f"About {APP_NAME}", ABOUT) - - def closeEvent(self, ev): - if not self._confirm_discard(): - ev.ignore() - return - self.settings.setValue("geometry", self.saveGeometry()) - super().closeEvent(ev) - - -# ============================================================== bootstrap -def apply_dark_theme(app: QApplication): - apply_theme(app, light=False) - - -def apply_theme(app: QApplication, light: bool = False, scale: float = 1.0): - """The look: dark (the usual) or light, and the writing at its own size or bigger.""" - app.setStyle("Fusion") - base = app.property("vaseinator_base_pt") - if base is None: - base = app.font().pointSizeF() - app.setProperty("vaseinator_base_pt", base) - font = app.font() - font.setPointSizeF(base * scale) - app.setFont(font) - title = f"QLabel#simpleTitle {{ font-weight: 700; font-size: {base * scale * 1.25:.1f}pt; }}" - if light: - _light_theme(app) - app.setStyleSheet(app.styleSheet() + title) - return - pal = QPalette() - c = QColor - pal.setColor(QPalette.Window, c(36, 39, 45)) - pal.setColor(QPalette.WindowText, c(222, 225, 230)) - pal.setColor(QPalette.Base, c(26, 28, 33)) - pal.setColor(QPalette.AlternateBase, c(44, 47, 54)) - pal.setColor(QPalette.ToolTipBase, c(52, 56, 64)) - pal.setColor(QPalette.ToolTipText, c(230, 230, 230)) - pal.setColor(QPalette.Text, c(222, 225, 230)) - pal.setColor(QPalette.Button, c(50, 54, 62)) - pal.setColor(QPalette.ButtonText, c(222, 225, 230)) - pal.setColor(QPalette.BrightText, c(255, 90, 90)) - pal.setColor(QPalette.Highlight, c(56, 132, 214)) - pal.setColor(QPalette.HighlightedText, c(255, 255, 255)) - pal.setColor(QPalette.Link, c(110, 180, 255)) - pal.setColor(QPalette.PlaceholderText, c(130, 135, 145)) - for role in (QPalette.Text, QPalette.ButtonText, QPalette.WindowText): - pal.setColor(QPalette.Disabled, role, c(120, 124, 132)) - app.setPalette(pal) - app.setStyleSheet(""" - QGroupBox { font-weight: 600; border: 1px solid #3b4049; border-radius: 6px; - margin-top: 12px; padding-top: 4px; } - QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 4px; color: #f0a356; } - QTextBrowser { border: none; } - QToolBar { border: none; spacing: 4px; padding: 3px; } - QSplitter::handle { background: #2b2e34; } - QPushButton#modeButton { border: 1px solid #3b4049; padding: 4px 12px; background: #2a2d33; } - QPushButton#modeButton:checked { background: #3884d6; color: white; border-color: #3884d6; } - QToolButton#pictureButton { border: 1px solid #3b4049; border-radius: 6px; padding: 4px; - background: #2a2d33; } - QToolButton#pictureButton:hover { border-color: #6b7380; } - QToolButton#pictureButton:checked { border: 2px solid #f0a356; background: #3a332b; } - QPushButton#bigButton { padding: 8px 10px; font-weight: 600; } - QPushButton#saveButton { background: #f0a356; color: #1a1c21; font-weight: 700; border-radius: 6px; - border: 1px solid #f0a356; } - QPushButton#saveButton:hover { background: #f5b878; } - QLabel#simpleTitle { color: #f0a356; } - """ + title) - - -def _light_theme(app: QApplication): - pal = QPalette() - c = QColor - pal.setColor(QPalette.Window, c(238, 240, 243)) - pal.setColor(QPalette.WindowText, c(28, 30, 34)) - pal.setColor(QPalette.Base, c(255, 255, 255)) - pal.setColor(QPalette.AlternateBase, c(232, 235, 239)) - pal.setColor(QPalette.ToolTipBase, c(255, 255, 238)) - pal.setColor(QPalette.ToolTipText, c(20, 20, 20)) - pal.setColor(QPalette.Text, c(28, 30, 34)) - pal.setColor(QPalette.Button, c(226, 229, 234)) - pal.setColor(QPalette.ButtonText, c(28, 30, 34)) - pal.setColor(QPalette.BrightText, c(200, 30, 30)) - pal.setColor(QPalette.Highlight, c(56, 132, 214)) - pal.setColor(QPalette.HighlightedText, c(255, 255, 255)) - pal.setColor(QPalette.Link, c(20, 100, 200)) - pal.setColor(QPalette.PlaceholderText, c(120, 125, 135)) - for role in (QPalette.Text, QPalette.ButtonText, QPalette.WindowText): - pal.setColor(QPalette.Disabled, role, c(150, 154, 162)) - app.setPalette(pal) - app.setStyleSheet(""" - QGroupBox { font-weight: 600; border: 1px solid #c4c9d1; border-radius: 6px; - margin-top: 12px; padding-top: 4px; } - QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 4px; color: #b45f12; } - QTextBrowser { border: none; } - QToolBar { border: none; spacing: 4px; padding: 3px; } - QSplitter::handle { background: #d5d9df; } - QPushButton#modeButton { border: 1px solid #c4c9d1; padding: 4px 12px; background: #e6e8ec; } - QPushButton#modeButton:checked { background: #3884d6; color: white; border-color: #3884d6; } - QToolButton#pictureButton { border: 1px solid #c4c9d1; border-radius: 6px; padding: 4px; - background: #f6f7f9; } - QToolButton#pictureButton:hover { border-color: #8c939e; } - QToolButton#pictureButton:checked { border: 2px solid #e08a2c; background: #fcefe0; } - QPushButton#bigButton { padding: 8px 10px; font-weight: 600; } - QPushButton#saveButton { background: #e08a2c; color: white; font-weight: 700; border-radius: 6px; - border: 1px solid #e08a2c; } - QPushButton#saveButton:hover { background: #ea9b44; } - QLabel#simpleTitle { color: #b45f12; } - """) - - -def place_for_testing(win: QMainWindow, argv): - """Put a test run's window on another screen than the main one (the second, unless --screen N), - so testing doesn't get in the way of whoever is at the computer. - - Windows places a new window itself, so the window is shown see-through, - moved once it is up, and only then made visible. - """ - from PySide6.QtCore import QRect - - screens = QApplication.screens() - index = int(argv[argv.index("--screen") + 1]) if "--screen" in argv else (1 if len(screens) > 1 else 0) - screen = screens[min(max(index, 0), len(screens) - 1)] - g = screen.availableGeometry() - win.setWindowOpacity(0.0) - win.show() - QApplication.processEvents() - win.windowHandle().setScreen(screen) - win.setGeometry(QRect(g.x() + 10, g.y() + 30, min(1480, g.width() - 20), min(940, g.height() - 40))) - QApplication.processEvents() - win.setWindowOpacity(1.0) - - -def main(argv=None): - argv = list(sys.argv if argv is None else argv) - fmt = QSurfaceFormat() - fmt.setDepthBufferSize(24) - fmt.setSamples(8) - fmt.setVersion(2, 1) - fmt.setProfile(QSurfaceFormat.CompatibilityProfile) - QSurfaceFormat.setDefaultFormat(fmt) - - app = QApplication(argv) - app.setApplicationName(APP_NAME) - app.setOrganizationName("Vaseinator") - apply_dark_theme(app) - settings = None - if "--screenshot" in argv: # a test run keeps its hands off the remembered settings - import tempfile - settings = QSettings(os.path.join(tempfile.gettempdir(), "Vaseinator-test.ini"), QSettings.IniFormat) - settings.clear() - settings.setValue("spin", False) - win = MainWindow(settings) - if "--screenshot" in argv: - place_for_testing(win, argv) - else: - win.show() - - valued = ("--screenshot", "--view", "--export-test", "--yaw", "--pitch", "--zoom", "--gallery", "--surprise", - "--print-at", "--spin", "--screen") - files = [a for i, a in enumerate(argv) if i > 0 and not a.startswith("--") - and argv[i - 1] not in valued and os.path.isfile(a)] - if files: - win.load_file(files[0]) - - # developer hook: --screenshot out.png [--view N] [--gallery NAME] [--surprise SEED] [--yaw a] - # [--pitch a] [--zoom k] [--print-at 0..1] [--spin deg] [--detailed] [--export-test DIR] - # [--screen N] (the window opens on the second screen, if there is one) [--only-3d] - if "--screenshot" in argv: - out = argv[argv.index("--screenshot") + 1] - win.set_simple_mode("--detailed" not in argv, remember=False) - if "--gallery" in argv: - win.use_gallery(argv[argv.index("--gallery") + 1]) - if "--surprise" in argv: - win.surprise_me(int(argv[argv.index("--surprise") + 1])) - if "--view" in argv: - win.show_view(int(argv[argv.index("--view") + 1])) - if "--export-test" in argv: - out_dir = argv[argv.index("--export-test") + 1] - d = win.design - write_stl(os.path.join(out_dir, "t_vase.stl"), d.verts, d.faces, "t") - write_3mf(os.path.join(out_dir, "t.3mf"), "t", win.band_parts(d), (d.p.bed_x / 2, d.p.bed_y / 2, 0.0)) - if d.p.box: - lid = Lid(d) - write_3mf(os.path.join(out_dir, "t_lid.3mf"), "t lid", [("t lid", lid.verts, lid.faces, 1, (1, 1, 1))]) - - def snap(): - win._fitted = None - win._apply_view() - if "--spin" in argv: - win.spin_angle = float(argv[argv.index("--spin") + 1]) - win.viewer.set_model(spin_transform(win.spin_angle)) - for flag, attr in (("--yaw", "yaw"), ("--pitch", "pitch")): - if flag in argv: - setattr(win.viewer, attr, float(argv[argv.index(flag) + 1])) - if "--zoom" in argv: - win.viewer.dist /= float(argv[argv.index("--zoom") + 1]) - if "--print-at" in argv: - t = float(argv[argv.index("--print-at") + 1]) - d = win.design - z = float(np.interp(d.filament_at[-1] * t, d.filament_at, d.filament_z)) - win.viewer.cut_z = z - win.viewer.set_lines(d.contour(z)) - layer = int(math.ceil(z / d.p.layer_height)) - win.hint.setText(f"Printing: layer {layer} of {d.layers} · {duration(d.seconds * t)} of " - f"{duration(d.seconds)}") - win.viewer.repaint() - (win.viewer.grabFramebuffer() if "--only-3d" in argv else win.grab()).save(out) - win._dirty = False # a test run has nothing to save: close without asking - app.quit() - QTimer.singleShot(2200, snap) - - return app.exec() - - -if __name__ == "__main__": - sys.exit(main()) +"""Vaseinator - main window.""" + +from __future__ import annotations + +import math +import json +import os +import random +import sys +import time + +import numpy as np +from PySide6.QtCore import QSettings, QSize, Qt, QTimer, QUrl, Signal +from PySide6.QtGui import (QAction, QColor, QDesktopServices, QFontDatabase, QIcon, QKeySequence, QPainter, + QPalette, QPen, QPixmap, QSurfaceFormat) +from PySide6.QtWidgets import (QApplication, QCheckBox, QColorDialog, QComboBox, QDialog, QDialogButtonBox, + QDoubleSpinBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel, + QLineEdit, QMainWindow, QMenu, QMessageBox, QPlainTextEdit, QPushButton, + QScrollArea, QSlider, QSpinBox, QSplitter, QStackedWidget, QStyle, + QTextBrowser, QToolButton, QVBoxLayout, QWidget) + +from . import sections, slicers +from .workspace import Workspace, locked_surprise +from .design_tools import DesignTools, show_comparison, save_calibration +from .curves import CurveEditor +from .expr import EXAMPLES +from .geometry import HEAD_SCALED, VaseDesign, duration, fit_head, require_exportable +from .outline_editor import CLOSE_UP_FROM, OutlineEditor, OutlineWindow +from .params import (DRAWN, FILAMENTS, ONE_COLOUR, OWN_PROFILE, POLYGON, REAL_VASE, VaseParams, colour_style_of, + load_design, save_design) +from .plots import ShapePlots +from .lid import Lid +from .report import ABOUT, APP_NAME, DRAWING_HELP, PRINTING_HELP, function_help, info_html, lid_steps, print_steps +from .settings import (COLOUR_STYLES, REAL_COLOUR_STYLES, REMEMBERED, SPEC, TEXT_KEYS, change as apply_change, + outline_from_shape, wanted) +from .presets import GALLERY, gallery_design, outline, palette, plain_design, slug, surprise +from .simple_panel import GalleryDialog, SimplePanel, asset, profile_icon +from .spinbox import ComboBox, DoubleSpinBox, Slider, SpinBox +from .shapes import PALETTES, SHAPE_LABELS +from .stl import write_stl +from .threemf import write_3mf +from .viewer import Viewer3D, spin_transform + +FILE_EXT = ".vase" +KOFI_URL = "https://ko-fi.com/rickapps" +VASE, SECTION, OVERHANGS, PRINT_LAYOUT = 0, 1, 2, 3 +VIEW_NAMES = ("Vase", "Section", "Overhangs", "Print layout") +SPIN_SPEED = 30.0 # degrees a second, on the turntable + + + +def swatch(color: str, size: int = 14) -> QIcon: + pm = QPixmap(size, size) + pm.fill(Qt.transparent) + p = QPainter(pm) + p.setPen(QPen(QColor(20, 22, 26), 1)) + p.setBrush(QColor(color)) + p.drawRect(0, 0, size - 1, size - 1) + p.end() + return QIcon(pm) + + +def palette_icon(colours) -> QIcon: + pm = QPixmap(56, 14) + pm.fill(Qt.transparent) + p = QPainter(pm) + for k, c in enumerate(colours): + p.setPen(QPen(QColor(20, 22, 26), 1)) + p.setBrush(QColor(c)) + p.drawRect(k * 14, 0, 13, 13) + p.end() + return QIcon(pm) + + +def glyph_icon(kind: str) -> QIcon: + """Small light line icons that read well on the dark theme.""" + from PySide6.QtCore import QPointF, QRectF + + pm = QPixmap(64, 64) + pm.fill(Qt.transparent) + p = QPainter(pm) + p.setRenderHint(QPainter.Antialiasing) + app = QApplication.instance() + ink = app.palette().color(QPalette.WindowText) if app is not None else QColor(225, 230, 236) + pen = QPen(ink, 5, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin) + p.setPen(pen) + if kind == "export": + p.drawLine(QPointF(32, 8), QPointF(32, 40)) + p.drawPolyline([QPointF(19, 28), QPointF(32, 41), QPointF(45, 28)]) + p.drawPolyline([QPointF(10, 40), QPointF(10, 54), QPointF(54, 54), QPointF(54, 40)]) + elif kind == "picture": + p.drawRoundedRect(QRectF(8, 14, 48, 38), 4, 4) + pen.setColor(QColor(240, 163, 86)) + p.setPen(pen) + p.drawPolyline([QPointF(14, 44), QPointF(26, 30), QPointF(34, 38), QPointF(42, 26), QPointF(50, 38)]) + elif kind == "dice": + p.drawRoundedRect(QRectF(10, 10, 44, 44), 9, 9) + p.setBrush(QColor(240, 163, 86)) + p.setPen(Qt.NoPen) + for x, y in ((22, 22), (42, 22), (32, 32), (22, 42), (42, 42)): + p.drawEllipse(QPointF(x, y), 4.5, 4.5) + elif kind == "gallery": + pen.setColor(QColor(240, 163, 86)) + p.setPen(pen) + p.drawPolyline([QPointF(20, 8), QPointF(20, 16), QPointF(12, 34), QPointF(16, 56), QPointF(48, 56), + QPointF(52, 34), QPointF(44, 16), QPointF(44, 8)]) + p.drawLine(QPointF(16, 8), QPointF(48, 8)) + elif kind in ("undo", "redo"): + sx = 1 if kind == "undo" else -1 + cx = 32 + p.drawArc(QRectF(14, 18, 36, 32), (0 if sx > 0 else 180) * 16, (180 if sx > 0 else -180) * 16) + tip = QPointF(cx - sx * 18, 34) + p.drawPolyline([QPointF(tip.x() - sx * 7, 25), tip, QPointF(tip.x() + sx * 9, 28)]) + elif kind == "coffee": + p.drawRoundedRect(QRectF(14, 26, 30, 26), 5, 5) # cup + p.drawArc(QRectF(36, 30, 16, 16), -90 * 16, 180 * 16) # handle, ends on the cup + pen.setColor(QColor(240, 163, 86)) + p.setPen(pen) + for x in (21, 29, 37): # steam + p.drawLine(QPointF(x, 10), QPointF(x, 20)) + p.end() + return QIcon(pm) + + +def make_app_icon() -> QIcon: + here = os.path.dirname(os.path.abspath(__file__)) + for cand in (os.path.join(here, "..", "assets", "icon.png"), + os.path.join(getattr(sys, "_MEIPASS", here), "assets", "icon.png")): + if os.path.exists(cand): + return QIcon(cand) + return QIcon() + + +class FunctionHelp(QDialog): + """What a formula can use, with examples to try.""" + + def __init__(self, parent, insert): + super().__init__(parent) + self.setWindowTitle("Your own function") + self.resize(640, 560) + text = QTextBrowser() + text.setHtml(function_help()) + box = QVBoxLayout(self) + box.addWidget(text) + row = QHBoxLayout() + row.addWidget(QLabel("Try an example:")) + pick = ComboBox() + for name, formula in EXAMPLES: + pick.addItem(name, formula) + row.addWidget(pick, 1) + use = QPushButton("Use it") + use.clicked.connect(lambda: (insert(pick.currentData()), self.accept())) + row.addWidget(use) + box.addLayout(row) + buttons = QDialogButtonBox(QDialogButtonBox.Close) + buttons.rejected.connect(self.reject) + box.addWidget(buttons) + + +class PrintSteps(QDialog): + """After Save for printing: where the file is, and plain steps to print it - the vase's, or the steps + given (a lid's, say, as steps(where)).""" + open_slicer = Signal() + + def __init__(self, parent, d, path, parts: int, slicer, steps=None): + super().__init__(parent) + self.setWindowTitle("How to print it") + where = slicers.slicer_name(slicer) if slicer else "your slicer" + text = QLabel(steps(where) if steps else print_steps(d, path, parts, where)) + text.setWordWrap(True) + text.setTextInteractionFlags(Qt.TextSelectableByMouse) + box = QVBoxLayout(self) + box.addWidget(text) + row = QHBoxLayout() + if slicer: + go = QPushButton(f"Open in {where}") + go.setObjectName("saveButton") + go.setMinimumHeight(36) + go.clicked.connect(lambda: (self.open_slicer.emit(), self.accept())) + row.addWidget(go) + show = QPushButton("Show the file") + show.clicked.connect(lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(os.path.dirname(path)))) + row.addWidget(show) + done = QPushButton("Close") + done.clicked.connect(self.accept) + row.addWidget(done) + box.addLayout(row) + self.resize(560, 320) + + +class MainWindow(QMainWindow): + def __init__(self, settings: QSettings | None = None): + super().__init__() + self.settings = settings or QSettings("Vaseinator", "Vaseinator") + if self.settings.value("big_text", False, type=bool) or self.settings.value("light_theme", False, type=bool): + apply_theme(QApplication.instance(), light=self.settings.value("light_theme", False, type=bool), + scale=1.3 if self.settings.value("big_text", False, type=bool) else 1.0) + self.simple_mode = self.settings.value("simple_mode", True, type=bool) + self.params = self._fresh_params() + try: + saved = self.settings.value("workspace", "") + self.workspace = Workspace(json.loads(saved) if saved else None) + except (ValueError, TypeError): + self.settings.setValue("workspace_unreadable_backup", saved) + self.workspace = Workspace() + self.design: VaseDesign | None = None + self.current_file = None + self.view = VASE + self._free_camera = False + self._fitted = None + self._dirty = False + self.widgets = {} + self.labels = {} + self.outline_menus = {} # Start from, for each drawn cross-section + self.close_up_buttons = {} + self.outline_windows = {} # the cross-section editors opened full window + self.slider_notes = {} + self.history: list[dict] = [] + self.future: list[dict] = [] + self.spin_angle = 0.0 + self.spinning = self.settings.value("spin", True, type=bool) + self.printing = None # the print animation: its start time + self._last_full_ms = 0.0 + self._note = "" # said with the next "Built in ..." message + self._last_tick = time.perf_counter() + + self.regen_timer = QTimer(self) + self.regen_timer.setSingleShot(True) + self.regen_timer.timeout.connect(self.regenerate) + self.draft_timer = QTimer(self) + self.draft_timer.setSingleShot(True) + self.draft_timer.timeout.connect(lambda: self.regenerate(draft=True)) + self.anim_timer = QTimer(self) + self.anim_timer.setInterval(16) + self.anim_timer.timeout.connect(self._anim_tick) + + self.setWindowIcon(make_app_icon()) + self._build_ui() + self._build_actions() + self.resize(1480, 940) + geo = self.settings.value("geometry") + if geo is not None: + self.restoreGeometry(geo) + self.regenerate() + self._update_anim() + + # ================================================================== UI + def _build_ui(self): + panel = QWidget() + pl = QVBoxLayout(panel) + pl.setContentsMargins(8, 8, 8, 8) + pl.setSpacing(8) + + mode_row = QHBoxLayout() + mode_row.setSpacing(0) + self.simple_btn = QPushButton("Simple") + self.detail_btn = QPushButton("Advanced") + self.simple_btn.setToolTip("A few choices made from pictures: size, shape, twist, pattern and colour.") + self.detail_btn.setToolTip("Every setting, with curves to draw how things change up the vase.") + for b in (self.simple_btn, self.detail_btn): + b.setCheckable(True) + b.setAutoExclusive(True) + b.setMinimumHeight(28) + b.setObjectName("modeButton") + mode_row.addWidget(b) + (self.simple_btn if self.simple_mode else self.detail_btn).setChecked(True) + self.simple_btn.clicked.connect(lambda: self.set_simple_mode(True)) + self.detail_btn.clicked.connect(lambda: self.set_simple_mode(False)) + + name_row = QHBoxLayout() + name_row.addWidget(QLabel("Name")) + self.name_edit = QLineEdit(self.params.name) + self.name_edit.setToolTip("Design name - used for exported file names.") + self.name_edit.textChanged.connect(self._on_name) + name_row.addWidget(self.name_edit) + dice = self.dice_btn = QToolButton() + dice.setIcon(glyph_icon("dice")) + dice.setToolTip("Surprise me: a new random vase that still prints (Ctrl+R)") + dice.clicked.connect(self.surprise_me) + name_row.addWidget(dice) + pl.addLayout(name_row) + + self._rows = {} + self._groups = [] + for title, items in SPEC: + box = QGroupBox(title) + form = QFormLayout(box) + form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) + form.setContentsMargins(8, 12, 8, 8) + form.setHorizontalSpacing(10) + form.setVerticalSpacing(5) + for key, label, kind, lo, hi, step, extra, tip in items: + w = self._make_widget(key, kind, lo, hi, step, extra) + if tip: + w.setToolTip(tip) + if label: + lab = QLabel(label) + if tip: + lab.setToolTip(tip) + self.labels[key] = lab + form.addRow(lab, w) + else: + form.addRow(w) + self._rows[key] = (form, w) + self._groups.append((box, [it[0] for it in items])) + pl.addWidget(box) + pl.addStretch(1) + + scroll = QScrollArea() + scroll.setWidget(panel) + scroll.setWidgetResizable(True) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + # Simple: a few choices made from pictures; Advanced: every setting + self.simple = SimplePanel() + self.simple.param.connect(lambda k, v: self._on_param(k, v)) + self.simple.live.connect(lambda k, v: self._on_param(k, v, live=True)) + self.simple.changes.connect(self.apply_changes) + self.simple.choose_design.connect(self.choose_design) + self.simple.surprise.connect(lambda: self.surprise_me()) + self.simple.draw_own.connect(self.draw_own_shape) + self.simple.save.connect(self.save_for_printing) + self.simple.save_lid.connect(self.save_lid) + self.simple.advanced.connect(lambda: self.set_simple_mode(False)) + simple_scroll = QScrollArea() + simple_scroll.setWidget(self.simple) + simple_scroll.setWidgetResizable(True) + simple_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.panels = QStackedWidget() + self.panels.addWidget(simple_scroll) + self.panels.addWidget(scroll) + self.panels.setCurrentIndex(0 if self.simple_mode else 1) + left = QWidget() + lv = QVBoxLayout(left) + lv.setContentsMargins(8, 8, 0, 0) + lv.setSpacing(6) + lv.addLayout(mode_row) + lv.addWidget(self.panels, 1) + left.setMinimumWidth(370) + left.setMaximumWidth(480) + + self.viewer = Viewer3D() + self.viewer.dragged.connect(self._on_dragged) + self.viewer.reset_requested.connect(lambda: self.show_view(self.view)) + self.viewer.resized.connect(self._on_resized) + self.plots = ShapePlots() + self.info = QTextBrowser() + self.info.setOpenExternalLinks(False) + self.info.setMinimumWidth(280) + + bottom = QSplitter(Qt.Horizontal) + bottom.addWidget(self.plots) + bottom.addWidget(self.info) + bottom.setStretchFactor(0, 3) + bottom.setStretchFactor(1, 2) + bottom.setSizes([560, 500]) + + right = QSplitter(Qt.Vertical) + right.addWidget(self._make_view_bar()) + right.addWidget(self.viewer) + right.addWidget(bottom) + right.setStretchFactor(1, 3) + right.setStretchFactor(2, 2) + right.setSizes([36, 560, 300]) + right.setCollapsible(0, False) + right.handle(1).setEnabled(False) + + main = QSplitter(Qt.Horizontal) + main.addWidget(left) + main.addWidget(right) + main.setStretchFactor(1, 1) + main.setSizes([380, 1100]) + self.setCentralWidget(main) + self.statusBar().showMessage("Ready") + self._refresh_widgets() + + def _make_view_bar(self): + bar = QWidget() + bar.setMaximumHeight(40) + h = QHBoxLayout(bar) + h.setContentsMargins(6, 4, 6, 4) + h.addWidget(QLabel("Show")) + row = QHBoxLayout() + row.setSpacing(0) + self.view_btns = [] + tips = ("The vase as it will look (Ctrl+1)", + "Cut in half through the middle: the single printed wall and the solid bottom (Ctrl+2)", + "How far each part of the wall leans: green is easy, orange near the limit, red needs " + "support (Ctrl+3)", + "On the printer bed, inside the space the printer can reach (Ctrl+4)") + for idx, text in enumerate(VIEW_NAMES): + b = QPushButton(text) + b.setCheckable(True) + b.setAutoExclusive(True) + b.setMinimumHeight(28) + b.setObjectName("modeButton") + b.setToolTip(tips[idx]) + b.clicked.connect(lambda _=False, i=idx: self.show_view(i)) + row.addWidget(b) + self.view_btns.append(b) + self.view_btns[self.view].setChecked(True) + h.addLayout(row) + h.addSpacing(10) + self.spin_btn = QPushButton("⟳ Spin") + self.spin_btn.setCheckable(True) + self.spin_btn.setChecked(self.spinning) + self.spin_btn.setObjectName("modeButton") + self.spin_btn.setMinimumHeight(28) + self.spin_btn.setToolTip("Turn the vase slowly on a turntable (S)") + self.spin_btn.toggled.connect(self.set_spin) + h.addWidget(self.spin_btn) + self.print_btn = QPushButton("▶ Watch it print") + self.print_btn.setCheckable(True) + self.print_btn.setObjectName("modeButton") + self.print_btn.setMinimumHeight(28) + self.print_btn.setToolTip("Build the vase up as the printer would, keeping to the real print time " + "(wide layers take longer) (F5)") + self.print_btn.toggled.connect(self.set_printing) + h.addWidget(self.print_btn) + h.addSpacing(10) + self.hint = QLabel("Drag to turn · wheel to zoom") + self.hint.setStyleSheet("color: #9aa3ad;") + h.addWidget(self.hint) + h.addStretch(1) + fit = QToolButton() + fit.setText("Reset view") + fit.setToolTip("Back to the chosen view (double-click the 3D view, or press F)") + fit.clicked.connect(lambda: self.show_view(self.view)) + h.addWidget(fit) + return bar + + def _make_widget(self, key, kind, lo, hi, step, extra): + def row(*parts): + box = QWidget() + hl = QHBoxLayout(box) + hl.setContentsMargins(0, 0, 0, 0) + hl.setSpacing(6) + for part in parts: + hl.addWidget(part, 0 if isinstance(part, QToolButton) else 1) + return box + + if kind == "widest": + s = DoubleSpinBox() + s.setDecimals(1) + s.setRange(lo, hi) + s.setSingleStep(step) + s.setSuffix(extra) + s.setKeyboardTracking(False) + s.setValue(self._widest()) + s.valueChanged.connect(lambda v: self._on_param("widest", float(v))) + self.widgets[key] = s + return s + if kind == "head": # not a setting of its own: it scales the design to fit + s = DoubleSpinBox() + s.setDecimals(1) + s.setRange(lo, hi) + s.setSingleStep(step) + s.setSuffix(extra) + s.setKeyboardTracking(False) + s.setValue(57.0) + s.valueChanged.connect(lambda v: self._on_param("head", float(v))) + self.widgets[key] = s + return s + if kind == "palette": + b = QToolButton() + b.setText("Choose a palette") + b.setPopupMode(QToolButton.InstantPopup) + b.setToolButtonStyle(Qt.ToolButtonTextOnly) + m = QMenu(b) + for name, cols in PALETTES: + a = m.addAction(palette_icon(cols), name) + a.triggered.connect(lambda _=False, n=name: self._apply_palette(n)) + b.setMenu(m) + b.setMinimumHeight(24) + self.widgets[key] = b + return b + if kind == "outline": # a drawn cross-section, and where to start one from + k = key[-1] + box = QWidget() + vl = QVBoxLayout(box) + vl.setContentsMargins(0, 0, 0, 0) + vl.setSpacing(4) + ed = OutlineEditor() + ed.set_outline(getattr(self.params, key), getattr(self.params, f"repeat{k}")) + ed.changed.connect(lambda pts, kk=int(k), e=ed: self._outline_edited(kk, pts, e, live=True)) + ed.finished.connect(lambda pts, kk=int(k), e=ed: self._outline_edited(kk, pts, e)) + start = QToolButton() + start.setText("Start from") + start.setPopupMode(QToolButton.InstantPopup) + m = QMenu(start) + for name, pts, repeat in sections.OUTLINES: + m.addAction(name).triggered.connect( + lambda _=False, kk=k, pp=pts, rr=repeat: self._use_outline(kk, pp, rr)) + m.addSeparator() + m.addAction("Trace a picture…").triggered.connect(lambda _=False, kk=k: self._trace_outline(kk)) + start.setMenu(m) + start.setToolTip("Start again from a ready-made outline, or trace one from a picture: dark lines " + "drawn round a shape on a light or see-through ground.") + close = QToolButton() + close.setText("Close up") + close.setCheckable(True) + close.setToolTip("Show one repeat close up, turned to point upwards - or the whole outline.\n" + f"Above {CLOSE_UP_FROM} repeats it starts close up.") + close.toggled.connect(ed.set_close_up) + ed.view_changed.connect(lambda kk=int(k): self._show_close_up(kk)) + big = QToolButton() + big.setText("Full window…") + big.setToolTip("Draw it in a window of its own, as big as the screen: the wheel zooms, a right-drag " + "moves it.") + big.clicked.connect(lambda _=False, kk=int(k): self._open_outline_window(kk)) + self.outline_menus[int(k)] = m + self.close_up_buttons[int(k)] = close + buttons = QHBoxLayout() + buttons.setContentsMargins(0, 0, 0, 0) + buttons.addWidget(start) + buttons.addWidget(close) + buttons.addWidget(big) + buttons.addStretch(1) + vl.addWidget(ed) + vl.addLayout(buttons) + self.widgets[key] = ed + return box + val = getattr(self.params, key) + if kind == "float": + suffix, _, special = extra.partition("|") + s = DoubleSpinBox() + s.setDecimals(2) + s.setRange(lo, hi) + s.setSingleStep(step) + s.setSuffix(suffix) + if special: + s.setSpecialValueText(special) + s.setValue(val) + s.setKeyboardTracking(False) + s.valueChanged.connect(lambda v, k=key: self._on_param(k, float(v))) + s.setMinimumWidth(110) + w = s + elif kind == "int" or kind == "seed": + suffix, _, special = extra.partition("|") + s = SpinBox() + s.setRange(int(lo), int(hi)) + s.setSingleStep(int(step)) + s.setSuffix(suffix) + if special: + s.setSpecialValueText(special) + s.setValue(int(val)) + s.setKeyboardTracking(False) + s.valueChanged.connect(lambda v, k=key: self._on_param(k, int(v))) + self.widgets[key] = s + if kind == "seed": + roll = QToolButton() + roll.setIcon(glyph_icon("dice")) + roll.setToolTip("Another one") + roll.clicked.connect(lambda _=False, ss=s: ss.setValue(random.randint(0, 99999))) + return row(s, roll) + return s + elif kind == "pct": + s = DoubleSpinBox() + s.setDecimals(0) + s.setRange(lo, hi) + s.setSingleStep(step) + s.setSuffix(extra) + s.setValue(100.0 * val) + s.setKeyboardTracking(False) + s.valueChanged.connect(lambda v, k=key: self._on_param(k, float(v) / 100.0)) + w = s + elif kind == "bool": + w = QCheckBox() + w.setChecked(val) + w.toggled.connect(lambda v, k=key: self._on_param(k, bool(v))) + elif kind == "choice": + w = ComboBox() + for value, label in lo: + w.addItem(label, value) + w.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon) + w.setMinimumContentsLength(12) + w.setCurrentIndex(max(0, w.findData(val))) + w.currentIndexChanged.connect(lambda i, k=key, ww=w: self._on_param(k, ww.itemData(i))) + elif kind == "color": + w = QPushButton() + w.setMinimumHeight(24) + w.clicked.connect(lambda _=False, k=key: self._pick_color(k)) + elif kind == "slider": + box = QWidget() + vl = QVBoxLayout(box) + vl.setContentsMargins(0, 0, 0, 0) + vl.setSpacing(1) + s = Slider(Qt.Horizontal) + s.setRange(int(round(lo * 100)), int(round(hi * 100))) + s.setPageStep(10) + s.setTickInterval(100) + s.setTickPosition(QSlider.TicksBelow) + s.setValue(int(round(val * 100))) + s.valueChanged.connect(lambda v, k=key: self._on_param(k, v / 100.0, live=True)) + s.sliderReleased.connect(lambda: self._on_param(key, s.value() / 100.0)) + self.slider_notes[key] = QLabel() + self.slider_notes[key].setStyleSheet("color: #9aa3ad;") + vl.addWidget(s) + vl.addWidget(self.slider_notes[key]) + self.widgets[key] = s + return box + elif kind == "curve": + w = CurveEditor(**lo) + w.set_points(val) + w.changed.connect(lambda pts, k=key: self._on_param(k, pts, live=True)) + w.finished.connect(lambda pts, k=key: self._on_param(k, pts)) + elif kind == "expr": + box = QWidget() + vl = QVBoxLayout(box) + vl.setContentsMargins(0, 0, 0, 0) + vl.setSpacing(4) + t = QPlainTextEdit(val) + mono = QFontDatabase.systemFont(QFontDatabase.FixedFont) + t.setFont(mono) + t.setPlaceholderText("e.g. 1.5 * sin(6*theta + 4*pi*h)") + t.setTabChangesFocus(True) + t.setFixedHeight(54) + t.textChanged.connect(lambda k=key, tt=t: self._on_param(k, tt.toPlainText())) + self.widgets[key] = t + ex = QToolButton() + ex.setText("Examples") + ex.setPopupMode(QToolButton.InstantPopup) + m = QMenu(ex) + for name, formula in EXAMPLES: + a = m.addAction(f"{name}: {formula}") + a.triggered.connect(lambda _=False, f=formula: self._set_expression(f)) + m.addSeparator() + m.addAction("Clear").triggered.connect(lambda: self._set_expression("")) + ex.setMenu(m) + helpb = QToolButton() + helpb.setText("Help…") + helpb.clicked.connect(self.function_help) + buttons = QHBoxLayout() + buttons.setContentsMargins(0, 0, 0, 0) + buttons.addWidget(ex) + buttons.addWidget(helpb) + buttons.addStretch(1) + vl.addWidget(t) + vl.addLayout(buttons) + return box + else: + raise ValueError(kind) + self.widgets[key] = w + return w + + def _widest(self) -> float: + return max(d for _, d in self.params.silhouette) + + def _refresh_widgets(self): + """Push self.params into the widgets without triggering updates.""" + for key, w in self.widgets.items(): + w.blockSignals(True) + if key == "widest": + w.setValue(self._widest()) + elif key in ("palette", "head"): # the head size follows the design once it is built + pass + elif isinstance(w, QComboBox): + w.setCurrentIndex(max(0, w.findData(getattr(self.params, key)))) + elif isinstance(w, QCheckBox): + w.setChecked(bool(getattr(self.params, key))) + elif isinstance(w, QPlainTextEdit): + if w.toPlainText() != getattr(self.params, key): + w.setPlainText(getattr(self.params, key)) + elif isinstance(w, QSpinBox): + w.setValue(int(getattr(self.params, key))) + elif isinstance(w, QDoubleSpinBox): + v = getattr(self.params, key) + w.setValue(100.0 * v if w.suffix() == " %" else v) + elif isinstance(w, QSlider): + w.setValue(int(round(getattr(self.params, key) * 100))) + elif isinstance(w, CurveEditor): + w.set_points(getattr(self.params, key)) + elif isinstance(w, OutlineEditor): + w.set_outline(getattr(self.params, key), getattr(self.params, f"repeat{key[-1]}")) + w.blockSignals(False) + for k in (1, 2): + self._push_outline(k) + self.name_edit.blockSignals(True) + self.name_edit.setText(self.params.name) + self.name_edit.blockSignals(False) + self._update_title() + self._sync_widgets() + + def _fresh_params(self) -> VaseParams: + """Default design, using the printer settings remembered from last time.""" + p = VaseParams() + # a first run borrows the printer from Maze Tube Designer or Lenticular Sign Maker + others = (QSettings("MazeTube", "MazeTubeDesigner"), QSettings("LenticularSign", "LenticularSignMaker")) + for key in REMEMBERED: + for v in (self.settings.value(f"default_{key}"),) + tuple(o.value(f"default_{key}") for o in others): + try: + if v is not None: + setattr(p, key, float(v)) + break + except (TypeError, ValueError): + pass + return p + + def _sync_widgets(self): + """Colour swatches, labels and greyed-out settings that follow other settings.""" + p = self.params + for k in FILAMENTS: + color = getattr(p, f"color{k}") + btn = self.widgets[f"color{k}"] + dark = QColor(color).lightnessF() < 0.55 + btn.setText(color.upper()) + btn.setStyleSheet(f"background: {color}; color: {'white' if dark else 'black'}; " + f"border: 1px solid #3b4049; border-radius: 3px;") + for k in (1, 2): + kind = getattr(p, f"shape{k}") + names = SHAPE_LABELS.get(kind, (None, None, None)) + for key, name in zip((f"count{k}", f"depth{k}", f"sharp{k}"), names): + if name: + self.labels[key].setText(name) + self.widgets[f"count{k}"].setMinimum(3 if kind == POLYGON else 1) + # a real spiral vase prints one filament: only the colour styles that need no change of filament + real = p.vase_mode == REAL_VASE + styles = REAL_COLOUR_STYLES if real else COLOUR_STYLES + combo = self.widgets["colour_style"] + combo.blockSignals(True) + if [(combo.itemData(i), combo.itemText(i)) for i in range(combo.count())] != styles: + combo.clear() + for value, text in styles: + combo.addItem(text, value) + combo.setCurrentIndex(max(0, combo.findData(colour_style_of(p)))) + combo.blockSignals(False) + self.labels["color1"].setText("Colour" if colour_style_of(p) == ONE_COLOUR else "Filament 1") + s = p.band_spacing + self.slider_notes["band_spacing"].setText( + "Even" if abs(s) < 0.05 else ("Thicker towards the top" if s > 0 else "Thicker towards the bottom")) + for k, win in self.outline_windows.items(): # a shape no longer drawn has nothing to draw + if win.isVisible() and getattr(p, f"shape{k}") != DRAWN: + win.close() + if hasattr(self, "simple"): + titles = {slug(t): t for t, _, _ in GALLERY} + titles["plain_cylinder"] = "Plain cylinder" + self.simple.refresh(p, self._widest(), titles.get(p.name, "")) + if hasattr(self, "a_lid"): + self.a_lid.setEnabled(p.box) + self._apply_mode() + + def _wanted(self, key) -> bool: + return wanted(self.params, key) + + def _apply_mode(self): + for key, (form, field) in self._rows.items(): + form.setRowVisible(field, self._wanted(key)) + for box, keys in self._groups: + box.setVisible(any(self._wanted(k) for k in keys)) + + def set_simple_mode(self, simple: bool, remember: bool = True): + self.simple_mode = simple + (self.simple_btn if simple else self.detail_btn).setChecked(True) + self.panels.setCurrentIndex(0 if simple else 1) + if remember: + self.settings.setValue("simple_mode", simple) + self._apply_mode() + + def _pick_color(self, key): + c = QColorDialog.getColor(QColor(getattr(self.params, key)), self, "Filament colour") + if c.isValid(): + self._on_param(key, c.name().upper()) + + def _apply_palette(self, name): + for k, v in palette(name).items(): + setattr(self.params, k, v) + self._on_param("color1", self.params.color1) + + def _set_expression(self, formula): + self.widgets["expression"].setPlainText(formula) # its textChanged updates the design + + def function_help(self): + FunctionHelp(self, self._set_expression).exec() + + def _build_actions(self): + st = self.style() + tb = self.addToolBar("Main") + tb.setMovable(False) + tb.setIconSize(QSize(20, 20)) + tb.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + + def act(text, slot, icon=None, shortcut=None, tip=None): + a = QAction(text, self) + if isinstance(icon, QIcon): + a.setIcon(icon) + elif icon is not None: + a.setIcon(st.standardIcon(icon)) + if shortcut: + a.setShortcut(QKeySequence(shortcut)) + if tip: + a.setToolTip(tip) + a.setStatusTip(tip) + a.triggered.connect(slot) + return a + + self.a_new = act("New", self.new_design, QStyle.SP_FileIcon, "Ctrl+N", "Start again from the default vase") + self.a_plain = act("New plain cylinder", self.use_plain, None, "Ctrl+Shift+N", + "Start from a plain cylinder, 200 mm across and 200 mm tall, with everything else off - " + "ready for your own function or a drawn cross-section") + self.a_open = act("Open…", self.open_design, QStyle.SP_DialogOpenButton, "Ctrl+O", "Open a saved vase design") + self.a_save = act("Save", self.save_design, QStyle.SP_DialogSaveButton, "Ctrl+S", "Save the design") + self.a_save_as = act("Save As…", lambda: self.save_design(as_new=True), None, "Ctrl+Shift+S") + self.a_exp_stl = act("Export STL…", self.export_stl, glyph_icon("export"), "Ctrl+E", + "The vase as one solid, watertight STL, for spiral vase mode") + self.a_exp_3mf = act("Export 3MF…", self.export_3mf, None, "Ctrl+Shift+E", + "The vase as a 3MF, in the middle of the bed, set to its first filament") + self.a_snap = act("Save picture…", self.save_snapshot, glyph_icon("picture"), "Ctrl+P", + "Save the 3D view as a PNG image") + self.a_print = act("Save for printing…", self.save_for_printing, glyph_icon("export"), "Ctrl+Shift+P", + "Save the vase for your printer's slicer, with how to print it") + self.a_lid = act("Save the lid for printing…", self.save_lid, None, None, + "A tissue box cover's lid for its open end, to print on its own: it hooks in at one end " + "and clicks in at the other") + self.a_slicer = act("Open in slicer", lambda: self.open_in_slicer(), None, "Ctrl+L", + "Open the vase saved for printing in your slicer") + self.a_pick_slicer = act("Choose slicer program…", lambda: self.choose_slicer(), None, None, + "Which program Open in slicer uses") + self.a_gallery = act("Choose a design…", self.choose_design, glyph_icon("gallery"), "Ctrl+G", + "Pictures of every ready-made vase, and a plain cylinder to start from") + self.a_big = act("Bigger text", lambda on: self._set_look(big=on), None, "Ctrl+=", + "Make all the writing bigger") + self.a_light = act("Light theme", lambda on: self._set_look(light=on), None, None, + "Dark writing on a light background") + for a, key in ((self.a_big, "big_text"), (self.a_light, "light_theme")): + a.setCheckable(True) + a.setChecked(self.settings.value(key, False, type=bool)) + self.a_quit = act("Exit", self.close, None, "Ctrl+Q") + self.a_undo = act("Undo", self.undo, glyph_icon("undo"), "Ctrl+Z", "Undo the last change") + self.a_redo = act("Redo", self.redo, glyph_icon("redo"), "Ctrl+Y", "Redo") + self.a_redo.setShortcuts([QKeySequence("Ctrl+Y"), QKeySequence("Ctrl+Shift+Z")]) + self.a_surprise = act("Surprise me", self.surprise_me, glyph_icon("dice"), "Ctrl+R", + "A new random vase that still prints without supports") + self.a_copy_plan = act("Copy colour changes", self.copy_colour_plan, None, "Ctrl+Shift+C", + "Copy the list of filament changes, to keep beside the slicer") + self.a_about = act("About", self.about) + self.a_printing = act("Printing it watertight…", self.printing_help) + self.a_function = act("Your own function…", self.function_help) + self.a_drawing = act("Drawing a cross-section…", self.drawing_help) + + tb.addAction(self.a_new) + tb.addAction(self.a_open) + tb.addAction(self.a_save) + tb.addSeparator() + tb.addAction(self.a_print) + exp_btn = QToolButton() + exp_btn.setDefaultAction(self.a_exp_stl) + exp_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + exp_btn.setPopupMode(QToolButton.MenuButtonPopup) + m = QMenu(exp_btn) + m.addAction(self.a_exp_stl) + m.addAction(self.a_exp_3mf) + m.addSeparator() + m.addAction(self.a_lid) + exp_btn.setMenu(m) + tb.addWidget(exp_btn) + tb.addAction(self.a_snap) + tb.addSeparator() + tb.addAction(self.a_surprise) + gal_btn = self.gal_btn = QToolButton() + gal_btn.setText("Gallery") + gal_btn.setIcon(glyph_icon("gallery")) + gal_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + gal_btn.setToolTip("Pictures of every ready-made vase to start from (Ctrl+G)") + gal_btn.clicked.connect(self.choose_design) + self.gallery_menu = QMenu(self) + self.gallery_menu.addAction(self.a_gallery) + self.gallery_menu.addSeparator() + self.gallery_menu.addAction(self.a_plain) + self.gallery_menu.addSeparator() + for title, about, _ in GALLERY: + a = self.gallery_menu.addAction(title) + a.setToolTip(about) + a.setStatusTip(about) + a.triggered.connect(lambda _=False, t=title: self.use_gallery(t)) + self.gallery_menu.setToolTipsVisible(True) + tb.addWidget(gal_btn) + tb.addSeparator() + tb.addAction(self.a_undo) + tb.addAction(self.a_redo) + + mb = self.menuBar() + fm = mb.addMenu("&File") + for a in (self.a_new, self.a_plain, self.a_open, self.a_save, self.a_save_as, None, self.a_print, + self.a_lid, self.a_slicer, self.a_pick_slicer, None, self.a_exp_stl, self.a_exp_3mf, self.a_snap, None, + self.a_quit): + fm.addAction(a) if a is not None else fm.addSeparator() + em = mb.addMenu("&Edit") + for a in (self.a_undo, self.a_redo, None, self.a_surprise, None, self.a_copy_plan): + em.addAction(a) if a is not None else em.addSeparator() + dm = mb.addMenu("&Designs") + dm.addActions(self.gallery_menu.actions()) + dm.addSeparator() + dm.addAction(act("Library, profiles and Surprise locks…", lambda: DesignTools(self).exec())) + fm.addAction(act("Save clearance samples…", lambda: save_calibration(self))) + vm = mb.addMenu("&View") + self.view_actions = [] + for i, name in enumerate(VIEW_NAMES): + a = QAction(name, self) + a.setShortcut(QKeySequence(f"Ctrl+{i + 1}")) + a.triggered.connect(lambda _=False, i=i: self.show_view(i)) + vm.addAction(a) + self.view_actions.append(a) + vm.addSeparator() + a = QAction("Spin", self) + a.setShortcut(QKeySequence("S")) + a.triggered.connect(self.spin_btn.toggle) + vm.addAction(a) + a = QAction("Watch it print", self) + a.setShortcut(QKeySequence("F5")) + a.triggered.connect(self.print_btn.toggle) + vm.addAction(a) + a = QAction("Reset view", self) + a.setShortcut(QKeySequence("Ctrl+0")) + a.triggered.connect(lambda: self.show_view(self.view)) + vm.addAction(a) + vm.addSeparator() + vm.addAction(act("Compare original and softened…", lambda: show_comparison(self))) + vm.addAction(self.a_big) + vm.addAction(self.a_light) + hm = mb.addMenu("&Help") + hm.addAction(self.a_printing) + hm.addAction(self.a_function) + hm.addAction(self.a_drawing) + hm.addSeparator() + hm.addAction(self.a_about) + + # A coffee cup after Help, for anyone who would like to chip in. + self.a_kofi = QAction(glyph_icon("coffee"), "", self) + self.a_kofi.setToolTip("Vaseinator is free — buy me a coffee") + self.a_kofi.setStatusTip("Vaseinator is free — buy me a coffee") + self.a_kofi.triggered.connect(lambda: QDesktopServices.openUrl(QUrl(KOFI_URL))) + mb.addAction(self.a_kofi) + self._update_undo() + + # ============================================================ params + def _on_param(self, key, value, live=False): + if key == "head": + self._set_head(value) + return + touched, message = apply_change(self.params, key, value) + for k in touched: + self._set_widget(k) + if message: + self.statusBar().showMessage(message, 8000) + if key in REMEMBERED: + self.settings.setValue(f"default_{key}", float(value)) + self._sync_widgets() + self._dirty = True + self._update_title() + d = self.design + if key in ("lid_grip", "lid_open") and d is not None and not d.draft and not self.regen_timer.isActive(): + for q in (d.p, d.asked): # the lid's own: the cover itself stays as it is + setattr(q, key, getattr(self.params, key)) + self._remember() + return + self._schedule(live=live, text=key in TEXT_KEYS) + + def _set_head(self, cm: float): + """Scale the design to fit a head ``cm`` round.""" + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + fitted = fit_head(self.params, 10.0 * cm) + finally: + QApplication.restoreOverrideCursor() + self.apply_changes({k: getattr(fitted, k) for k in ("silhouette",) + HEAD_SCALED}) + self.statusBar().showMessage(f"Sized for a head {cm:g} cm round — Ctrl+Z to go back", 6000) + + def _show_head(self, d: VaseDesign): + """The head the design fits, in the head size boxes.""" + cm = round(d.head_fit / 10.0, 1) + w = self.widgets["head"] + w.blockSignals(True) + w.setValue(cm) + w.blockSignals(False) + self.simple.show_head(cm) + + def _set_widget(self, key): + w = self.widgets[key] + w.blockSignals(True) + if key == "widest": + w.setValue(self._widest()) + elif isinstance(w, CurveEditor): + w.set_points(getattr(self.params, key)) + elif isinstance(w, OutlineEditor): + w.blockSignals(False) + self._push_outline(int(key[-1])) + elif isinstance(w, QComboBox): + w.setCurrentIndex(max(0, w.findData(getattr(self.params, key)))) + elif isinstance(w, QSpinBox): + w.setValue(int(getattr(self.params, key))) + if key in ("repeat1", "repeat2"): + self._push_outline(int(key[-1])) + elif isinstance(w, QDoubleSpinBox): + v = getattr(self.params, key) + w.setValue(100.0 * v if w.suffix() == " %" else v) + elif isinstance(w, QCheckBox): + w.setChecked(bool(getattr(self.params, key))) + w.blockSignals(False) + + # ----------------------------------------------------- drawn outlines + def _outline_edited(self, k: int, pts, source, live=False): + """An editor changed drawn outline k: the vase follows, and so does the other editor.""" + self._on_param(f"drawn{k}", pts, live=live) + self._push_outline(k, source) + + def _push_outline(self, k: int, source=None): + """Show drawn outline k in its editors - the one in the panel and any full window - but the one it came + from.""" + p = self.params + pts, repeat = getattr(p, f"drawn{k}"), getattr(p, f"repeat{k}") + ed = self.widgets.get(f"drawn{k}") + if ed is not None and ed is not source: + ed.set_outline(pts, repeat) + win = self.outline_windows.get(k) + if win is not None and win.editor is not source: + win.set_outline(pts, repeat) + + def _show_close_up(self, k: int): + ed, b = self.widgets[f"drawn{k}"], self.close_up_buttons[k] + b.blockSignals(True) + b.setEnabled(ed.repeat > 1) + b.setChecked(ed.close_up()) + b.blockSignals(False) + + def _open_outline_window(self, k: int): + """The cross-section editor in a window of its own, as big as the screen.""" + win = self.outline_windows.get(k) + if win is None: + title = "Draw the cross-section" if k == 1 else "Draw the cross-section it turns into" + win = OutlineWindow(self, f"{title} — {APP_NAME}", self.outline_menus[k]) + win.editor.changed.connect(lambda pts, kk=k, e=win.editor: self._outline_edited(kk, pts, e, live=True)) + win.editor.finished.connect(lambda pts, kk=k, e=win.editor: self._outline_edited(kk, pts, e)) + win.repeat_changed.connect(lambda v, kk=k: self._on_param(f"repeat{kk}", int(v))) + self.outline_windows[k] = win + win.set_outline(getattr(self.params, f"drawn{k}"), getattr(self.params, f"repeat{k}")) + win.showMaximized() + win.raise_() + win.activateWindow() + + def _outline_from_shape(self, k: int): + outline_from_shape(self.params, k) + self._set_widget(f"repeat{k}") + self._set_widget(f"drawn{k}") + + def _use_outline(self, k: int, pts, repeat: int): + """Start drawn outline k again from given control points.""" + setattr(self.params, f"repeat{k}", int(repeat)) + self._set_widget(f"repeat{k}") + self._on_param(f"drawn{k}", [list(q) for q in pts]) + self._set_widget(f"drawn{k}") + + def _trace_outline(self, k: int): + start = self.settings.value("picture_dir", self._last_dir()) + path, _ = QFileDialog.getOpenFileName(QApplication.activeWindow() or self, "Trace a picture", start, + "Pictures (*.png *.jpg *.jpeg *.bmp *.gif *.webp);;All files (*)") + if not path: + return + self.settings.setValue("picture_dir", os.path.dirname(path)) + try: + pts = sections.trace_picture(path) + except (ValueError, OSError) as exc: + QMessageBox.warning(self, "Can't trace that picture", + f"{os.path.basename(path)}: {exc}. Draw the outline as one closed dark line on a " + "light or see-through ground.") + return + self._note = (f"Traced {os.path.basename(path)}: {len(pts)} points, " + f"{sum(q[2] for q in pts)} sharp corners. ") + self._use_outline(k, pts, 1) + + def _schedule(self, live=False, text=False): + """Rebuild after a pause; while something is being dragged, quick rough builds follow the hand.""" + if text: + self.regen_timer.start(600) + return + slow = self._last_full_ms > 220 + if live or slow: + self.draft_timer.start(15) + self.regen_timer.start(450 if live else 300) + else: + self.regen_timer.start(120) + + def _on_name(self, text): + self.params.name = text.strip() or "vase" + self._dirty = True + self.workspace.add("recovery", self.params) + self._save_workspace() + self._update_title() + + def _update_title(self): + f = os.path.basename(self.current_file) if self.current_file else "unsaved" + star = " •" if self._dirty else "" + self.setWindowTitle(f"{APP_NAME} — {self.params.name} ({f}){star}") + + # ======================================================= undo / redo + def _remember(self): + d = self.params.to_dict() + if self.history: + self.workspace.add("recovery", self.params) + self._save_workspace() + if not self.history or self.history[-1] != d: + self.history.append(d) + del self.history[:-200] + self.future.clear() + self._update_undo() + + def _save_workspace(self): + self.settings.setValue("workspace", self.workspace.dumps()) + + def _update_undo(self): + if hasattr(self, "a_undo"): + self.a_undo.setEnabled(len(self.history) > 1) + self.a_redo.setEnabled(bool(self.future)) + + def _restore(self, d): + self.params = VaseParams.from_dict(d) + self._dirty = True + self._refresh_widgets() + self.regenerate() + + def _flush(self): + """Build now if a change is still waiting.""" + if self.regen_timer.isActive() or self.draft_timer.isActive(): + self.regen_timer.stop() + self.draft_timer.stop() + self.regenerate() + + def undo(self): + self._flush() + if len(self.history) < 2: + return + self.future.append(self.history.pop()) + self._restore(self.history[-1]) + self._update_undo() + + def redo(self): + self._flush() + if not self.future: + return + d = self.future.pop() + self.history.append(d) + self._restore(d) + self._update_undo() + + def surprise_me(self, seed=None): + self._flush() + seed = random.randint(1, 999_999) if seed is None or seed is False else int(seed) + self.params = locked_surprise(self.params, seed, self.workspace.data["locks"]) + self._dirty = True + self._refresh_widgets() + self.regenerate() + self.statusBar().showMessage(f"Surprise #{seed}: {self.params.name.replace('_', ' ')} — Ctrl+Z to go " + "back", 8000) + + def apply_changes(self, changes: dict, live=False): + """Several settings at once - a picture picked in Simple mode.""" + p = self.params + for key, value in changes.items(): + setattr(p, key, value) + if "profile" in changes and p.profile != OWN_PROFILE: + p.silhouette = outline(p.profile, self._widest()) + self._dirty = True + self._refresh_widgets() + self._schedule(live=live) + + def choose_design(self): + """Every ready-made vase as a picture to click.""" + def entry(title, about, p, picture): + pts = np.asarray(p.silhouette, dtype=float) + return title, about, asset("gallery", picture), profile_icon(pts, QColor(p.color1)) + entries = [entry("Plain cylinder", "200 mm across and 200 mm tall, with everything else off: a blank " + "start for your own shape or function", plain_design(), "plain_cylinder.png")] + entries += [entry(t, about, gallery_design(t), slug(t) + ".png") for t, about, _ in GALLERY] + dlg = GalleryDialog(self, entries) + if dlg.exec() == QDialog.Accepted and dlg.chosen: + if dlg.chosen == "Plain cylinder": + self.use_plain() + else: + self.use_gallery(dlg.chosen) + + def draw_own_shape(self): + """Draw the shape from above, in a window of its own, starting from the shape it is.""" + if self.params.shape1 != DRAWN: + self._on_param("shape1", DRAWN) + self._flush() + self._open_outline_window(1) + + def _set_look(self, big=None, light=None): + """Bigger text, and the light or dark theme - remembered for next time.""" + if big is not None: + self.settings.setValue("big_text", bool(big)) + if light is not None: + self.settings.setValue("light_theme", bool(light)) + apply_theme(QApplication.instance(), light=self.settings.value("light_theme", False, type=bool), + scale=1.3 if self.settings.value("big_text", False, type=bool) else 1.0) + # the line icons are drawn in the theme's ink: draw them again + for a, kind in ((self.a_exp_stl, "export"), (self.a_print, "export"), (self.a_snap, "picture"), + (self.a_surprise, "dice"), (self.a_undo, "undo"), (self.a_redo, "redo"), + (self.a_gallery, "gallery"), (self.a_kofi, "coffee")): + a.setIcon(glyph_icon(kind)) + self.gal_btn.setIcon(glyph_icon("gallery")) + self.dice_btn.setIcon(glyph_icon("dice")) + + def use_plain(self): + """A blank start: the plain cylinder, keeping the printer settings.""" + self._flush() + self.params = plain_design(keep=self.params) + self._dirty = True + self._refresh_widgets() + self.regenerate() + self.statusBar().showMessage("Plain cylinder, 200 mm across and 200 mm tall - type your own function, or " + "choose Drawn as the cross-section; Ctrl+Z to go back", 12000) + + def use_gallery(self, title): + self._flush() + self.params = gallery_design(title, keep=self.params) + self._dirty = True + self._refresh_widgets() + self.regenerate() + self.statusBar().showMessage(f"{title} — your printer settings are kept; Ctrl+Z to go back", 8000) + + # ============================================================ build + def regenerate(self, draft=False): + if not draft: + self.draft_timer.stop() + t0 = time.perf_counter() + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + d = VaseDesign(self.params.copy(), draft=draft) + except Exception as exc: # pragma: no cover - safety net for the GUI + self._show_info(None, [f"Internal error: {exc}"], []) + return + finally: + QApplication.restoreOverrideCursor() + if not draft: + self._remember() + if d.errors: + self._show_info(None, d.errors, d.warnings) + self.statusBar().showMessage("This vase cannot be built - see the panel on the right") + return + self.design = d + self._show_design(d) + self._show_head(d) + dt = (time.perf_counter() - t0) * 1000 + if not draft: + self._last_full_ms = dt + self._show_info(d, [], d.warnings) + note, self._note = self._note, "" + self.statusBar().showMessage(f"{note}Built in {dt:.0f} ms — {d.triangles:,} triangles", + 10000 if note else 6000) + + def _show_design(self, d: VaseDesign): + colors = d.p.colors() + phase = d.colour_phase(d.shell_verts[:, 2]) if d.gradient else None + self.viewer.set_mesh("vase", d.shell_verts, d.shell_faces, colors[d.bands[0][1]], + face_vals=d.shell_lean, vert_vals=phase, flat=d.rows is not None) + self.viewer.set_section_source(d.shell_verts, d.shell_faces) + self.viewer.set_bed(d.p.bed_x, d.p.bed_y, d.p.printer_height) + self.plots.set_design(d, colors) + self._apply_view() + if self.printing is not None: + self._anim_tick() + + def _show_info(self, d, errors, warnings): + self.info.setHtml(info_html(d, errors, warnings)) + + + # ============================================================ view + def show_view(self, idx): + self.view = idx + self._free_camera = False + self._fitted = None + self.view_btns[idx].setChecked(True) + self._apply_view() + + def _apply_view(self): + d, v = self.design, self.viewer + if d is None: + return + p = d.p + v.show_bed = self.view == PRINT_LAYOUT + v.section = self.view == SECTION + colors = p.colors() + v.bands = [(z, colors[f]) for z, f in d.bands] + v.gradient = [colors[f] for f in d.gradient_fils] + v.colmode = 3 if self.view == OVERHANGS else (2 if d.gradient else 1) + v.limit = p.max_overhang + v.look = p.look + if self.view == OVERHANGS: + self.hint.setText(f"Green: easy · orange: near {p.max_overhang:g}° · red: over it, needs support") + elif self.printing is None: + self.hint.setText("Drag to turn · wheel to zoom · right-drag to move") + size = (round(float(d.size[0]), 0), round(float(d.size[1]), 0), round(d.H, 0)) + if self._fitted != (self.view, size): + r = 0.5 * math.hypot(d.size[0], d.size[1]) + reach = math.hypot(r, 0.5 * d.H) + aspect = max(v.width(), 1) / max(v.height(), 1) + fov = math.radians(v.fov) * min(1.0, aspect) + v.target = np.array([0.0, 0.0, 0.5 * d.H]) + v.dist = reach / math.sin(fov / 2.0) * (1.75 if self.view == PRINT_LAYOUT else 1.12) + self._fitted = (self.view, size) + if not self._free_camera: + v.yaw, v.pitch = {VASE: (-60.0, 14.0), SECTION: (-90.0, 8.0), OVERHANGS: (-60.0, 14.0), + PRINT_LAYOUT: (-55.0, 32.0)}[self.view] + v.set_model(spin_transform(self.spin_angle)) + v.update() + + def _on_dragged(self): + self._free_camera = True + + def _on_resized(self): + if not self._free_camera: + self._fitted = None + self._apply_view() + + # ------------------------------------------------- spin and printing + def set_spin(self, on: bool): + self.spinning = bool(on) + self.settings.setValue("spin", self.spinning) + self._update_anim() + + def set_printing(self, on: bool): + if on and self.design is not None: + self.printing = time.perf_counter() + else: + self.printing = None + self.viewer.cut_z = None + self.viewer.set_lines(None) + self._apply_view() + self._update_anim() + + def _update_anim(self): + if self.spinning or self.printing is not None: + self._last_tick = time.perf_counter() + self.anim_timer.start() + else: + self.anim_timer.stop() + + def _print_length(self): + d = self.design + return float(np.clip(6.0 + d.H / 30.0, 6.0, 16.0)) + + def _anim_tick(self): + now = time.perf_counter() + dt, self._last_tick = now - self._last_tick, now + d, v = self.design, self.viewer + if d is None: + return + if self.spinning: + self.spin_angle = (self.spin_angle + SPIN_SPEED * min(dt, 0.1)) % 360.0 + v.set_model(spin_transform(self.spin_angle)) + if self.printing is not None: + span = self._print_length() + t = (now - self.printing) / span + if t > 1.25: # hold the finished vase a moment, then again + self.printing = now + t = 0.0 + t = min(t, 1.0) + # the real print time: the filament used up to each height, at an even speed + done = d.filament_at[-1] * t + z = float(np.interp(done, d.filament_at, d.filament_z)) if d.filament_at[-1] > 0 else t * d.H + z = min(max(z, 0.05), d.H) + v.cut_z = z if t < 1.0 else None + v.set_lines(d.contour(z) if t < 1.0 else None) + layer = min(int(math.ceil(z / d.p.layer_height)), d.layers) + self.hint.setText(f"Printing: layer {layer} of {d.layers} · {duration(d.seconds * t)} of " + f"{duration(d.seconds)}" if t < 1.0 else f"Done: {duration(d.seconds)}, " + f"{d.grams:.0f} g of filament") + v.update() + + # ============================================================ files + def _last_dir(self): + return self.settings.value("last_dir", os.path.join(os.path.expanduser("~"), "Documents")) + + def _remember_dir(self, path): + self.settings.setValue("last_dir", os.path.dirname(path)) + + def _safe_name(self): + name = "".join(ch if ch.isalnum() or ch in "-_ " else "_" for ch in self.params.name).strip() + return name or "vase" + + def new_design(self): + if not self._confirm_discard(): + return + self.params = self._fresh_params() + self.current_file = None + self._dirty = False + self._refresh_widgets() + self.regenerate() + + def _confirm_discard(self): + if not self._dirty: + return True + r = QMessageBox.question(self, "Unsaved changes", "Discard unsaved changes to the current design?", + QMessageBox.Discard | QMessageBox.Cancel, QMessageBox.Cancel) + return r == QMessageBox.Discard + + def open_design(self): + if not self._confirm_discard(): + return + path, _ = QFileDialog.getOpenFileName(self, "Open design", self._last_dir(), + f"Vaseinator designs (*{FILE_EXT} *.json);;All files (*)") + if path: + self.load_file(path) + + def load_file(self, path): + try: + params = load_design(path) + except Exception as exc: + QMessageBox.critical(self, "Open failed", f"Could not open the design:\n{exc}") + return + self._remember_dir(path) + self.params = params + self.current_file = path + self._dirty = False + self._refresh_widgets() + self.regenerate() + self.statusBar().showMessage(f"Opened {path}", 6000) + + def save_design(self, as_new=False): + path = self.current_file + if as_new or not path: + default = os.path.join(self._last_dir(), self._safe_name() + FILE_EXT) + path, _ = QFileDialog.getSaveFileName(self, "Save design", default, f"Vaseinator designs (*{FILE_EXT})") + if not path: + return + if not os.path.splitext(path)[1]: + path += FILE_EXT + try: + save_design(path, self.params) + except OSError as exc: + QMessageBox.critical(self, "Save failed", str(exc)) + return + self._remember_dir(path) + self.current_file = path + self._dirty = False + self._update_title() + self.statusBar().showMessage(f"Saved {path}", 6000) + + def _current_design(self): + """The design to export, rebuilt in full first if a change is still waiting.""" + self._flush() + d = self.design + if d is not None and d.draft: + self.regenerate() + d = self.design + if d is None or {**d.asked.to_dict(), "name": ""} != {**self.params.to_dict(), "name": ""}: + QMessageBox.warning(self, "Nothing to export", "The current settings do not make a vase.") + return None + try: + require_exportable(d) + except ValueError as exc: + QMessageBox.warning(self, "Not exported", str(exc)) + return None + return d + + def export_stl(self): + d = self._current_design() + if d is None: + return + base = self._safe_name() + if not base.endswith("_vase"): + base += "_vase" # make it obvious this is a solid model for spiral vase mode + path, _ = QFileDialog.getSaveFileName(self, "Export STL", os.path.join(self._last_dir(), base + ".stl"), + "STL files (*.stl)") + if not path: + return + if not path.lower().endswith(".stl"): + path += ".stl" + try: + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + verts, faces = d.export_mesh() + finally: + QApplication.restoreOverrideCursor() + write_stl(path, verts, faces, base) + except OSError as exc: + QMessageBox.critical(self, "Export failed", str(exc)) + return + self._remember_dir(path) + if d.p.vase_mode == REAL_VASE: + how = "slice it with spiral vase mode on" + else: + how = ("the hollow pot: slice it with your usual settings (an STL has no colours: Export 3MF has the " + "bands as parts)") + self.statusBar().showMessage( + f"Exported {os.path.basename(path)} ({len(faces):,} triangles, watertight) to " + f"{os.path.dirname(path)} — {how}", 12000) + + def band_parts(self, d): + """The vase for write_3mf: a part per colour band, set to its filament.""" + colors = d.p.colors() + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + parts = d.export_parts() + finally: + QApplication.restoreOverrideCursor() + many = len(parts) > 1 + return [(f"{self._safe_name()} band {k + 1}" if many else self._safe_name(), verts, faces, fil, colors[fil]) + for k, (fil, verts, faces) in enumerate(parts)] + + def export_3mf(self): + """The vase for the slicer: one solid for a real spiral vase, a part per colour band for a fake one.""" + d = self._current_design() + if d is None: + return + p = d.p + base = self._safe_name() + path, _ = QFileDialog.getSaveFileName(self, "Export 3MF", os.path.join(self._last_dir(), base + ".3mf"), + "3MF files (*.3mf)") + if not path: + return + if not path.lower().endswith(".3mf"): + path += ".3mf" + parts = self.band_parts(d) + try: + write_3mf(path, base, parts, (p.bed_x / 2.0, p.bed_y / 2.0, 0.0)) + except OSError as exc: + QMessageBox.critical(self, "Export failed", str(exc)) + return + self._remember_dir(path) + if p.vase_mode == REAL_VASE: + how = "turn on spiral vase" + else: + how = "leave spiral vase off and print with your usual settings" + if len(parts) > 1: + fils = ", ".join(str(f) for f in sorted({f for _, _, _, f, _ in parts})) + what = f"{len(parts)} bands set to filaments {fils}" + else: + what = "the vase, one part" + self.statusBar().showMessage( + f"Exported {os.path.basename(path)}: {what} — in Flash Studio use File ▸ Import (Ctrl+I), then {how}", + 15000) + + def save_for_printing(self): + """The one button Simple mode needs: the vase as a 3MF for the slicer, and plain steps to print it.""" + d = self._current_design() + if d is None: + return + base = self._safe_name() + path, _ = QFileDialog.getSaveFileName(self, "Save for printing", os.path.join(self._last_dir(), base + ".3mf"), + "3MF files, for the slicer (*.3mf)") + if not path: + return + if not path.lower().endswith(".3mf"): + path += ".3mf" + parts = self.band_parts(d) + try: + write_3mf(path, base, parts, (d.p.bed_x / 2.0, d.p.bed_y / 2.0, 0.0)) + except OSError as exc: + QMessageBox.critical(self, "Couldn't save it", str(exc)) + return + self._remember_dir(path) + self.print_file = path + self.statusBar().showMessage(f"Saved {os.path.basename(path)} for printing, in {os.path.dirname(path)}", + 12000) + self.steps = PrintSteps(self, d, path, len(parts), self._slicer(ask=False)) + self.steps.open_slicer.connect(lambda: self.open_in_slicer(path)) + self.steps.show() + + def save_lid(self): + """A tissue box cover's lid, made to fit it, as a 3MF for the slicer - with how to print it and put it on.""" + d = self._current_design() + if d is None: + return + lid = Lid(d) + try: + require_exportable(lid) + except ValueError as exc: + QMessageBox.warning(self, "No lid", str(exc)) + return + base = self._safe_name() + " lid" + path, _ = QFileDialog.getSaveFileName(self, "Save the lid for printing", + os.path.join(self._last_dir(), base + ".3mf"), + "3MF files, for the slicer (*.3mf)") + if not path: + return + if not path.lower().endswith(".3mf"): + path += ".3mf" + try: + write_3mf(path, base, [(base, lid.verts, lid.faces, 1, d.p.colors()[1])], + (d.p.bed_x / 2.0, d.p.bed_y / 2.0, 0.0)) + except OSError as exc: + QMessageBox.critical(self, "Couldn't save it", str(exc)) + return + self._remember_dir(path) + self.print_file = path + self.statusBar().showMessage(f"Saved {os.path.basename(path)} for printing, in {os.path.dirname(path)}", + 12000) + self.steps = PrintSteps(self, d, path, 1, self._slicer(ask=False), + steps=lambda where: lid_steps(lid, path, where)) + self.steps.open_slicer.connect(lambda: self.open_in_slicer(path)) + self.steps.show() + + def _slicer(self, ask=True): + """The slicer program to open a vase in: the one chosen before, else the first one installed.""" + path = self.settings.value("slicer", "", type=str) + if path and os.path.exists(path): + return path + found = slicers.find_slicers() + if found: + self.settings.setValue("slicer", found[0][1]) + return found[0][1] + return self.choose_slicer() if ask else None + + def choose_slicer(self): + found = slicers.find_slicers() + start = os.path.dirname(found[0][1]) if found else os.environ.get("ProgramFiles", os.path.expanduser("~")) + path, _ = QFileDialog.getOpenFileName(QApplication.activeWindow() or self, "Choose your slicer program", + start, "Programs (*.exe);;All files (*)") + if not path: + return None + self.settings.setValue("slicer", path) + self.statusBar().showMessage(f"Open in slicer will use {slicers.slicer_name(path)}", 8000) + return path + + def open_in_slicer(self, path=None): + """Open the vase saved for printing in the slicer (saving it first if it hasn't been).""" + path = path or getattr(self, "print_file", None) + if not path or not os.path.exists(path): + self.save_for_printing() + return + program = self._slicer() + if not program: + return + if slicers.open_in(program, path): + self.statusBar().showMessage(f"Opening {os.path.basename(path)} in {slicers.slicer_name(program)}…", + 10000) + else: + QMessageBox.warning(self, "Couldn't open the slicer", + f"{program} didn't start. Choose your slicer program in the File menu, or open " + f"{path} in it yourself.") + + def save_snapshot(self): + default = os.path.join(self._last_dir(), self._safe_name() + "_3d.png") + path, _ = QFileDialog.getSaveFileName(self, "Save picture", default, "PNG image (*.png)") + if not path: + return + if not path.lower().endswith(".png"): + path += ".png" + self.viewer.grabFramebuffer().save(path) + self._remember_dir(path) + self.statusBar().showMessage(f"Saved picture to {path}", 8000) + + def copy_colour_plan(self): + self._flush() + if self.design is None: + return + QApplication.clipboard().setText(self.design.colour_plan()) + self.statusBar().showMessage("Colour changes copied to the clipboard", 6000) + + def printing_help(self): + QMessageBox.information(self, "Printing it watertight", PRINTING_HELP) + + def drawing_help(self): + QMessageBox.information(self, "Drawing a cross-section", DRAWING_HELP) + + def about(self): + QMessageBox.about(self, f"About {APP_NAME}", ABOUT) + + def closeEvent(self, ev): + if not self._confirm_discard(): + ev.ignore() + return + self.workspace.add("recovery", self.params) + self._save_workspace() + self.settings.setValue("geometry", self.saveGeometry()) + super().closeEvent(ev) + + +# ============================================================== bootstrap +def apply_dark_theme(app: QApplication): + apply_theme(app, light=False) + + +def apply_theme(app: QApplication, light: bool = False, scale: float = 1.0): + """The look: dark (the usual) or light, and the writing at its own size or bigger.""" + app.setStyle("Fusion") + base = app.property("vaseinator_base_pt") + if base is None: + base = app.font().pointSizeF() + app.setProperty("vaseinator_base_pt", base) + font = app.font() + font.setPointSizeF(base * scale) + app.setFont(font) + title = f"QLabel#simpleTitle {{ font-weight: 700; font-size: {base * scale * 1.25:.1f}pt; }}" + if light: + _light_theme(app) + app.setStyleSheet(app.styleSheet() + title) + return + pal = QPalette() + c = QColor + pal.setColor(QPalette.Window, c(36, 39, 45)) + pal.setColor(QPalette.WindowText, c(222, 225, 230)) + pal.setColor(QPalette.Base, c(26, 28, 33)) + pal.setColor(QPalette.AlternateBase, c(44, 47, 54)) + pal.setColor(QPalette.ToolTipBase, c(52, 56, 64)) + pal.setColor(QPalette.ToolTipText, c(230, 230, 230)) + pal.setColor(QPalette.Text, c(222, 225, 230)) + pal.setColor(QPalette.Button, c(50, 54, 62)) + pal.setColor(QPalette.ButtonText, c(222, 225, 230)) + pal.setColor(QPalette.BrightText, c(255, 90, 90)) + pal.setColor(QPalette.Highlight, c(56, 132, 214)) + pal.setColor(QPalette.HighlightedText, c(255, 255, 255)) + pal.setColor(QPalette.Link, c(110, 180, 255)) + pal.setColor(QPalette.PlaceholderText, c(130, 135, 145)) + for role in (QPalette.Text, QPalette.ButtonText, QPalette.WindowText): + pal.setColor(QPalette.Disabled, role, c(120, 124, 132)) + app.setPalette(pal) + app.setStyleSheet(""" + QGroupBox { font-weight: 600; border: 1px solid #3b4049; border-radius: 6px; + margin-top: 12px; padding-top: 4px; } + QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 4px; color: #f0a356; } + QTextBrowser { border: none; } + QToolBar { border: none; spacing: 4px; padding: 3px; } + QSplitter::handle { background: #2b2e34; } + QPushButton#modeButton { border: 1px solid #3b4049; padding: 4px 12px; background: #2a2d33; } + QPushButton#modeButton:checked { background: #3884d6; color: white; border-color: #3884d6; } + QToolButton#pictureButton { border: 1px solid #3b4049; border-radius: 6px; padding: 4px; + background: #2a2d33; } + QToolButton#pictureButton:hover { border-color: #6b7380; } + QToolButton#pictureButton:checked { border: 2px solid #f0a356; background: #3a332b; } + QPushButton#bigButton { padding: 8px 10px; font-weight: 600; } + QPushButton#saveButton { background: #f0a356; color: #1a1c21; font-weight: 700; border-radius: 6px; + border: 1px solid #f0a356; } + QPushButton#saveButton:hover { background: #f5b878; } + QLabel#simpleTitle { color: #f0a356; } + """ + title) + + +def _light_theme(app: QApplication): + pal = QPalette() + c = QColor + pal.setColor(QPalette.Window, c(238, 240, 243)) + pal.setColor(QPalette.WindowText, c(28, 30, 34)) + pal.setColor(QPalette.Base, c(255, 255, 255)) + pal.setColor(QPalette.AlternateBase, c(232, 235, 239)) + pal.setColor(QPalette.ToolTipBase, c(255, 255, 238)) + pal.setColor(QPalette.ToolTipText, c(20, 20, 20)) + pal.setColor(QPalette.Text, c(28, 30, 34)) + pal.setColor(QPalette.Button, c(226, 229, 234)) + pal.setColor(QPalette.ButtonText, c(28, 30, 34)) + pal.setColor(QPalette.BrightText, c(200, 30, 30)) + pal.setColor(QPalette.Highlight, c(56, 132, 214)) + pal.setColor(QPalette.HighlightedText, c(255, 255, 255)) + pal.setColor(QPalette.Link, c(20, 100, 200)) + pal.setColor(QPalette.PlaceholderText, c(120, 125, 135)) + for role in (QPalette.Text, QPalette.ButtonText, QPalette.WindowText): + pal.setColor(QPalette.Disabled, role, c(150, 154, 162)) + app.setPalette(pal) + app.setStyleSheet(""" + QGroupBox { font-weight: 600; border: 1px solid #c4c9d1; border-radius: 6px; + margin-top: 12px; padding-top: 4px; } + QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 4px; color: #b45f12; } + QTextBrowser { border: none; } + QToolBar { border: none; spacing: 4px; padding: 3px; } + QSplitter::handle { background: #d5d9df; } + QPushButton#modeButton { border: 1px solid #c4c9d1; padding: 4px 12px; background: #e6e8ec; } + QPushButton#modeButton:checked { background: #3884d6; color: white; border-color: #3884d6; } + QToolButton#pictureButton { border: 1px solid #c4c9d1; border-radius: 6px; padding: 4px; + background: #f6f7f9; } + QToolButton#pictureButton:hover { border-color: #8c939e; } + QToolButton#pictureButton:checked { border: 2px solid #e08a2c; background: #fcefe0; } + QPushButton#bigButton { padding: 8px 10px; font-weight: 600; } + QPushButton#saveButton { background: #e08a2c; color: white; font-weight: 700; border-radius: 6px; + border: 1px solid #e08a2c; } + QPushButton#saveButton:hover { background: #ea9b44; } + QLabel#simpleTitle { color: #b45f12; } + """) + + +def place_for_testing(win: QMainWindow, argv): + """Put a test run's window on another screen than the main one (the second, unless --screen N), + so testing doesn't get in the way of whoever is at the computer. + + Windows places a new window itself, so the window is shown see-through, + moved once it is up, and only then made visible. + """ + from PySide6.QtCore import QRect + + screens = QApplication.screens() + index = int(argv[argv.index("--screen") + 1]) if "--screen" in argv else (1 if len(screens) > 1 else 0) + screen = screens[min(max(index, 0), len(screens) - 1)] + g = screen.availableGeometry() + win.setWindowOpacity(0.0) + win.show() + QApplication.processEvents() + win.windowHandle().setScreen(screen) + win.setGeometry(QRect(g.x() + 10, g.y() + 30, min(1480, g.width() - 20), min(940, g.height() - 40))) + QApplication.processEvents() + win.setWindowOpacity(1.0) + + +def main(argv=None): + argv = list(sys.argv if argv is None else argv) + fmt = QSurfaceFormat() + fmt.setDepthBufferSize(24) + fmt.setSamples(8) + fmt.setVersion(2, 1) + fmt.setProfile(QSurfaceFormat.CompatibilityProfile) + QSurfaceFormat.setDefaultFormat(fmt) + + app = QApplication(argv) + app.setApplicationName(APP_NAME) + app.setOrganizationName("Vaseinator") + apply_dark_theme(app) + settings = None + if "--screenshot" in argv: # a test run keeps its hands off the remembered settings + import tempfile + settings = QSettings(os.path.join(tempfile.gettempdir(), "Vaseinator-test.ini"), QSettings.IniFormat) + settings.clear() + settings.setValue("spin", False) + win = MainWindow(settings) + if "--screenshot" in argv: + place_for_testing(win, argv) + else: + win.show() + + valued = ("--screenshot", "--view", "--export-test", "--yaw", "--pitch", "--zoom", "--gallery", "--surprise", + "--print-at", "--spin", "--screen") + files = [a for i, a in enumerate(argv) if i > 0 and not a.startswith("--") + and argv[i - 1] not in valued and os.path.isfile(a)] + if files: + win.load_file(files[0]) + + # developer hook: --screenshot out.png [--view N] [--gallery NAME] [--surprise SEED] [--yaw a] + # [--pitch a] [--zoom k] [--print-at 0..1] [--spin deg] [--detailed] [--export-test DIR] + # [--screen N] (the window opens on the second screen, if there is one) [--only-3d] + if "--screenshot" in argv: + out = argv[argv.index("--screenshot") + 1] + win.set_simple_mode("--detailed" not in argv, remember=False) + if "--gallery" in argv: + win.use_gallery(argv[argv.index("--gallery") + 1]) + if "--surprise" in argv: + win.surprise_me(int(argv[argv.index("--surprise") + 1])) + if "--view" in argv: + win.show_view(int(argv[argv.index("--view") + 1])) + if "--export-test" in argv: + out_dir = argv[argv.index("--export-test") + 1] + d = win.design + write_stl(os.path.join(out_dir, "t_vase.stl"), d.verts, d.faces, "t") + write_3mf(os.path.join(out_dir, "t.3mf"), "t", win.band_parts(d), (d.p.bed_x / 2, d.p.bed_y / 2, 0.0)) + if d.p.box: + lid = Lid(d) + write_3mf(os.path.join(out_dir, "t_lid.3mf"), "t lid", [("t lid", lid.verts, lid.faces, 1, (1, 1, 1))]) + + def snap(): + win._fitted = None + win._apply_view() + if "--spin" in argv: + win.spin_angle = float(argv[argv.index("--spin") + 1]) + win.viewer.set_model(spin_transform(win.spin_angle)) + for flag, attr in (("--yaw", "yaw"), ("--pitch", "pitch")): + if flag in argv: + setattr(win.viewer, attr, float(argv[argv.index(flag) + 1])) + if "--zoom" in argv: + win.viewer.dist /= float(argv[argv.index("--zoom") + 1]) + if "--print-at" in argv: + t = float(argv[argv.index("--print-at") + 1]) + d = win.design + z = float(np.interp(d.filament_at[-1] * t, d.filament_at, d.filament_z)) + win.viewer.cut_z = z + win.viewer.set_lines(d.contour(z)) + layer = int(math.ceil(z / d.p.layer_height)) + win.hint.setText(f"Printing: layer {layer} of {d.layers} · {duration(d.seconds * t)} of " + f"{duration(d.seconds)}") + win.viewer.repaint() + (win.viewer.grabFramebuffer() if "--only-3d" in argv else win.grab()).save(out) + win._dirty = False # a test run has nothing to save: close without asking + app.quit() + QTimer.singleShot(2200, snap) + + return app.exec() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/vaseinator/design_tools.py b/vaseinator/design_tools.py new file mode 100644 index 0000000..51da76b --- /dev/null +++ b/vaseinator/design_tools.py @@ -0,0 +1,237 @@ +"""Desktop controls for the shared portable design tools.""" + +import json + +from PySide6.QtCore import Qt, QSize, QByteArray +from PySide6.QtGui import QIcon, QPixmap, QPainter +from PySide6.QtSvg import QSvgRenderer +from PySide6.QtSvgWidgets import QSvgWidget +from PySide6.QtWidgets import ( + QDialog, + QVBoxLayout, + QHBoxLayout, + QLabel, + QPushButton, + QCheckBox, + QTabWidget, + QWidget, + QListWidget, + QListWidgetItem, + QInputDialog, + QFileDialog, + QMessageBox, +) + +from .workspace import LOCKS, comparison, calibration_parts, CALIBRATION_HELP, thumbnail +from .params import VaseParams +from .threemf import write_3mf + + +def show_comparison(win): + win._flush() + d = win._current_design() + if d is None: + return + data = comparison(d) + dlg = QDialog(win) + dlg.setWindowTitle("Original and softened") + dlg.resize(700, 500) + layout = QVBoxLayout(dlg) + label = QLabel(data["text"]) + label.setWordWrap(True) + layout.addWidget(label) + svg = QSvgWidget() + svg.load(QByteArray(data["svg"].encode())) + layout.addWidget(svg) + dlg.exec() + + +def save_calibration(win): + path, _ = QFileDialog.getSaveFileName( + win, "Save clearance samples", "clearance_samples.3mf", "3MF (*.3mf)" + ) + if not path: + return + if not path.lower().endswith(".3mf"): + path += ".3mf" + try: + write_3mf( + path, + "Clearance samples", + calibration_parts(), + (win.params.bed_x / 2, win.params.bed_y / 2, 0), + ) + except OSError as exc: + QMessageBox.warning(win, "Could not save samples", str(exc)) + return + QMessageBox.information(win, "How to use the samples", CALIBRATION_HELP) + + +class DesignTools(QDialog): + def __init__(self, win): + super().__init__(win) + self.win = win + self.setWindowTitle("Design library and profiles") + self.resize(760, 620) + layout = QVBoxLayout(self) + layout.addWidget( + QLabel( + "Keep these when using Surprise me (printer settings are always kept):" + ) + ) + row = QHBoxLayout() + self.lock_boxes = {} + for name in LOCKS: + box = QCheckBox(name) + box.setChecked(name in win.workspace.data["locks"]) + row.addWidget(box) + self.lock_boxes[name] = box + box.toggled.connect(lambda on, n=name: self.lock(n, on)) + layout.addLayout(row) + note = QLabel( + "Library and recovery stay on this computer. Export a library backup to transfer it to the web app. Recovery keeps the last 30 settled designs. Thumbnails are silhouette sketches." + ) + note.setWordWrap(True) + layout.addWidget(note) + tabs = QTabWidget() + layout.addWidget(tabs) + self.lists = {} + for category, title in [ + ("designs", "My designs"), + ("profiles", "Printer and material profiles"), + ("recovery", "Recovery"), + ]: + page = QWidget() + box = QVBoxLayout(page) + entries = QListWidget() + entries.setIconSize(QSize(80, 80)) + box.addWidget(entries) + self.lists[category] = entries + buttons = QHBoxLayout() + box.addLayout(buttons) + + def button(text, callback): + b = QPushButton(text) + b.clicked.connect(callback) + buttons.addWidget(b) + + if category == "designs": + button("Save current variant", lambda: self.save("designs")) + button("Favourite", lambda: self.selected("designs", "favorite")) + if category == "profiles": + button("Save printer", lambda: self.save("profiles", "printer")) + button("Save material", lambda: self.save("profiles", "material")) + button( + "Restore / apply", + lambda _=False, c=category: self.selected(c, "restore"), + ) + button("Remove", lambda _=False, c=category: self.selected(c, "delete")) + tabs.addTab(page, title) + row = QHBoxLayout() + layout.addLayout(row) + for text, fn in [ + ("Export library backup", self.export_file), + ("Import library", self.import_file), + ("Close", self.accept), + ]: + b = QPushButton(text) + b.clicked.connect(fn) + row.addWidget(b) + self.refresh() + + def lock(self, name, on): + locks = set(self.win.workspace.data["locks"]) + locks.add(name) if on else locks.discard(name) + self.win.workspace.command("locks", self.win.params, {"locks": list(locks)}) + self.win._save_workspace() + + def refresh(self): + for name, box in self.lock_boxes.items(): + box.blockSignals(True) + box.setChecked(name in self.win.workspace.data["locks"]) + box.blockSignals(False) + for category, widget in self.lists.items(): + widget.clear() + for e in sorted( + self.win.workspace.data[category], + key=lambda e: (e["favorite"], e["created"]), + reverse=True, + ): + text = ( + ("★ " if e["favorite"] else "") + e["name"] + " — " + e["created"] + ) + if category == "profiles": + text = e["kind"].title() + ": " + text + item = QListWidgetItem(text) + item.setData(Qt.UserRole, e["id"]) + if category == "designs": + renderer = QSvgRenderer( + QByteArray( + thumbnail(VaseParams.from_dict(e["params"])).encode() + ) + ) + pix = QPixmap(80, 80) + pix.fill(Qt.transparent) + painter = QPainter(pix) + renderer.render(painter) + painter.end() + item.setIcon(QIcon(pix)) + widget.addItem(item) + + def save(self, category, kind="printer"): + name, ok = QInputDialog.getText( + self, + "Save variant" if category == "designs" else "Save profile", + "Name:", + text=self.win.params.name, + ) + if ok and name.strip(): + self.run("save", dict(category=category, name=name, kind=kind)) + + def selected(self, category, op): + item = self.lists[category].currentItem() + if item is None: + return + if ( + op == "delete" + and QMessageBox.question(self, "Remove entry", "Remove this saved entry?") + != QMessageBox.Yes + ): + return + self.run(op, dict(category=category, id=item.data(Qt.UserRole))) + + def run(self, op, payload): + try: + self.win._flush() + result = self.win.workspace.command(op, self.win.params, payload) + if result["params"]: + if payload.get("category") != "profiles": + self.win.current_file = None + self.win._restore(result["params"]) + self.win._save_workspace() + self.refresh() + except (ValueError, KeyError, StopIteration) as exc: + QMessageBox.warning(self, "Design tools", str(exc)) + + def export_file(self): + path, _ = QFileDialog.getSaveFileName( + self, "Export library", "vaseinator-library.json", "JSON (*.json)" + ) + if path: + try: + with open(path, "w", encoding="utf-8") as fh: + fh.write(self.win.workspace.dumps()) + except OSError as exc: + QMessageBox.warning(self, "Export failed", str(exc)) + + def import_file(self): + path, _ = QFileDialog.getOpenFileName( + self, "Import library", "", "JSON (*.json)" + ) + if path: + try: + with open(path, encoding="utf-8") as fh: + data = json.load(fh) + self.run("import", data) + except (ValueError, OSError) as exc: + QMessageBox.warning(self, "Import failed", str(exc)) diff --git a/vaseinator/geometry.py b/vaseinator/geometry.py index a818cdb..933d1b9 100644 --- a/vaseinator/geometry.py +++ b/vaseinator/geometry.py @@ -1,1611 +1,1620 @@ -"""The vase itself: its surface, made printable, as closed watertight meshes. - -The surface is a radial height map r(theta, z) on a grid of heights and -angles. Because every height has exactly one radius in each direction, -each layer is one simple closed loop round the axis - just what a spiral -vase slicer needs - and the mesh can never cross itself: every triangle -lies in its own wedge of angle between two heights. - -Overhangs are measured on the real surface normal. A wall leaning out *or -in* overhangs the single printed line beneath it, so both count. Where the -wall leans too far, the radius is limited column by column (a Lipschitz -envelope from above and below, averaged), with the allowed change per layer -worked out from how the wall faces there - a flank that faces sideways may -move a long way sideways. The finished triangles are then checked, and the -limit tightened until every one of them is within it. -""" - -from __future__ import annotations - -import math - -import numpy as np - -from .expr import ExprError, compile_expression -from . import hollow, sections -from .params import (ALMOND_EYES, BANDS, CHIN_MOUTH, DIAMOND, DRAWN, EVENLY, FAKE_VASE, GRADIENT, GRILLE_MOUTH, - MAX_BANDS, NO_EYES, NO_HOLES, NO_MOUTH, NO_NOSE, NO_TISSUE, ONE_COLOUR, OPEN_MOUTH, OWN, - RAISED_NOSE, REAL_VASE, RECTANGLE, ROUND, SAME, SLOTS, STRIPES, TISSUE_OVAL, TISSUE_ROUND, - VORONOI, VaseParams, colour_style_of, face_holes, needs_normal) -from .shapes import (TABLE, TAU, blur, change, lobes_needed, organic, pchip, progress, rounded_rectangle, sample, - shape_table, smooth_max, smoothstep, strength) - -TOLERANCE = 0.3 # degrees over the overhang limit still counted as within it -HOLE_WORDS = {VORONOI: "Voronoi", ROUND: "round holes", DIAMOND: "diamonds", SLOTS: "slots"} -VORONOI_BRIDGE = 0.7 # a Voronoi oval's round top is bridged once it is this share of the cells' size across -VORONOI_LONGEST = 14.0 # ... and never more than this (mm): a round top that size bridges on most printers -HEAD_ROOM = 10.0 # mm more round the inside of a mask or helmet than round the head it fits -SIMPLIFY_ERROR = 1e-3 # how far saving may simplify the mesh: pyfqmr's lossless error, a few hundredths of a mm -SIMPLIFY_MM = 0.02 # ... and in the web version, meshoptimizer's (mm) -BOX_ROOM = 2.0 # mm to spare each side between a tissue box cover and the box of tissues inside it -BOX_TOP_ROOM = 3.0 # ... and over the box's top -BOX_END_ROOM = 2.0 # ... and more inside each end, for the lid's tabs -LEDGE_DEPTH = 1.2 # a cover's ledges, inside each end at the rim, that its lid clicks in behind (mm) -LEDGE_FROM = 4.0 # ... how far in from the rim each starts, the cover on -LEDGE_RAMP = 3.5 # ... the gentle slope the lid's tabs ride over, going on -LEDGE_FLAT = 0.4 # ... its flat -LEDGE_LEAN = 35.0 # ... and the steep side they catch on, from upright: an overhang as the cover prints, - # with room for a pattern's own lean -LEDGE_CLEAR = 13.0 # a cover's holes keep this far from its rim, clear of the lid's tabs (mm) -TISSUE_MARGIN = 8.0 # the tissue hole keeps at least this much of the cover's top round it (mm) -TISSUE_WORDS = {1: "a slot", 2: "an oval", 3: "a round hole"} -# what grows with a design sized for a head: its height and the face, in mm -HEAD_SCALED = ("height", "eye_width", "eye_height", "eye_apart", "eye_up", "nose_width", "nose_length", - "nose_depth", "nose_up", "mouth_width", "mouth_height", "mouth_up", "mouth_smile", "strap_up") - - -# ===================================================================== meshes -def grid_side(z, theta, R): - """The wall from a grid: rows of radii at shared angles. - - Returns (verts, faces, band of each face, first ring, last ring). Each - cell is split along the diagonal whose ends are closer in radius, so - ridges that spiral up the vase stay smooth. - """ - K, N = R.shape - c, s = np.cos(theta)[None, :], np.sin(theta)[None, :] - verts = np.stack([R * c, R * s, np.broadcast_to(z[:, None], R.shape)], -1).reshape(-1, 3) - idx = np.arange(K * N).reshape(K, N) - nxt = np.roll(idx, -1, axis=1) - v00, v01, v10, v11 = idx[:-1], nxt[:-1], idx[1:], nxt[1:] - Rn = np.roll(R, -1, axis=1) - along = (np.abs(R[:-1] - Rn[1:]) <= np.abs(Rn[:-1] - R[1:]))[..., None] - t1 = np.where(along, np.stack([v00, v01, v11], -1), np.stack([v00, v01, v10], -1)) - t2 = np.where(along, np.stack([v00, v11, v10], -1), np.stack([v01, v11, v10], -1)) - faces = np.concatenate([t1.reshape(-1, 3), t2.reshape(-1, 3)]) - band = np.tile(np.repeat(np.arange(K - 1), N), 2) - return verts, faces, band, idx[0].copy(), idx[-1].copy() - - -def loft_side(rows): - """The wall from rings with angles of their own: (z, angles ascending, radii) each. - - Neighbouring rings are joined by walking round both at once, always - stepping on along the one whose next point comes first. - """ - verts, starts, o = [], [], 0 - for z, a, r in rows: - verts.append(np.stack([r * np.cos(a), r * np.sin(a), np.full(len(a), z)], 1)) - starts.append(o) - o += len(a) - faces, band = [], [] - for k in range(len(rows) - 1): - A, B = rows[k][1], rows[k + 1][1] - na, nb, sa, sb = len(A), len(B), starts[k], starts[k + 1] - d = np.mod(B - A[0] + math.pi, TAU) - math.pi - j0 = int(np.argmin(np.abs(d))) - b0 = A[0] + d[j0] - au = A[0] + np.append(np.mod(A - A[0], TAU), TAU) - bu = b0 + np.append(np.mod(np.roll(B, -j0) - B[j0], TAU), TAU) - i = j = 0 - while i < na or j < nb: - if j >= nb or (i < na and au[i + 1] <= bu[j + 1]): - faces.append((sa + i % na, sa + (i + 1) % na, sb + (j0 + j) % nb)) - i += 1 - else: - faces.append((sa + i % na, sb + (j0 + j + 1) % nb, sb + (j0 + j) % nb)) - j += 1 - band.append(k) - verts = np.concatenate(verts) - first = np.arange(starts[0], starts[0] + len(rows[0][1])) - last = np.arange(starts[-1], starts[-1] + len(rows[-1][1])) - return verts, np.array(faces, dtype=np.int64), np.array(band), first, last - - -def grid_side_xy(z, P): - """The wall from rows of outline points (K, N, 2), point i of each row joined to point i of the next. - - Returns (verts, faces, band of each face, first ring, last ring). Each cell - is split along its shorter diagonal. - """ - K, N = P.shape[:2] - verts = np.concatenate([P, np.broadcast_to(z[:, None, None], (K, N, 1))], axis=2).reshape(-1, 3) - idx = np.arange(K * N).reshape(K, N) - nxt = np.roll(idx, -1, axis=1) - v00, v01, v10, v11 = idx[:-1], nxt[:-1], idx[1:], nxt[1:] - d1 = np.linalg.norm(verts[v00] - verts[v11], axis=-1) - d2 = np.linalg.norm(verts[v01] - verts[v10], axis=-1) - along = (d1 <= d2)[..., None] - t1 = np.where(along, np.stack([v00, v01, v11], -1), np.stack([v00, v01, v10], -1)) - t2 = np.where(along, np.stack([v00, v11, v10], -1), np.stack([v01, v11, v10], -1)) - faces = np.concatenate([t1.reshape(-1, 3), t2.reshape(-1, 3)]) - band = np.tile(np.repeat(np.arange(K - 1), N), 2) - return verts, faces, band, idx[0].copy(), idx[-1].copy() - - -def limit_slope(v, step): - """A 1D curve kept within a most change per step (step[i] between v[i] and v[i+1]). - - The largest curve under it and the smallest over it that keep within the - limit, averaged - as the columns of a radial vase are softened. - """ - lo, hi = v.copy(), v.copy() - for i in range(1, len(v)): - lo[i] = min(lo[i], lo[i - 1] + step[i - 1]) - hi[i] = max(hi[i], hi[i - 1] - step[i - 1]) - for i in range(len(v) - 2, -1, -1): - lo[i] = min(lo[i], lo[i + 1] + step[i]) - hi[i] = max(hi[i], hi[i + 1] - step[i]) - return 0.5 * (lo + hi) - - -def fall_gently(v, step: float): - """The largest curve under v that changes by at most step from one value to the next.""" - lo = v.copy() - for i in range(1, len(v)): - lo[i] = min(lo[i], lo[i - 1] + step) - for i in range(len(v) - 2, -1, -1): - lo[i] = min(lo[i], lo[i + 1] + step) - return lo - - -def fan(center: int, ring, up: bool): - """A flat cap: triangles from the centre to each side of a ring.""" - ring = np.asarray(ring) - nxt = np.roll(ring, -1) - c = np.full(len(ring), center) - return np.stack([c, ring, nxt] if up else [c, nxt, ring], 1) - - -def annulus(outer, inner): - """A flat, upward-facing ring between two rings of the same angles.""" - o, i = np.asarray(outer), np.asarray(inner) - o1, i1 = np.roll(o, -1), np.roll(i, -1) - return np.concatenate([np.stack([o, o1, i1], 1), np.stack([o, i1, i], 1)]) - - -def lighter(verts, faces): - """The mesh with its triangles merged wherever that moves the surface by no more than a few hundredths of a - millimetre - the slicer's Simplify model, done before saving, so a fine mesh makes a file the slicer opens - quickly: with pyfqmr in the desktop app, meshoptimizer in the web version. As it was if neither is there, or - if the result wouldn't be closed.""" - try: - import pyfqmr - except ImportError: - pyfqmr = None - for k in range(3): # each time a little more careful, if it didn't stay closed - if pyfqmr is not None: - s = pyfqmr.Simplify() - s.setMesh(np.asarray(verts, dtype=np.float64), np.asarray(faces, dtype=np.int32)) - s.simplify_mesh_lossless(epsilon=SIMPLIFY_ERROR / 4 ** k, verbose=False) - v, f, _ = s.getMesh() - else: - try: - import meshopt # the web version: given to its Python by the page - except ImportError: - return verts, faces - kept = meshopt.simplify(np.asarray(verts, dtype=np.float32).tobytes(), - np.asarray(faces, dtype=np.uint32).tobytes(), SIMPLIFY_MM / 2 ** k) - f = np.frombuffer(bytes(kept.to_py()), dtype=np.uint32).reshape(-1, 3).astype(np.int64) - used = np.unique(f) # only the corners still in use - v, f = np.asarray(verts)[used], np.searchsorted(used, f) - # a triangle and its twin facing the other way are a fin with nothing inside: neither is needed - f = np.asarray(f, dtype=np.int64) - _, which, count = np.unique(np.sort(f, axis=1), axis=0, return_inverse=True, return_counts=True) - f = f[count[which.ravel()] == 1] - if len(f) and edge_check(f)[0]: - return np.asarray(v, dtype=np.float64), f - return verts, faces - - -def face_normals(verts, faces): - tri = verts[faces] - n = np.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0]) - ln = np.linalg.norm(n, axis=1, keepdims=True) - ln[ln == 0] = 1.0 - return n / ln - - -def lean_angles(verts, faces): - """How far each face leans from upright (degrees), out or in alike.""" - n = face_normals(verts, faces) - return np.degrees(np.arcsin(np.clip(np.abs(n[:, 2]), 0.0, 1.0))) - - -def mesh_volume(verts, faces) -> float: - tri = verts[faces] - return float(np.einsum("ij,ij->i", tri[:, 0], np.cross(tri[:, 1], tri[:, 2])).sum() / 6.0) - - -def edge_check(faces): - """Whether a mesh is closed: every edge used once each way, by exactly two faces. - - Returns (closed, number of bad edges). - """ - f = np.asarray(faces, dtype=np.int64) - a = f.ravel() - b = np.roll(f, -1, axis=1).ravel() - n = int(f.max()) + 1 - fwd = np.sort(a * n + b) - rev = np.sort(b * n + a) - twice = int((np.diff(fwd) == 0).sum()) # an edge used twice the same way - unmatched = int((fwd != rev).sum()) # an edge with no face the other way - return twice == 0 and unmatched == 0, twice + unmatched - - -def inset(angles, radii, width): - """Radii of a ring moved in by ``width`` square to the wall (as a radial map).""" - a = np.asarray(angles) - r = np.asarray(radii) - da = np.mod(np.roll(a, -1, axis=-1) - np.roll(a, 1, axis=-1), TAU) - da[da == 0] = 1e-9 - rth = (np.roll(r, -1, axis=-1) - np.roll(r, 1, axis=-1)) / da - out = r - width * np.sqrt(r * r + rth * rth) / np.maximum(r, 1e-6) - return np.maximum(out, np.maximum(0.15 * r, 0.2)) - - -def soften(R, dth, dz, angle_deg, scale=None, rounds=4): - """Limit the radius so that no part of the wall leans more than angle_deg from upright. - - Column by column, the largest surface under R and the smallest one over - it that keep within the lean are found and averaged: peaks are cut back - and hollows filled in by the same amount. ``scale`` tightens the limit - point by point where the finished triangles showed it was needed. - """ - t = math.tan(math.radians(max(angle_deg, 1.0))) - R = R.copy() - for _ in range(rounds): - # how far the wall faces sideways there - smoothed round the vase, so that neighbouring - # columns are allowed much the same and a sharp tip passing across them doesn't streak - rth = (np.roll(R, -1, axis=1) - np.roll(R, 1, axis=1)) / (2.0 * dth) - k = np.sqrt(R * R + rth * rth) / np.maximum(R, 1e-6) - k = t * np.exp(blur_around(np.log(k), 1.5)) - if scale is not None: - k = k * scale - step = np.minimum(k[:-1], k[1:]) * dz # most each row may differ from the next - lo, hi = R.copy(), R.copy() - for j in range(1, len(R)): - np.minimum(lo[j], lo[j - 1] + step[j - 1], out=lo[j]) - np.maximum(hi[j], hi[j - 1] - step[j - 1], out=hi[j]) - for j in range(len(R) - 2, -1, -1): - np.minimum(lo[j], lo[j + 1] + step[j], out=lo[j]) - np.maximum(hi[j], hi[j + 1] - step[j], out=hi[j]) - new = 0.5 * (lo + hi) - moved = float(np.abs(new - R).max()) - R = new - if moved < 1e-4: - break - return R - - -def ease_twist(R, dth, omega, angle_deg, rounds=3): - """Soften the flanks of a fast-twisting shape round the vase, layer by layer. - - A flank that faces sideways and is carried sideways by the twist leans by - tan(lean) = r·ω·|r_θ| / sqrt(r² + r_θ²). Where r·ω is more than the limit - allows, only a gentler flank can keep within it, so |r_θ| is limited round - each layer (envelopes from above and below, averaged, as for the columns). - Every layer of a twisted shape is softened alike, so it stays one clean - twisted shape. A little is left in hand for the outline's own slope. - """ - t = math.tan(math.radians(max(angle_deg, 1.0))) * 0.9 - N = R.shape[1] - for _ in range(rounds): - rw = R * omega[:, None] - if not (rw > t).any(): - break - bound = np.where(rw > t, R * t / np.sqrt(np.maximum(rw * rw - t * t, 1e-12)), np.inf) - step = np.minimum(bound, np.roll(bound, -1, axis=1)) * dth # between column i and i + 1 - lo, hi = R.copy(), R.copy() - for _ in range(2): # twice round, for the wrap - for i in range(N): - np.minimum(lo[:, i], lo[:, i - 1] + step[:, i - 1], out=lo[:, i]) - np.maximum(hi[:, i], hi[:, i - 1] - step[:, i - 1], out=hi[:, i]) - for i in range(N - 1, -1, -1): - np.minimum(lo[:, i], lo[:, (i + 1) % N] + step[:, i], out=lo[:, i]) - np.maximum(hi[:, i], hi[:, (i + 1) % N] - step[:, i], out=hi[:, i]) - R = 0.5 * (lo + hi) - return R - - -def blur_around(a, sigma_cols): - """Smooth a grid round the vase (each row on its own), sigma in columns.""" - N = a.shape[1] - k = np.fft.rfftfreq(N, d=1.0 / N) - gain = np.exp(-0.5 * (k * sigma_cols * TAU / N) ** 2) - return np.fft.irfft(np.fft.rfft(a, axis=1) * gain[None, :], n=N, axis=1) - - -def layer_top(z: float, layer: float, first: float | None = None) -> tuple[int, float]: - """The first layer that starts at height z or above: (its number from 1, the height of its top). - - The first layer may be thicker than the rest, as slicers often make it. - """ - first = layer if first is None else first - if z <= first * 0.5: - return 1, first - k = max(math.ceil((z - first) / layer - 0.5), 0) # whole layers above the first one, below z - return k + 2, round(first + (k + 1) * layer, 4) - - -def duration(seconds: float) -> str: - m = int(round(seconds / 60.0)) - return f"{m // 60} h {m % 60:02d} min" if m >= 60 else f"{max(m, 1)} min" - - -# ===================================================================== design -class VaseDesign: - """A vase built from its parameters: the solid to export, the shell to show, and its sums.""" - - def __init__(self, p: VaseParams, draft: bool = False): - self.errors: list[str] = [] - self.warnings: list[str] = [] - self.notes: list[str] = [] - self.asked = p.copy() # the settings as given: p below is as built, a box's shape and all - self.box_room = 0.0 - if p.box: # a tissue box cover: its shape comes from the box it fits - self.box_room = box_room(p) - p = box_shape(p) - if p.vase_mode == REAL_VASE and needs_normal(p): - p = p.copy() - p.vase_mode = FAKE_VASE - self.notes.append("Holes, a face, a tissue box cover, an open bottom and a wall part way round need a " - "normal print, with walls (spiral vase mode prints one closed line a layer): it is " - "printed normally.") - if p.closed_top and p.around < 360.0: - p = p.copy() - p.closed_top = False - self.notes.append("A closed top needs the wall all the way round: the top is left open.") - self.p = p - self.draft = draft - self.pot_rows = None - self.bridge = 0.0 - self.holes_open = False - self.pattern_open = False - self.bridge_allowed = 0.0 # how wide a hole's top may be bridged (mm): Voronoi ovals - self.hole_share = 0.0 - self.head_fit = 0.0 - self.ceiling = 0.0 - self.floor_hole = None # a tissue box cover's hole through its top, on the bed - self.tissue = None # ... its length and width (mm) - self.fn = None - self.closed = None - self.free = DRAWN in (self.p.shape1, self.p.shape2) # a drawn outline: built point by point - self.outer_rows = None - try: - if self._check(): - if self.free: - self._free_surface() - if self.errors: - return - self._free_printable() - inner = np.stack([sections.inset(row, self.wall) for row in self.P]) - self._pot(self.P, inner) - self.outer_rows = [(zz, np.arctan2(r[:, 1], r[:, 0]), np.hypot(r[:, 0], r[:, 1])) - for zz, r in zip(self.z, self.P)] - else: - self._grid_size() - self._surface() - self._printable() - if self.rows is None: - c, s = np.cos(self.theta), np.sin(self.theta) - Ri = inset(self.theta, self.R, self.wall) - self._pot(np.stack([self.R * c, self.R * s], -1), np.stack([Ri * c, Ri * s], -1)) - else: - self._shell() # low-poly: the facets' own shell - self._measure() - self._colours() - self._advice() - except ExprError as exc: - self.errors.append(f"Your own function: {exc}") - - # ------------------------------------------------------------ checks - def _check(self) -> bool: - p = self.p - if p.height < 5.0: - self.errors.append("The vase must be at least 5 mm tall.") - if len(p.silhouette) < 2 or min(d for _, d in p.silhouette) <= 0: - self.errors.append("The outline needs at least two points, every one wider than nothing.") - for key, label in (("line_width", "Line width"), ("layer_height", "Layer height"), - ("filament_d", "Filament diameter"), ("speed", "Print speed"), - ("max_flow", "Max flow"), ("density", "Filament density")): - if getattr(p, key) <= 0: - self.errors.append(f"{label} must be more than zero.") - if p.facet_size <= 0: - self.errors.append("Mesh detail must be more than zero.") - try: - self.fn = compile_expression(p.expression) - except ExprError as exc: - self.errors.append(f"Your own function: {exc}") - return not self.errors - - def _grid_size(self): - p = self.p - self.H = H = float(p.height) - sil = np.asarray(p.silhouette, dtype=float) - tex = ((abs(p.rib_depth) if p.rib_count > 0 else 0.0) + (abs(p.ring_depth) if p.ring_count > 0 else 0.0) - + (abs(p.bump_depth) if p.bump_around > 0 else 0.0) + 1.2 * max(p.organic_depth, 0.0)) - r_est = 0.5 * sil[:, 1].max() + tex - step = max(p.facet_size, 0.15) * (2.6 if self.draft else 1.0) - per = 10 if self.draft else 22 # points round each lobe or rib - lobes = [lobes_needed(p.shape1, p.count1)] - if p.shape2 != SAME: - lobes.append(lobes_needed(p.shape2, p.count2)) - if p.rib_count > 0: - lobes.append(p.rib_count * (2 if p.rib_cross else 1)) - if p.bump_around > 0: - lobes.append(p.bump_around) - N = max(math.ceil(TAU * r_est / step), per * max(lobes), 96) - M = max(math.ceil(H / step), (8 if self.draft else 16) * max(p.ring_count, 1), 24) - if p.bump_around > 0: - M = max(M, (8 if self.draft else 16) * p.bump_up) - if p.organic_depth > 0: # a few points across every lump - fine = max(p.organic_scale, 2.0) / (5 if self.draft else 10) - N = max(N, math.ceil(TAU * r_est / fine)) - M = max(M, math.ceil(H / fine)) - if self.fn is not None: - N = max(N, 180 if self.draft else 480) - limit = 140_000 if self.draft else 1_200_000 - if N * M > limit: - f = math.sqrt(limit / (N * M)) - N, M = max(int(N * f), 64), max(int(M * f), 16) - spacing = step / f - if not self.draft and spacing > 0.4: # finer than that, nobody would see it - self.notes.append(f"The surface detail was capped at about {spacing:.1f} mm to keep the mesh a " - "manageable size.") - self.N = (N + 3) // 4 * 4 - self.M = M - - # ----------------------------------------------------------- surface - def twist_at(self, h): - """How far the shape has turned at height h (radians).""" - p = self.p - if p.twist_style == OWN: - deg = progress(OWN, h, p.twist_curve) - else: - deg = p.twist * progress(p.twist_style, h, swings=p.swings) - return np.radians(deg) - - def _texture(self, frame, z, mean_r: float): - """The surface patterns in mm, out (+) or in, at angles ``frame`` (rows, columns) and heights z.""" - p = self.p - h = z / self.H - tex = np.zeros(np.broadcast(frame, z[:, None]).shape) - if p.rib_count > 0 and p.rib_depth != 0: - k = 1.0 + 7.0 * p.rib_sharp ** 2 - lean = math.radians(p.rib_lean) * h[:, None] - rib = (0.5 * (1.0 + np.cos(p.rib_count * (frame - lean)))) ** k - if p.rib_cross: - rib = np.maximum(rib, (0.5 * (1.0 + np.cos(p.rib_count * (frame + lean)))) ** k) - tex += p.rib_depth * rib - if p.ring_count > 0 and p.ring_depth != 0: - k = 1.0 + 7.0 * p.ring_sharp ** 2 - tex += p.ring_depth * ((0.5 * (1.0 - np.cos(TAU * p.ring_count * h))) ** k)[:, None] - if p.bump_around > 0 and p.bump_depth != 0: - tex += p.bump_depth * (0.5 * (1.0 + np.cos(p.bump_around * frame) - * np.cos(TAU * max(p.bump_up, 1) * h)[:, None])) ** 1.5 - if p.organic_depth > 0: - tex += p.organic_depth * organic(frame, z[:, None], p.organic_scale, mean_r, p.organic_seed) - return tex - - def _envelope(self, z): - """How strong the surface patterns are at each height: the strength curve, faded at foot and rim.""" - p = self.p - env = strength(p.strength, z / self.H, p.strength_curve) - if p.fade_foot > 0: - env = env * smoothstep(z / p.fade_foot) - if p.fade_rim > 0: - env = env * smoothstep((self.H - z) / p.fade_rim) - return env - - def _surface(self): - p, H, N, M = self.p, self.H, self.N, self.M - self.z = z = np.linspace(0.0, H, M + 1) - self.theta = theta = np.arange(N) * (TAU / N) - self.dz, self.dth = H / M, TAU / N - h = z / H - sil = np.asarray(p.silhouette, dtype=float) - R0, q = self._dome(z, 0.5 * pchip(sil[:, 0], sil[:, 1], h)) - self.tau = tau = self.twist_at(h) - TH, TA = theta[None, :], tau[:, None] - - tab1 = blur(shape_table(p.shape1, p.count1, p.depth1, p.sharp1), self.dth) - if p.shape2 != SAME: - tab2 = blur(shape_table(p.shape2, p.count2, p.depth2, p.sharp2), self.dth) - mix = change(p.morph, h, p.morph_curve, p.morph_swings)[:, None] - mix = np.clip(mix, 0.0, 1.0) - a1, a2 = math.radians(p.turn1), math.radians(p.turn2) - - def section(angle): - s = sample(tab1, angle - a1) - if p.shape2 != SAME: - s = (1.0 - mix) * s + mix * sample(tab2, angle - a2) - return s - - s = section(TH - TA) - if p.lattice: - # round the crossing ridges over a couple of mesh columns, so they don't alias - steep = float(np.abs(np.diff(tab1)).max()) * TABLE / TAU - s = smooth_max(s, section(TH + TA), 3.0 * self.dth * steep) - r = R0[:, None] * s - - frame = TH - TA if p.texture_twist else np.broadcast_to(TH, r.shape) - if p.even_spacing: - frame = self._round_the_wall(r, tau if p.texture_twist else np.zeros_like(tau)) - r = r + (q * self._envelope(z))[:, None] * self._texture(frame, z, float(R0.mean())) - if p.nose == RAISED_NOSE: - # along the surface from the front (-y: the column a quarter turn before the start) - front = r[:, 3 * N // 4] - s = (np.mod(TH - 1.5 * math.pi + math.pi, TAU) - math.pi) * front[:, None] - r = r + hollow.nose(s, z, p, 0.9 * math.tan(math.radians(p.max_overhang))) - - if self.fn is not None: - env_vars = dict(theta=np.broadcast_to(TH, r.shape), phi=frame, z=z[:, None], h=h[:, None], r=r, - H=np.float64(H)) - add = self.fn(**env_vars) - # does the function meet itself round the back? Try it a whole turn on. - wrap = self.fn(theta=np.full((M + 1, 1), TAU), phi=frame[:, :1] + TAU, z=z[:, None], - h=h[:, None], r=r[:, :1], H=np.float64(H)) - if float(np.abs(wrap[:, 0] - add[:, 0]).max()) > 0.05: - self.warnings.append( - "Your own function doesn't meet itself round the vase, so there is a step " - "up the back. Use whole numbers with the angle, e.g. sin(5*theta), not sin(2.5*theta).") - r = r + add - if p.closed_top: # nothing sticks out past the point, patterns and all - r = np.minimum(r, self._cone(z)[:, None]) - - floor_r = max(1.5, 2.0 * self.wall) - near = r < floor_r - if p.closed_top: # a closed top's point comes in to it, as it should - near &= (self._cone(z) > 2.0 * floor_r)[:, None] - if near.any(): - self.notes.append(f"Where the shape came near the middle it is kept {floor_r:.1f} mm out from it.") - self.R_design = np.maximum(r, floor_r) - - def _round_the_wall(self, r, start): - """How far round the wall each grid point is, as an angle - 2 pi all the way round - measured along the - wall from the direction ``start`` (each row's), rather than as the angle from the middle: patterns laid - out by it are as close together on a box's long sides as round its ends.""" - N = self.N - c, s = np.cos(self.theta), np.sin(self.theta) - x, y = r * c, r * s - step = np.hypot(np.roll(x, -1, axis=1) - x, np.roll(y, -1, axis=1) - y) - along = np.concatenate([np.zeros((len(r), 1)), np.cumsum(step, axis=1)], axis=1) # (rows, N + 1) - share = along / along[:, -1:] * TAU - f = np.mod(start, TAU) / self.dth # where each row starts, between two columns - i0 = np.floor(f).astype(np.intp) % N - t = f - np.floor(f) - rows = np.arange(len(r)) - first = share[rows, i0] * (1.0 - t) + share[rows, i0 + 1] * t - return share[:, :N] - first[:, None] - - def _cone(self, z): - """A closed top's point: the widest the vase may be at each height, leaning in at a little under the - overhang limit up to a point the width of the narrowest the wall may come to the axis.""" - tip = max(1.5, 2.0 * self.wall) - return tip + (self.H - z) * 0.92 * math.tan(math.radians(self.p.max_overhang)) - - def _dome(self, z, R0): - """The outline's radius at each height, closed over in a point if the top is closed; and how far each - height was narrowed (1: not at all), for the patterns to shrink with it.""" - if not self.p.closed_top: - return R0, np.ones_like(R0) - # the lesser of the outline and the point, rounded where they meet (never leaning more than either) - cone = self._cone(z) - k = 0.12 * float(R0.max()) - capped = np.minimum(R0, cone) - k * np.log1p(np.exp(-np.abs(R0 - cone) / k)) - q = capped / np.maximum(R0, 1e-9) - narrowed = np.nonzero(q < 0.999)[0] - self.dome_from = float(z[narrowed[0]]) if len(narrowed) else self.H - return capped, q - - # ----------------------------------------------------- printable solid - def _rows(self, R): - """The low-poly rings: (z, angles, radii) for each row of facets.""" - p, H = self.p, self.H - rng = np.random.default_rng(int(p.facet_seed)) - Nf, Mf = max(int(p.facets_around), 3), max(int(p.facets_up), 1) - rows = [] - for j in range(Mf + 1): - zj = H * j / Mf - a = TAU * np.arange(Nf) / Nf + (j % 2) * math.pi / Nf + float(self.twist_at(np.array([zj / H]))[0]) - if p.facet_rough > 0: - a = a + rng.uniform(-0.35, 0.35, Nf) * p.facet_rough * TAU / Nf - r = self._sample(R, zj, a) - if p.facet_rough > 0: - r = r * (1.0 + rng.uniform(-0.05, 0.05, Nf) * p.facet_rough) - rows.append((zj, a, r)) - return rows - - def _sample(self, R, zq, aq): - """The radius between the grid points (bilinear, round the back too).""" - fj = min(max(zq / self.dz, 0.0), self.M) - j0 = min(int(fj), self.M - 1) - tj = fj - j0 - fi = np.mod(aq, TAU) / self.dth - i0 = np.floor(fi).astype(np.int64) - ti = fi - i0 - i0 %= self.N - i1 = (i0 + 1) % self.N - lo = R[j0, i0] * (1 - ti) + R[j0, i1] * ti - hi = R[j0 + 1, i0] * (1 - ti) + R[j0 + 1, i1] * ti - return lo * (1 - tj) + hi * tj - - def _solid(self, R): - """The solid to export from a surface: (verts, faces, band of each wall face, wall face count, rows).""" - rows = self._rows(R) if self.p.facets else None - if rows is None: - verts, faces, band, first, last = grid_side(self.z, self.theta, R) - else: - verts, faces, band, first, last = loft_side(rows) - nv = len(verts) - verts = np.concatenate([verts, [[0.0, 0.0, 0.0], [0.0, 0.0, self.H]]]) - side = len(faces) - faces = np.concatenate([faces, fan(nv, first, up=False), fan(nv + 1, last, up=True)]) - return verts, faces, band, side, rows - - def _soften(self, target, scale): - """The design softened, with the correction smoothed round the vase and softened again. - - Each column is softened on its own, so on its own the correction can - differ a little from column to column and leave fine streaks; smoothing - it sideways and softening once more keeps the surface clean. - """ - base = self.R_base - R = soften(base, self.dth, self.dz, target, scale) - for _ in range(2): - fix = blur_around(R - base, 2.0) - R = soften(base + fix, self.dth, self.dz, target, scale) - return R - - def _printable(self): - p = self.p - target = p.max_overhang - scale = np.ones_like(self.R_design) # where the limit has to be tighter than it looks - R = self.R_design - self.R_base = self.R_design - if p.soften: # a fast twist first: gentler flanks, layer by layer - omega = np.abs(np.gradient(self.tau, self.dz)) - self.R_base = ease_twist(self.R_design, self.dth, omega, p.max_overhang) - for attempt in range(14 if p.soften else 1): - R = self._soften(target, scale) if p.soften else self.R_design - verts, faces, band, side, rows = self._solid(R) - lean = lean_angles(verts, faces[:side]) - worst = float(lean.max()) - if worst <= p.max_overhang + TOLERANCE or not p.soften: - break - if rows is None: - # tighten only round the faces that still lean too far - # by as much as each face is over, spread a little and feathered over a few columns - # and rows, so the tightening itself leaves no crease - over = lean > p.max_overhang + 0.5 * TOLERANCE - cut = 1.0 - math.tan(math.radians(p.max_overhang - 0.25)) / np.tan(np.radians(lean[over])) - cut = np.clip(cut + 0.03, 0.03, 0.6) - corner = faces[:side][over].ravel() - keep = corner < (self.M + 1) * self.N - j, i = np.divmod(corner[keep], self.N) - tight = np.zeros_like(scale) - np.maximum.at(tight, (j, i), np.repeat(cut, 3)[keep]) - for axis in (0, 1): - tight = np.maximum(tight, np.maximum(np.roll(tight, 1, axis), np.roll(tight, -1, axis))) - tight = blur_around(tight, 1.5) - scale *= 1.0 - np.clip(tight, 0.0, 0.6) - if attempt >= 6: # still not there: ask a little less everywhere too - target -= max(1.0, worst - p.max_overhang) - else: - # low-poly: the facets cut across the softened surface, so ask for less everywhere - target -= max(0.75, worst - p.max_overhang) - if target < 2.0: - break - self.R, self.rows = R, rows - self.verts, self.faces, self.side = verts, faces, side - self.band, self.lean = band, lean - self.worst = worst - self.softened = float(np.abs(R - self.R_design).max()) if p.soften else 0.0 - self.row_moved = np.abs(R - self.R_design).max(axis=1) if rows is None else None - if not self.draft: - self.closed = edge_check(faces)[0] - nb = int(band.max()) + 1 - self.band_lean = np.zeros(nb) - np.maximum.at(self.band_lean, band, lean) - self.triangles = len(faces) - lo, hi = verts.min(0), verts.max(0) - self.size = hi - lo - self.top = self.H - - # --------------------------------------------------------- shown shell - @property - def wall(self) -> float: - """How thick the printed wall is: one spiral line, or a fake vase's walls side by side.""" - p = self.p - return p.line_width * (max(int(p.walls), 1) if p.vase_mode == FAKE_VASE else 1) - - @property - def normal(self) -> bool: - """Printed normally, with walls - rather than seamless, in spiral vase mode.""" - return self.p.vase_mode == FAKE_VASE - - def _pot(self, O, I): - """What the printer makes: the hollow pot - the outer face O and inner face I (K, N, 2) at the grid's - heights, the floor or an open bottom, the rim - with any holes cut right through. For a normal print it - is what is exported; seamless, it is what the single line makes, for the 3D view.""" - p = self.p - zb = 0.0 if p.open_bottom else min(max(p.bottom, p.layer_height), 0.5 * self.H) - self.zb = zb - f = self._holes(O, zb) - if p.open_bottom: - zs, (Os, Is, fs), kb = self.z, (O, I, f), None - else: - zs, (Os, Is, fs), at = hollow.insert_rows(self.z, [O, I, f], [zb]) - kb = at[zb] - if p.box: # the ledges a lid clicks in behind - heights = ledge_heights(p) - zs, (Os, Is, fs), _ = hollow.insert_rows(zs, [Os, Is, fs], [self.H - h for h in heights]) - Is = ledges(zs, Is, self.H, heights) - b = None - if p.hole_swell > 0 and (fs > 0).any(): - # rounded edges: the wall swells out and in round every hole, curving from outside to in - far = max(6.0, 0.5 * p.hole_size) if p.holes != NO_HOLES else 6.0 - b = hollow.swell(fs, zs, p.hole_swell, far, 0.6 * math.tan(math.radians(p.max_overhang))) - kr = self._roof(zs, Os, kb) if p.closed_top else None - if p.box and p.tissue_hole != NO_TISSUE and kb is not None: - self.floor_hole = self._tissue_hole(Is[kb]) - if not self.draft: - # leave out the rows and columns that lie in line with their neighbours: far fewer triangles where the - # wall is flat or straight, every one kept where a pattern, a hole or a curve needs it - b = np.zeros_like(fs) if b is None else b - zs, Os, Is, fs, b, rk = hollow.simplify(zs, Os, Is, fs, b, [k for k in (kb, kr) if k is not None]) - kb = None if kb is None else int(np.searchsorted(rk, kb)) - kr = None if kr is None else int(np.searchsorted(rk, kr)) - verts, faces, kinds = hollow.pot(zs, Os, Is, fs, floor_row=kb, roof_row=kr, swell=b, - floor_hole=self.floor_hole) - kinds, self.bridge, side_worst = hollow.bridges(verts, faces, kinds, p.max_overhang, - longest=max(8.0, self.bridge_allowed + 1.0)) - self.pot_rows = (zs, Os, Is, fs, kb, kr, np.zeros_like(fs) if b is None else b) - lo, hi = (0 if kb is None else kb), (len(zs) - 1 if kr is None else kr) - if kr is not None: - self.ceiling = hollow.bridge_span(Is[kr]) - fs = np.where(np.arange(len(zs))[:, None] >= kr, np.minimum(fs, -1e-9), fs) - # the head it fits: round the inside, where the head goes in - the narrowest place below the - # widest; a mask, the widest place of the head it is shaped round - per = hollow.row_lengths(Is[lo:hi + 1])[2] - widest = int(np.argmax(per)) - fit = float(per.max() if p.around < 360.0 else per[:widest + 1].min()) - self.head_fit = max(fit - HEAD_ROOM, 0.0) - self.holes_open = bool((fs > 0).any()) - self.hole_share = float((fs > 0).mean()) if self.holes_open else 0.0 - self._set_shell(verts, faces, kinds) - self.worst = max(self.worst, side_worst) - if self.normal: # what is exported, and what the panel counts - self.triangles = len(faces) - self.size = verts.max(0) - verts.min(0) - if not self.draft: - self.closed = bool(self.closed) and edge_check(faces)[0] - self.inner_rows = [(zz, np.arctan2(r[:, 1], r[:, 0]), np.hypot(r[:, 0], r[:, 1])) - for zz, r in zip(zs[lo:hi + 1], Is[lo:hi + 1])] - - def _tissue_hole(self, inside): - """The hole in a tissue box cover's top for the tissues to come out of, as a loop round the middle, - anticlockwise - kept TISSUE_MARGIN clear of the walls inside. None if there is no room for it.""" - p = self.p - along_y = p.turn1 % 180.0 > 45.0 # the box's length runs along y - half = np.abs(np.asarray(inside, dtype=float)).max(axis=0) - TISSUE_MARGIN - long_room, short_room = (half[1], half[0]) if along_y else (half[0], half[1]) - w = min(0.5 * p.tissue_width, short_room) - length = w if p.tissue_hole == TISSUE_ROUND else max(min(0.5 * p.tissue_length, long_room), w) - if p.tissue_hole == TISSUE_ROUND: - w = length = min(w, long_room) - if w < 3.0: - self.notes.append("There is no room in the top for the tissue hole.") - return None - if w < 0.5 * p.tissue_width - 0.05 or (p.tissue_hole != TISSUE_ROUND and length < 0.5 * p.tissue_length - 0.05): - self.notes.append(f"The tissue hole was made smaller, to keep {TISSUE_MARGIN:g} mm of the top round it.") - step = max(p.facet_size, 0.3) * (2.6 if self.draft else 1.0) - if p.tissue_hole == TISSUE_OVAL: - n = max(int(2.0 * math.pi * max(length, w) / step), 48) - t = np.arange(n) * (TAU / n) - xs, ys = length * np.cos(t), w * np.sin(t) - else: # a slot: round ends; a circle - n = max(int((4.0 * length + 2.3 * w) / step), 48) - xs, ys = rounded_rectangle(length, w, w, per=n) - self.tissue = (2.0 * length, 2.0 * w) - return np.stack([-ys, xs] if along_y else [xs, ys], axis=1) - - def _roof(self, zs, Os, kb): - """The row a closed top's ceiling is on: as high as leaves the point above it as thick as the floor, - where the wall still has room inside it.""" - p = self.p - rmin = np.hypot(Os[..., 0], Os[..., 1]).min(axis=1) - top = self.H - max(p.bottom, 2.0 * p.layer_height) - ok = np.nonzero((rmin >= self.wall + 0.6) & (zs <= top + 1e-9) & (np.arange(len(zs)) > (kb or 0) + 1))[0] - if not len(ok): - self.notes.append("The vase is too small to close over at the top: it is left open.") - return None - return int(ok[-1]) - - def _set_shell(self, verts, faces, kinds=None): - """The shell to show, and how far each face leans: a hole's side only counts where it leans out over - the hole (its sill faces up, and prints as it is); the bridge at a hole's very top is as steep as allowed.""" - lean = lean_angles(verts, faces) - if kinds is not None: - over = np.degrees(np.arcsin(np.clip(-face_normals(verts, faces)[:, 2], 0.0, 1.0))) - lean = np.where(kinds == hollow.SIDE, over, lean) - lean = np.where(kinds == hollow.TIP, np.minimum(over, self.p.max_overhang), lean) - lean = np.where(kinds == hollow.CAP, -1.0, lean) - self.shell_verts, self.shell_faces, self.shell_kind, self.shell_lean = verts, faces, kinds, lean - - def _holes(self, O, zb): - """The hole pattern on the grid: how far inside a hole each point is (mm, below nought in the wall), - with pointed tops that print without support.""" - p = self.p - K, N = O.shape[:2] - f = np.full((K, N), -1.0) - if not self.normal: - return f - seg, along, L = hollow.row_lengths(O) - slope = 0.85 * math.tan(math.radians(p.max_overhang)) - face = None - if face_holes(p) or p.around < 360.0: - face = hollow.openings(hollow.from_front(O, seg, along, L), self.z, L, p) - if (face > 0).any(): - face = hollow.pointed_tops(face, self.z, O, slope) - f = face - bridge = None - if p.holes != NO_HOLES: - # a strut's width of wall round the face and along the edges - room = None if face is None else -face - max(p.hole_strut, 2.0) - pattern, spans = self._pattern_holes(O, zb, seg, along, L, room) - self.pattern_open = bool((pattern > 0).any()) - f = pattern if face is None else np.maximum(face, pattern) - if spans is not None: # Voronoi ovals: their round tops close over a short bridge - bridge = np.where(pattern > 0, spans, 0.0) - self.bridge_allowed = float(bridge.max()) - if not (f > 0).any(): - return f - return hollow.pointed_tops(f, self.z, O, slope, bridge) - - def _pattern_holes(self, O, zb, seg, along, L, room=None): - """The pattern of holes, Voronoi, round, diamonds or slots, as a hole field - only where room is above - nought, if given.""" - p = self.p - K, N = O.shape[:2] - # the holes turn with the twist - but no faster than a wall may lean, so their sides stay printable - turn = np.zeros(K) - if p.texture_twist: - r = np.maximum(L / TAU, 1.0) - turn = limit_slope(self.tau.astype(float), 0.6 * math.tan(math.radians(p.max_overhang)) - * np.diff(self.z) / np.minimum(r[1:], r[:-1])) - # measured round each row from the point at that angle - ang = np.arctan2(O[..., 1], O[..., 0]) - c0 = np.argmin(np.abs(np.mod(ang - turn[:, None] + math.pi, TAU) - math.pi), axis=1) - start = along[np.arange(K), c0] - t = np.mod((along - start[:, None]) / L[:, None], 1.0) - z_lo = zb + max(p.hole_foot, 0.0) - z_hi = (self.dome_from if p.closed_top else self.H) - max(p.hole_rim, LEDGE_CLEAR if p.box else 0.0) - f, spans = hollow.hole_field(p.holes, t, self.z, L, p.hole_size, p.hole_strut, p.hole_jitter, p.hole_seed, - z_lo, z_hi, room, 0.85 * math.tan(math.radians(p.max_overhang)), - min(VORONOI_BRIDGE * p.hole_size, VORONOI_LONGEST)) - if not (f > 0).any(): - self.notes.append("There is no room for the holes between the plain foot and rim: make the vase " - "taller, the holes smaller, or the plain foot and rim less.") - return f, spans - - def print_mesh(self): - """The vase as one closed mesh for the slicer: the hollow pot for a normal print, the solid for - spiral vase mode.""" - if self.normal: - return self.shell_verts, self.shell_faces - return self.verts, self.faces - - def export_mesh(self): - """The mesh to save for the slicer: print_mesh, simplified (see ``lighter``).""" - return lighter(*self.print_mesh()) - - def export_parts(self): - """The parts to save for the slicer: print_parts, each simplified (see ``lighter``).""" - return [(fil, *lighter(v, f)) for fil, v, f in self.print_parts()] - - def print_parts(self): - """The vase for the slicer as [(filament, verts, faces)]: printed normally, the hollow pot, a part for - each band of colour; seamless, the solid.""" - if not self.normal: - return self.band_parts() - if self.pot_rows is None: - return self._loft_parts() - zs, Os, Is, fs, kb, kr, swollen = self.pot_rows - floor = kb is not None - # bands start on top of the floor, and the solid point of a closed top is one with the band below it - lowest = (zs[kb] if floor else 0.0) + 0.5 * self.p.layer_height - highest = self.H if kr is None else zs[kr] - 0.5 * self.p.layer_height - tops = sorted({max(z, lowest) for z, _ in self.bands[1:] if z < min(self.H - 1e-6, highest)}) - z2, (O2, I2, f2, s2), at = hollow.insert_rows(zs, [Os, Is, fs, swollen], tops) - kb2 = int(np.argmin(np.abs(z2 - zs[kb]))) if floor else None - kr2 = None if kr is None else int(np.argmin(np.abs(z2 - zs[kr]))) - bounds = [0] + [at[t] for t in tops] + [len(z2) - 1] - fils = [fil for _, fil in self.bands][:len(bounds) - 1] - parts = [] - for fil, a, b in zip(fils, bounds[:-1], bounds[1:]): - rows = slice(a, b + 1) - verts, faces, _ = hollow.pot(z2[rows], O2[rows], I2[rows], f2[rows], - floor_row=kb2 if (floor and a == 0) else None, - roof_row=kr2 - a if (kr2 is not None and b == len(z2) - 1) else None, - swell=s2[rows], floor_hole=self.floor_hole if a == 0 else None) - parts.append((fil, verts, faces)) - return parts - - def _loft_parts(self): - """Low-poly, printed normally: the facets' hollow shell, a part for each band of colour.""" - w, zb = self.wall, self.zb - tops = [zs for zs, _ in self.bands[1:]] + [self.H] - parts = [] - for (lo, fil), hi in zip(self.bands, tops): - lo = max(lo, zb + 0.5 * self.p.layer_height) if lo > 0 else 0.0 - rings = [self._ring_at(lo)] + [r for r in self.rows if lo + 1e-6 < r[0] < hi - 1e-6] + [self._ring_at(hi)] - if lo == 0.0: - inner = [self._ring_at(zb)] + [r for r in rings if r[0] > zb + 1e-6] - else: - inner = rings - inner = [(zz, a, inset(a, r, w)) for zz, a, r in inner] - o_v, o_f, _, o_first, o_last = loft_side(rings) - i_v, i_f, _, i_first, i_last = loft_side(inner) - no, ni = len(o_v), len(i_v) - verts = np.concatenate([o_v, i_v, [[0.0, 0.0, 0.0], [0.0, 0.0, zb]]]) - faces = [o_f, i_f[:, ::-1] + no, annulus(o_last, i_last + no)] - if lo == 0.0: - faces += [fan(no + ni, o_first, up=False), fan(no + ni + 1, i_first + no, up=True)] - else: - faces.append(annulus(o_first, i_first + no)[:, ::-1]) - parts.append((fil, verts, np.concatenate(faces))) - return parts - - def _shell(self): - """What the printer really makes: the wall over a solid bottom, for the 3D view.""" - p = self.p - if needs_normal(p) or p.closed_top: - self.notes.append("Holes, a face, open ends and a closed top work on smooth shapes, not low-poly " - "facets.") - w = self.wall - zb = min(max(p.bottom, p.layer_height), 0.5 * self.H) - self.zb = zb - if self.rows is None: - z, R = self.z, self.R - keep = z > zb + 0.3 * self.dz - fj = zb / self.dz - j0 = min(int(fj), self.M - 1) - first = R[j0] + (R[j0 + 1] - R[j0]) * (fj - j0) - zi = np.concatenate([[zb], z[keep]]) - Ri = inset(self.theta, np.vstack([first, R[keep]]), w) - o_verts, o_faces, _, o_first, o_last = grid_side(z, self.theta, R) - i_verts, i_faces, _, i_first, i_last = grid_side(zi, self.theta, Ri) - self.inner_rows = [(zz, self.theta, rr) for zz, rr in zip(zi, Ri)] - else: - rows = self.rows - inner = [(zb, rows[0][1], inset(rows[0][1], rows[0][2], w))] - inner += [(zz, a, inset(a, r, w)) for zz, a, r in rows[1:] if zz > zb + 0.3] - o_verts, o_faces, _, o_first, o_last = loft_side(rows) - i_verts, i_faces, _, i_first, i_last = loft_side(inner) - self.inner_rows = inner - no, ni = len(o_verts), len(i_verts) - verts = np.concatenate([o_verts, i_verts, [[0.0, 0.0, 0.0], [0.0, 0.0, zb]]]) - cb, cf = no + ni, no + ni + 1 - walls = np.concatenate([o_faces, i_faces[:, ::-1] + no]) - caps = np.concatenate([fan(cb, o_first, up=False), fan(cf, i_first + no, up=True), - annulus(o_last, i_last + no)]) - self.shell_verts = verts - self.shell_faces = np.concatenate([walls, caps]) - self.shell_kind = None - self.shell_lean = np.concatenate([lean_angles(verts, walls), np.full(len(caps), -1.0)]) - if self.normal: # what is exported, and what the panel counts - self.triangles = len(self.shell_faces) - - # ======================================================= drawn outlines - def _outline(self, k: int): - """Cross-section k (1 at the bottom, 2 at the top) as a loop of points, widest at radius 1.""" - p = self.p - kind = getattr(p, f"shape{k}") - if kind == DRAWN: - pts = getattr(p, f"drawn{k}") - loop = sections.spline(pts, getattr(p, f"repeat{k}")) - hits = sections.piece_crossings(loop, 48 * len(pts)) - if hits: - x, y = hits[0] - where = "bottom" if k == 1 else "top" - self.errors.append(f"The drawn {where} outline crosses itself (near {x:.2f}, {y:.2f} in the " - "drawing): move its points apart so it makes one simple loop.") - else: - loop = sections.from_table(shape_table(kind, getattr(p, f"count{k}"), getattr(p, f"depth{k}"), - getattr(p, f"sharp{k}"))) - loop = sections.normalised(loop) - a = math.radians(getattr(p, f"turn{k}")) - c, s = math.cos(a), math.sin(a) - return loop @ np.array([[c, s], [-s, c]]) - - def _free_surface(self): - """The designed surface of a vase with a drawn outline: rows of points (M + 1, N, 2).""" - p = self.p - self.H = H = float(p.height) - loops = [self._outline(1)] + ([self._outline(2)] if p.shape2 != SAME else []) - if self.errors: - return - sil = np.asarray(p.silhouette, dtype=float) - tex = ((abs(p.rib_depth) if p.rib_count > 0 else 0.0) + (abs(p.ring_depth) if p.ring_count > 0 else 0.0) - + (abs(p.bump_depth) if p.bump_around > 0 else 0.0) + 1.2 * max(p.organic_depth, 0.0)) - self.tex_depth = tex - perimeter = max(float(np.linalg.norm(np.roll(L, -1, axis=0) - L, axis=1).sum()) for L in loops) - step = max(p.facet_size, 0.15) * (2.6 if self.draft else 1.0) - per = 10 if self.draft else 22 - reps = max([getattr(p, f"repeat{k}") for k in (1, 2) if getattr(p, f"shape{k}") == DRAWN] + [1]) - N = max(math.ceil(perimeter * (0.5 * sil[:, 1].max() + tex) / step), - per * max(p.rib_count * (2 if p.rib_cross else 1), p.bump_around, reps), 160) - M = max(math.ceil(H / step), (8 if self.draft else 16) * max(p.ring_count, 1), 24) - if p.bump_around > 0: - M = max(M, (8 if self.draft else 16) * p.bump_up) - if p.organic_depth > 0: - fine = max(p.organic_scale, 2.0) / (5 if self.draft else 10) - N = max(N, math.ceil(perimeter * 0.5 * sil[:, 1].max() / fine)) - M = max(M, math.ceil(H / fine)) - if self.fn is not None: - N = max(N, 180 if self.draft else 480) - limit = 140_000 if self.draft else 1_200_000 - if N * M > limit: - f = math.sqrt(limit / (N * M)) - N, M = max(int(N * f), 64, 8 * reps), max(int(M * f), 16) # each repeat keeps a few points - self.N, self.M = (N + 3) // 4 * 4, M - self.z = z = np.linspace(0.0, H, M + 1) - self.dz = H / M - h = z / H - self.C1 = sections.resample(loops[0], self.N) - self.C2 = sections.resample(loops[1], self.N) if len(loops) > 1 else None - self.R0_d, q = self._dome(z, 0.5 * pchip(sil[:, 0], sil[:, 1], h)) - self.tau = self.tau_d = self.twist_at(h) - self.mix_d = (np.clip(change(p.morph, h, p.morph_curve, p.morph_swings), 0.0, 1.0) if self.C2 is not None - else np.zeros_like(h)) - self.env_d = self._envelope(z) * q # the patterns shrink with a closed top's point - if p.lattice and (p.twist_style == OWN or abs(p.twist) > 1e-9): - self.notes.append("Both ways (lattice) works on the built-in shapes only: a drawn outline twists one way.") - if p.facets: - self.notes.append("Low-poly facets work on the built-in shapes only: a drawn outline stays smooth.") - self.P_design = self._free_points(self.R0_d, self.tau_d, self.mix_d, self.env_d) - # a blend of two outlines, or deep patterns in a tight corner, can make a layer cross itself - for j in np.linspace(0, M, 7).astype(int): - if sections.crossings(self.P_design[j], limit=max(700, 12 * reps)): - self.warnings.append(f"The outline crosses itself at about {z[j]:.0f} mm up (where the shapes " - "blend, or patterns meet in a tight corner): the slicer may fill it wrongly " - "there. Ease the change of shape or the patterns.") - break - - def _free_points(self, R0, tau, mix, env): - """The surface's points: the outline blended, sized, turned, then patterned square to the wall.""" - p = self.p - base = (self.C1[None] if self.C2 is None - else (1.0 - mix)[:, None, None] * self.C1 + mix[:, None, None] * self.C2) - P = base * R0[:, None, None] - c, s = np.cos(tau)[:, None], np.sin(tau)[:, None] - x, y = P[..., 0], P[..., 1] - P = np.stack([x * c - y * s, x * s + y * c], -1) - # patterns run round the outline (turning with it), or round the axis if they shouldn't turn - along = np.arange(self.N) * (TAU / self.N) - frame = (np.broadcast_to(along, P.shape[:2]) if p.texture_twist - else np.mod(np.arctan2(P[..., 1], P[..., 0]), TAU)) - out = sections.normals(P) - tex = env[:, None] * self._texture(frame, self.z, float(R0.mean())) - if self.fn is not None: - tex = tex + self.fn(theta=np.mod(np.arctan2(P[..., 1], P[..., 0]), TAU), phi=frame, - z=self.z[:, None], h=(self.z / self.H)[:, None], r=np.hypot(P[..., 0], P[..., 1]), - H=np.float64(self.H)) - if p.nose == RAISED_NOSE: - s = (np.mod(np.arctan2(P[..., 1], P[..., 0]) + 0.5 * math.pi + math.pi, TAU) - math.pi) * R0[:, None] - tex = tex + hollow.nose(s, self.z, p, 0.9 * math.tan(math.radians(p.max_overhang))) - return P + tex[..., None] * out - - def _free_solid(self, P): - """The closed solid from rows of outline points: (verts, faces, band of each wall face, wall face count).""" - verts, faces, band, first, last = grid_side_xy(self.z, P) - side = len(faces) - bottom = first[sections.triangulate(P[0])][:, ::-1] # facing down, onto the bed - top = last[sections.triangulate(P[-1])] - return verts, np.concatenate([faces, bottom, top]), band, side - - def _free_printable(self): - """Soften a drawn vase until no face leans past the limit. - - A drawn outline itself stands upright; only the changes up the vase - lean it - the outline widening or narrowing, the twist, the change of - shape and the patterns. Where faces lean too far, those changes are - slowed at that height (the same envelopes as for the columns of a - radial vase) and the patterns eased, until every face is within it. - """ - p = self.p - R0, tau, mix, env = self.R0_d.copy(), self.tau_d.copy(), self.mix_d.copy(), self.env_d.copy() - aim = math.tan(math.radians(max(p.max_overhang - 0.4, 1.0))) - # patterns fade in and out over a few layers: a sudden change of depth would lean the wall itself - fade = 0.1 * aim * self.dz / max(self.tex_depth, 1e-6) - P = self.P_design - for attempt in range(24 if p.soften else 1): - if attempt: - P = self._free_points(R0, tau, mix, env) - verts, faces, band, side = self._free_solid(P) - lean = lean_angles(verts, faces[:side]) - worst = float(lean.max()) - if worst <= p.max_overhang + TOLERANCE or not p.soften: - break - over = np.zeros(self.M) - np.maximum.at(over, band, lean) - f = np.where(over > p.max_overhang, aim / np.tan(np.radians(np.minimum(over, 89.0))), 1.0) - f = np.clip(f, 0.1, 1.0) - pad = np.concatenate([[f[0]] * 2, f, [f[-1]] * 2]) - f = np.minimum.reduce([pad[k:k + self.M] for k in range(5)]) # a little room each side - R0 = limit_slope(R0, np.abs(np.diff(R0)) * f) - tau = limit_slope(tau, np.abs(np.diff(tau)) * f) - mix = limit_slope(mix, np.abs(np.diff(mix)) * f) - env = fall_gently(env * np.minimum(np.concatenate([[f[0]], f]), np.concatenate([f, [f[-1]]])), fade) - self.P, self.tau, self.rows = P, tau, None - # what softening changed, in the design's own terms - with np.errstate(divide="ignore", invalid="ignore"): - kept = np.where(self.env_d > 1e-6, env / self.env_d, 1.0) - self.eased = dict(twist=float(np.degrees(np.abs(tau - self.tau_d)).max()), - width=float(2.0 * np.abs(R0 - self.R0_d).max()), - change=float(np.abs(mix - self.mix_d).max()), patterns=float(np.min(kept))) - self.verts, self.faces, self.side = verts, faces, side - self.band, self.lean, self.worst = band, lean, worst - moved = np.linalg.norm(P - self.P_design, axis=-1) - self.row_moved = moved.max(axis=1) - self.softened = float(moved.max()) if p.soften else 0.0 - if not self.draft: - self.closed = edge_check(faces)[0] - self.band_lean = np.zeros(self.M) - np.maximum.at(self.band_lean, band, lean) - self.triangles = len(faces) - self.size = verts.max(0) - verts.min(0) - self.top = self.H - - def _P_at(self, zq): - """The outline's points at height zq, between the rows.""" - fj = min(max(zq / self.dz, 0.0), self.M) - j = min(int(fj), self.M - 1) - return self.P[j] + (fj - j) * (self.P[j + 1] - self.P[j]) - - # ---------------------------------------------------------- the sums - def _ring_stats(self, rows): - """Perimeter and enclosed area of each ring.""" - per, area = [], [] - for _, a, r in rows: - x, y = r * np.cos(a), r * np.sin(a) - x1, y1 = np.roll(x, -1), np.roll(y, -1) - per.append(float(np.hypot(x1 - x, y1 - y).sum())) - area.append(float(0.5 * (x * y1 - x1 * y).sum())) - return np.array(per), np.array(area) - - def _measure(self): - p = self.p - w, lh = self.wall, p.layer_height - loops = max(int(p.walls), 1) if p.vase_mode == FAKE_VASE else 1 # lines round each layer - if self.outer_rows is not None: - outer = self.outer_rows - elif self.rows is not None: - outer = self.rows - else: - outer = [(zz, self.theta, rr) for zz, rr in zip(self.z, self.R)] - oz = np.array([o[0] for o in outer]) - o_per, o_area = self._ring_stats(outer) - iz = np.array([i[0] for i in self.inner_rows]) - _, i_area = self._ring_stats(self.inner_rows) - zb = self.zb - above = oz >= zb - zs = np.concatenate([[zb], oz[above]]) - ps = np.concatenate([[np.interp(zb, oz, o_per)], o_per[above]]) - wall_area = float(np.sum(0.5 * (ps[1:] + ps[:-1]) * np.diff(zs))) # mm² of wall, unrolled - bottom_vol = float(np.interp(0.0, oz, o_area)) * zb - self.capacity = float(np.sum(0.5 * (i_area[1:] + i_area[:-1]) * np.diff(iz))) / 1000.0 # ml - if self.holes_open or p.open_bottom or p.closed_top or p.box: - self.capacity = 0.0 - fil_area = math.pi * (p.filament_d / 2.0) ** 2 - vol = mesh_volume(self.shell_verts, self.shell_faces) # the plastic, holes and all - wall_area *= 1.0 - self.hole_share - self.grams = vol / 1000.0 * p.density - self.metres = vol / fil_area / 1000.0 - v = min(p.speed, p.max_flow / max(p.line_width * lh, 1e-6)) - layers_bottom = 0 if p.open_bottom else max(1, math.ceil(zb / lh - 1e-6)) - path = wall_area * loops / lh + layers_bottom * float(np.interp(0.0, oz, o_area)) / p.line_width - self.seconds = path / max(v, 0.1) + 120.0 - self.layers = int(round(self.H / lh)) - self.bottom_layers = layers_bottom - # filament used up to each height, for a gradient spool's colours - cum = np.concatenate([[0.0], np.cumsum(0.5 * (o_per[1:] + o_per[:-1]) * np.diff(oz))]) * w - cum += np.minimum(oz, zb) * float(np.interp(0.0, oz, o_area)) - self.filament_z, self.filament_at = oz, cum / fil_area # mm of filament - self.solid_volume = mesh_volume(self.verts, self.faces) / 1000.0 # cm³ - r0, rt = outer[0][2], self.inner_rows[-1][2] - self.base_d = (2 * float(r0.min()), 2 * float(r0.max())) - self.top_d = (2 * float(rt.min()), 2 * float(rt.max())) - self.widest = 2 * float(max(o[2].max() for o in outer)) - # for the profile picture: the widest and narrowest radius at each height - self.profile_z = oz - self.profile_max = np.array([o[2].max() for o in outer]) - self.profile_min = np.array([o[2].min() for o in outer]) - - # ----------------------------------------------------------- colours - def _colours(self): - """The filament for each height: [(from z, filament)], snapped to layers.""" - p = self.p - lh, H = p.layer_height, self.H - used = max(1, min(int(p.colours_used), 4)) - bands = [(0.0, 1)] - if colour_style_of(p) == BANDS: - k = max(int(p.band_count), 1) - gamma = 2.0 ** (1.3 * p.band_spacing) - bands = [(H * (i / k) ** gamma, 1 + i % used) for i in range(k)] - elif colour_style_of(p) == STRIPES: - stripe = max(p.stripe_height, lh) - n = math.ceil(H / stripe - 1e-9) - if n > MAX_BANDS: - stripe = H / MAX_BANDS - n = MAX_BANDS - self.warnings.append(f"Stripes that thin would be more than {MAX_BANDS} colour changes: " - f"they are made {stripe:.1f} mm tall.") - bands = [(i * stripe, 1 + i % used) for i in range(n)] - snapped = [] - for i, (zs, fil) in enumerate(bands): - zs = 0.0 if i == 0 else round(zs / lh) * lh - if zs >= H - 0.5 * lh: # starts at the very top: never printed - continue - if snapped and zs <= snapped[-1][0] + 0.5 * lh: - snapped[-1] = (snapped[-1][0], fil) # the band before has no layers: this one takes over - else: - snapped.append((zs, fil)) - merged = [] - for zs, fil in snapped: - if not merged or merged[-1][1] != fil: - merged.append((zs, fil)) - self.bands = merged or [(0.0, 1)] - self.gradient = colour_style_of(p) == GRADIENT - self.gradient_fils = list(range(1, used + 1)) - - def filament_of(self, k: int) -> str: - return getattr(self.p, f"color{k}").upper() - - def band_parts(self): - """The vase as one closed solid per colour band: [(filament, verts, faces)], bottom to top. - - Neighbouring bands meet on a shared flat face at the height where the - colour changes, so together they fill the vase exactly once - the way - a slicer wants the parts of a multi-colour object. - """ - tops = [zs for zs, _ in self.bands[1:]] + [self.H] - parts = [] - for (lo, fil), hi in zip(self.bands, tops): - if self.free: - inside = (self.z > lo + 1e-6) & (self.z < hi - 1e-6) - zs = np.concatenate([[lo], self.z[inside], [hi]]) - Ps = np.concatenate([self._P_at(lo)[None], self.P[inside], self._P_at(hi)[None]]) - verts, faces, _, first, last = grid_side_xy(zs, Ps) - faces = np.concatenate([faces, first[sections.triangulate(Ps[0])][:, ::-1], - last[sections.triangulate(Ps[-1])]]) - parts.append((fil, verts, faces)) - continue - if self.rows is None: - inside = (self.z > lo + 1e-6) & (self.z < hi - 1e-6) - zs = np.concatenate([[lo], self.z[inside], [hi]]) - Rs = np.vstack([self._row_at(lo), self.R[inside], self._row_at(hi)]) - verts, faces, _, first, last = grid_side(zs, self.theta, Rs) - else: - rings = [self._ring_at(lo)] + [r for r in self.rows if lo + 1e-6 < r[0] < hi - 1e-6] + [self._ring_at(hi)] - verts, faces, _, first, last = loft_side(rings) - n = len(verts) - verts = np.concatenate([verts, [[0.0, 0.0, lo], [0.0, 0.0, hi]]]) - faces = np.concatenate([faces, fan(n, first, up=False), fan(n + 1, last, up=True)]) - parts.append((fil, verts, faces)) - return parts - - def _row_at(self, zq): - """The surface's radii at height zq, between the grid's rows (straight up each column).""" - fj = min(max(zq / self.dz, 0.0), self.M) - j = min(int(fj), self.M - 1) - return self.R[j] + (fj - j) * (self.R[j + 1] - self.R[j]) - - def _ring_at(self, zq): - """The low-poly wall's cross-section at height zq, as a ring (z, angles, radii).""" - for row in self.rows: - if abs(row[0] - zq) < 1e-6: - return row - j = max(k for k, row in enumerate(self.rows) if row[0] < zq) - verts, faces, *_ = loft_side(self.rows[j:j + 2]) - tri = verts[faces] - d = tri[:, :, 2] - zq - pts = [] - for a, b in ((0, 1), (1, 2), (2, 0)): - hit = d[:, a] * d[:, b] < 0 - t = d[hit, a] / (d[hit, a] - d[hit, b]) - pts.append(tri[hit, a] + (tri[hit, b] - tri[hit, a]) * t[:, None]) - pts = np.unique(np.round(np.concatenate(pts)[:, :2], 6), axis=0) - ang = np.mod(np.arctan2(pts[:, 1], pts[:, 0]), TAU) - order = np.argsort(ang) - ang, rad = ang[order], np.hypot(pts[order, 0], pts[order, 1]) - keep = np.concatenate([[True], np.diff(ang) > 1e-7]) - return (zq, ang[keep], rad[keep]) - - def colour_changes(self, first_layer: float | None = None): - """[(layer number, height of that layer's top, filament)] for each change after the first. - - first_layer: the slicer's first layer height, if it differs from the rest. - """ - lh = self.p.layer_height - return [(*layer_top(zs, lh, first_layer), fil) for zs, fil in self.bands[1:]] - - def colour_phase(self, z): - """Colour cycles of a gradient spool used by each height.""" - length = max(self.p.gradient_length, 0.05) * 1000.0 - return np.interp(z, self.filament_z, self.filament_at) / length - - def colour_plan(self, first_layer: float | None = None) -> str: - p = self.p - lines = [f"Vaseinator: {p.name} - {'a normal print' if self.normal else 'spiral vase mode'}, " - f"{p.layer_height:g} mm layers"] - if self.gradient: - lines.append(f"Print with one gradient spool (one colour cycle every {p.gradient_length:g} m).") - return "\n".join(lines) - lines.append(f"Start with filament {self.bands[0][1]} ({self.filament_of(self.bands[0][1])})") - for layer, ztop, fil in self.colour_changes(first_layer): - lines.append(f"Layer {layer} ({ztop:.2f} mm): change to filament {fil} ({self.filament_of(fil)})") - return "\n".join(lines) - - # ------------------------------------------------------------ advice - def _advice(self): - p = self.p - if self.worst > p.max_overhang + TOLERANCE: - if p.soften: - self.warnings.append( - f"Some of the wall still leans {self.worst:.0f}° (the limit is {p.max_overhang:g}°). " - "With low-poly facets, more facets round the vase or fewer up it lean less.") - else: - self.warnings.append( - f"The wall leans up to {self.worst:.0f}°, over the {p.max_overhang:g}° limit: it would " - "sag or fail there (red in the Overhangs view). Turn on Soften to fit, or make the " - "shape gentler.") - elif self.softened > 0.05 and self.free: - e = self.eased - what = [f"the twist by up to {e['twist']:.0f}°" if e["twist"] > 0.5 else "", - f"the outline's width by up to {e['width']:.1f} mm" if e["width"] > 0.05 else "", - f"the change of shape by up to {100 * e['change']:.0f}%" if e["change"] > 0.005 else "", - f"the patterns to {100 * e['patterns']:.0f}% at the steepest place" if e["patterns"] < 0.995 else ""] - what = [w for w in what if w] - self.notes.append("Overhangs softened, so no part of the wall leans more than " - f"{p.max_overhang:g}°: " + (", ".join(what) if what else "slightly") + ".") - elif self.softened > 0.05: - self.notes.append(f"Overhangs softened: the surface was moved by up to {self.softened:.1f} mm so " - f"no part of the wall leans more than {p.max_overhang:g}°.") - if self.H > p.printer_height: - self.warnings.append(f"The vase is {self.H:.0f} mm tall: taller than the printer ({p.printer_height:.0f} mm).") - sx, sy = self.size[0], self.size[1] - if sx > p.bed_x or sy > p.bed_y: - self.warnings.append(f"It is {sx:.0f} × {sy:.0f} mm across: bigger than the bed " - f"({p.bed_x:.0f} × {p.bed_y:.0f} mm).") - if self.H > 3.2 * self.base_d[1] and self.H > 60: - self.warnings.append(f"The base is narrow for the height (Ø {self.base_d[1]:.0f} mm under a " - f"{self.H:.0f} mm vase): it may topple, on the bed or with flowers in. " - "A brim helps it print.") - if self.top_d[0] < 8.0 and not p.closed_top and not p.box: - self.notes.append(f"The opening at the top is only {self.top_d[0]:.0f} mm across: a bud vase.") - changes = len(self.bands) - 1 - if changes > 20: - self.warnings.append(f"{changes} colour changes: each one takes time and leaves a small seam. " - "Fewer, taller bands print cleaner.") - if self.pattern_open and p.hole_strut < 2.0 * p.line_width: - self.warnings.append(f"The wall between the holes ({p.hole_strut:g} mm) is thinner than two lines " - f"({2 * p.line_width:g} mm): it may print thin or break.") - if p.line_width < 0.45: - self.notes.append("A wider line (0.6 to 0.8 mm on a 0.4 mm nozzle) makes a stronger, more surely " - "watertight wall.") - if self.closed is False: # pragma: no cover - the construction should never allow it - self.warnings.append("The mesh is not closed: please report this design.") - - # -------------------------------------------------------------- view - def info(self) -> dict: - p = self.p - lean = f"{self.worst:.1f}° from upright (limit {p.max_overhang:g}°)" - lean += " ✓" if self.worst <= p.max_overhang + TOLERANCE else " ✗" - mesh = f"{self.triangles:,} triangles" - if self.closed: - mesh += ", closed and watertight ✓" - d = { - "Height": f"{self.H:.1f} mm, {self.layers} layers of {p.layer_height:g} mm", - "Widest": f"Ø {self.widest:.1f} mm", - "Footprint": f"{self.size[0]:.1f} × {self.size[1]:.1f} mm", - "Base": f"Ø {self.base_d[0]:.0f}–{self.base_d[1]:.0f} mm", - **({"Top": ("closed in a point" + (f", over a ceiling {self.ceiling:.0f} mm across" if self.ceiling else "")) - if self.normal else "comes in to a point (spiral vase mode prints no top: a hole a few mm " - "across stays at the very top)"} - if p.closed_top else {"Opening at the top": f"Ø {self.top_d[0]:.0f}–{self.top_d[1]:.0f} mm"}), - **({"Goes round": f"{p.around:g}° of the way round"} if p.around < 360.0 else {}), - **({"Fits a head": f"up to {self.head_fit / 10.0:.1f} cm round (round the forehead, above the ears)"} - if p.worn and self.head_fit > 0 else {}), - **({"Fits over": f"a box of tissues {p.box_length / 10.0:g} × {p.box_width / 10.0:g} × " - f"{p.box_height / 10.0:g} cm, with {self.box_room:.1f} mm to spare each side and " - f"{BOX_TOP_ROOM:g} mm over it", - "Tissues come out of": (f"{TISSUE_WORDS[p.tissue_hole]} {self.tissue[0]:.0f}" - + ("" if p.tissue_hole == TISSUE_ROUND else f" × {self.tissue[1]:.0f}") - + " mm, through its bottom layers") if self.tissue else "nowhere: no hole", - "Printed": "upside down, as it is shown - the hole on the bed, the open top for the box to go " - "in: nothing needs supports. Turn it over to put it on."} if p.box else {}), - "Holds": ("nothing: it goes only part way round" if p.around < 360.0 else - "nothing: it is open at the bottom" if p.open_bottom else - "nothing: it has holes" if self.holes_open else - "nothing: it is closed at the top" if p.closed_top else - f"about {self.capacity:.0f} ml of water"), - "Wall": (f"one {p.line_width:g} mm spiral line" if p.vase_mode != FAKE_VASE else - f"{max(int(p.walls), 1)} walls of {p.line_width:g} mm (about {self.wall:.1f} mm)") - + (", open at the bottom" if p.open_bottom else - f", on a {self.zb:g} mm solid bottom ({self.bottom_layers} layers)"), - **({"Holes": self._hole_words()} if self.pattern_open else {}), - **({"Face": self._face_words()} if self._face_words() else {}), - "Steepest overhang": lean, - "Filament": f"{self.grams:.0f} g, {self.metres:.1f} m", - "Print time": f"about {duration(self.seconds)}", - "Mesh": mesh, - } - if p.around < 360.0: # widths round the whole oval it is cut from would mislead - for key in ("Widest", "Base", "Opening at the top"): - d.pop(key, None) - if p.box: # a box isn't round, and holds tissues, not water - for key in ("Widest", "Base", "Opening at the top", "Holds"): - d.pop(key, None) - d["Wall"] = d["Wall"].replace("solid bottom", "solid top") - return d - - def _hole_words(self) -> str: - p = self.p - if p.holes == VORONOI: - text = (f"Voronoi ovals, {p.hole_size:g} mm apart with at least {p.hole_strut:g} mm between; round tops" - + (f", bridging at most {self.bridge:.1f} mm" if self.bridge > 0 else "")) - else: - text = (f"{HOLE_WORDS[p.holes]}, {p.hole_size:g} mm apart with {p.hole_strut:g} mm between; pointed tops" - + (f" ({self.bridge:.1f} mm bridges at their points)" if self.bridge > 0 else "")) - if p.hole_swell > 0: - text += f"; edges rounded, the wall swelling {p.hole_swell:g} mm each side round them" - return text - - def _face_words(self) -> str: - p = self.p - words = [{ALMOND_EYES: "almond eyes"}.get(p.eyes, "round eyes") if p.eyes != NO_EYES else "", - "a raised nose" if p.nose == RAISED_NOSE else "", - {OPEN_MOUTH: "an open mouth", GRILLE_MOUTH: f"a grille of {p.mouth_slots} slots for a mouth", - CHIN_MOUTH: "open down to the chin"}.get(p.mouth, ""), - "slots for a strap" if p.straps else ""] - words = [w for w in words if w] - if not words: - return "" - text = ", ".join(words) - if face_holes(p) and not self.pattern_open: - text += "; pointed tops" + (f" ({self.bridge:.1f} mm bridges at their points)" if self.bridge > 0 else "") - return text[:1].upper() + text[1:] - - def contour(self, zc: float): - """Where the wall crosses height zc: line segments (K, 2, 3), for the print animation.""" - if not hasattr(self, "_tri"): - if self.holes_open or self.floor_hole is not None: # the pot's wall: each piece between holes a loop - keep = np.isin(self.shell_kind, (hollow.OUTER, hollow.INNER, hollow.SIDE, hollow.TIP)) - tri = self.shell_verts[self.shell_faces[keep]] - else: - tri = self.verts[self.faces[:self.side]] - self._tri = tri - self._tz = (tri[:, :, 2].min(1), tri[:, :, 2].max(1)) - zc = float(zc) + 1e-7 - sel = self._tri[(self._tz[0] < zc) & (self._tz[1] > zc)] - if not len(sel): - return np.zeros((0, 2, 3)) - pts, crosses = [], [] - for a, b in ((0, 1), (1, 2), (2, 0)): - za, zb = sel[:, a, 2], sel[:, b, 2] - cross = (za - zc) * (zb - zc) < 0 - t = np.where(cross, (zc - za) / np.where(zb == za, 1.0, zb - za), 0.0) - pts.append(sel[:, a] + (sel[:, b] - sel[:, a]) * t[:, None]) - crosses.append(cross) - P, C = np.stack(pts, 1), np.stack(crosses, 1) - first_two = np.argsort(~C, axis=1, kind="stable")[:, :2] # the two edges each face crosses - seg = np.take_along_axis(P, first_two[:, :, None], axis=1) - return seg[C.sum(1) >= 2] - - -# ================================================================ a tissue box cover -def box_room(p: VaseParams) -> float: - """How much bigger a tissue box cover is inside than the box of tissues, each side (mm): BOX_ROOM, more where - rounded corners need it to clear a square-cornered box, and more for patterns and rounded hole edges that - reach in.""" - wall = p.line_width * max(int(p.walls), 1) - inward = ((max(-p.rib_depth, 0.0) if p.rib_count > 0 else 0.0) + (max(-p.ring_depth, 0.0) if p.ring_count > 0 - else 0.0) + (max(-p.bump_depth, 0.0) if p.bump_around > 0 else 0.0) + 1.2 * max(p.organic_depth, 0.0) - + (p.hole_swell if p.holes != NO_HOLES else 0.0)) - corner = max(p.box_corner - wall, 1.0) # inside - return max(BOX_ROOM, corner - (corner - BOX_ROOM) / math.sqrt(2.0)) + inward - - -def box_shape(p: VaseParams) -> VaseParams: - """A tissue box cover's own shape, from the box of tissues it goes over: a rectangle seen from above with - rounded corners, straight up. It is made upside down, as it prints - the top, with the hole for the tissues, - is the floor on the bed, and the rim is the open bottom the box goes in by - so nothing needs supports.""" - q = p.copy() - wall = q.line_width * max(int(q.walls), 1) - room = box_room(q) - a = 0.5 * max(q.box_length, q.box_width) + room + BOX_END_ROOM + wall # half its length and width, outside - b = 0.5 * min(q.box_length, q.box_width) + room + wall - c = min(max(q.box_corner, wall + 1.0), b) - reach = math.hypot(a - c, b - c) + c # to the furthest point, round a corner - q.shape1, q.depth1, q.sharp1, q.shape2 = RECTANGLE, min(1.0 - b / a, 0.9), c / b, SAME - q.turn1 = 90.0 if q.box_width > q.box_length else 0.0 - q.height = round(q.box_height + BOX_TOP_ROOM + max(q.bottom, q.layer_height), 3) - q.profile, q.silhouette = 1, [[0.0, round(2.0 * reach, 3)], [1.0, round(2.0 * reach, 3)]] - q.twist, q.twist_style, q.lattice, q.facets = 0.0, EVENLY, False, False - q.open_bottom, q.closed_top, q.around, q.worn = False, False, 360.0, False - q.eyes, q.nose, q.mouth, q.straps = NO_EYES, NO_NOSE, NO_MOUTH, False - q.vase_mode = FAKE_VASE - return q - - -def ledge_lean(p: VaseParams) -> float: - """How far a cover's ledges' steep sides lean from upright (degrees): LEDGE_LEAN, or less, as the wall may.""" - return max(min(LEDGE_LEAN, p.max_overhang - 10.0), 10.0) - - -def ledge_heights(p: VaseParams): - """Where a cover's ledges change, measured up from the rim with the cover on: the foot of the ramp, the top - of the ramp and the end of the flat, both LEDGE_DEPTH in, and the top of the steep side.""" - h0 = LEDGE_FROM - h1 = h0 + LEDGE_RAMP - h2 = h1 + LEDGE_FLAT - return h0, h1, h2, h2 + LEDGE_DEPTH / math.tan(math.radians(ledge_lean(p))) - - -def ledges(z, I, H: float, heights): - """The inside of a cover's wall I (K, N, 2) at heights z, with a ledge across each end - the straight part of - the end, on the long way - standing in from the wall near the rim at H, for its lid's tabs to click in - behind. As the cover prints, upside down, the ramp faces up and the steep side down, no steeper than a wall - may lean, so nothing needs supports.""" - h0, h1, h2, h3 = heights - h = H - np.asarray(z, dtype=float) - depth = LEDGE_DEPTH * np.interp(h, [h0, h1, h2, h3], [0.0, 1.0, 1.0, 0.0], left=0.0, right=0.0) - rim = I[-1] - X, Y = np.abs(rim).max(axis=0) - U = np.array([1.0, 0.0]) if X >= Y else np.array([0.0, 1.0]) - V = np.array([-U[1], U[0]]) - u, v = rim @ U, rim @ V - flat = v[np.abs(u) >= np.abs(u).max() - 0.05] - half = max(0.5 * float(flat.max() - flat.min()) - 1.0, 12.0) # the straight part of each end, or its middle - across = np.clip((half - np.abs(I @ V)) / 3.0, 0.0, 1.0) # each ledge tapers off at its ends ... - on_end = np.abs(I @ U) > 0.5 * np.abs(u).max() # ... and is only on the ends - off = depth[:, None] * across * on_end - return I - sections.normals(I) * off[..., None] - - -# ================================================================ sizing for a head -def scaled(p: VaseParams, k: float) -> VaseParams: - """The design k times the size: its height, outline and face (patterns and holes stay as they are).""" - q = p.copy() - q.silhouette = [[h, round(d * k, 2)] for h, d in p.silhouette] - for key in HEAD_SCALED: - setattr(q, key, round(getattr(p, key) * k, 2)) - return q - - -def fit_head(p: VaseParams, head: float) -> VaseParams: - """The design sized to fit a head ``head`` mm round (round the forehead, above the ears), with a little - room: the inside measured where the head goes in, the whole design scaled to fit.""" - q = p.copy() - for _ in range(4): - d = VaseDesign(q, draft=True) - if d.errors or d.head_fit <= 0: - return q - if abs(d.head_fit - head) < 0.5: - break - extra = HEAD_ROOM + TAU * d.wall # the inside is this much less round than the outside - q = scaled(q, (head + extra) / (d.head_fit + extra)) - return q +"""The vase itself: its surface, made printable, as closed watertight meshes. + +The surface is a radial height map r(theta, z) on a grid of heights and +angles. Because every height has exactly one radius in each direction, +each layer is one simple closed loop round the axis - just what a spiral +vase slicer needs - and the mesh can never cross itself: every triangle +lies in its own wedge of angle between two heights. + +Overhangs are measured on the real surface normal. A wall leaning out *or +in* overhangs the single printed line beneath it, so both count. Where the +wall leans too far, the radius is limited column by column (a Lipschitz +envelope from above and below, averaged), with the allowed change per layer +worked out from how the wall faces there - a flank that faces sideways may +move a long way sideways. The finished triangles are then checked, and the +limit tightened until every one of them is within it. +""" + +from __future__ import annotations + +import math + +import numpy as np + +from .expr import ExprError, compile_expression +from . import hollow, sections +from .params import (ALMOND_EYES, BANDS, CHIN_MOUTH, DIAMOND, DRAWN, EVENLY, FAKE_VASE, GRADIENT, GRILLE_MOUTH, + MAX_BANDS, NO_EYES, NO_HOLES, NO_MOUTH, NO_NOSE, NO_TISSUE, ONE_COLOUR, OPEN_MOUTH, OWN, + RAISED_NOSE, REAL_VASE, RECTANGLE, ROUND, SAME, SLOTS, STRIPES, TISSUE_OVAL, TISSUE_ROUND, + VORONOI, VaseParams, colour_style_of, face_holes, needs_normal) +from .shapes import (TABLE, TAU, blur, change, lobes_needed, organic, pchip, progress, rounded_rectangle, sample, + shape_table, smooth_max, smoothstep, strength) + +TOLERANCE = 0.3 # degrees over the overhang limit still counted as within it +HOLE_WORDS = {VORONOI: "Voronoi", ROUND: "round holes", DIAMOND: "diamonds", SLOTS: "slots"} +VORONOI_BRIDGE = 0.7 # a Voronoi oval's round top is bridged once it is this share of the cells' size across +VORONOI_LONGEST = 14.0 # ... and never more than this (mm): a round top that size bridges on most printers +HEAD_ROOM = 10.0 # mm more round the inside of a mask or helmet than round the head it fits +SIMPLIFY_ERROR = 1e-3 # how far saving may simplify the mesh: pyfqmr's lossless error, a few hundredths of a mm +SIMPLIFY_MM = 0.02 # ... and in the web version, meshoptimizer's (mm) +BOX_ROOM = 2.0 # mm to spare each side between a tissue box cover and the box of tissues inside it +BOX_TOP_ROOM = 3.0 # ... and over the box's top +BOX_END_ROOM = 2.0 # ... and more inside each end, for the lid's tabs +LEDGE_DEPTH = 1.2 # a cover's ledges, inside each end at the rim, that its lid clicks in behind (mm) +LEDGE_FROM = 4.0 # ... how far in from the rim each starts, the cover on +LEDGE_RAMP = 3.5 # ... the gentle slope the lid's tabs ride over, going on +LEDGE_FLAT = 0.4 # ... its flat +LEDGE_LEAN = 35.0 # ... and the steep side they catch on, from upright: an overhang as the cover prints, + # with room for a pattern's own lean +LEDGE_CLEAR = 13.0 # a cover's holes keep this far from its rim, clear of the lid's tabs (mm) +TISSUE_MARGIN = 8.0 # the tissue hole keeps at least this much of the cover's top round it (mm) +TISSUE_WORDS = {1: "a slot", 2: "an oval", 3: "a round hole"} +# what grows with a design sized for a head: its height and the face, in mm +HEAD_SCALED = ("height", "eye_width", "eye_height", "eye_apart", "eye_up", "nose_width", "nose_length", + "nose_depth", "nose_up", "mouth_width", "mouth_height", "mouth_up", "mouth_smile", "strap_up") + + +def require_exportable(design): + """One export gate for desktop and browser, including lids.""" + if design.errors: + raise ValueError("; ".join(design.errors)) + if not design.closed: + raise ValueError("The mesh is not closed. Please report this design before printing it.") + return design + + +# ===================================================================== meshes +def grid_side(z, theta, R): + """The wall from a grid: rows of radii at shared angles. + + Returns (verts, faces, band of each face, first ring, last ring). Each + cell is split along the diagonal whose ends are closer in radius, so + ridges that spiral up the vase stay smooth. + """ + K, N = R.shape + c, s = np.cos(theta)[None, :], np.sin(theta)[None, :] + verts = np.stack([R * c, R * s, np.broadcast_to(z[:, None], R.shape)], -1).reshape(-1, 3) + idx = np.arange(K * N).reshape(K, N) + nxt = np.roll(idx, -1, axis=1) + v00, v01, v10, v11 = idx[:-1], nxt[:-1], idx[1:], nxt[1:] + Rn = np.roll(R, -1, axis=1) + along = (np.abs(R[:-1] - Rn[1:]) <= np.abs(Rn[:-1] - R[1:]))[..., None] + t1 = np.where(along, np.stack([v00, v01, v11], -1), np.stack([v00, v01, v10], -1)) + t2 = np.where(along, np.stack([v00, v11, v10], -1), np.stack([v01, v11, v10], -1)) + faces = np.concatenate([t1.reshape(-1, 3), t2.reshape(-1, 3)]) + band = np.tile(np.repeat(np.arange(K - 1), N), 2) + return verts, faces, band, idx[0].copy(), idx[-1].copy() + + +def loft_side(rows): + """The wall from rings with angles of their own: (z, angles ascending, radii) each. + + Neighbouring rings are joined by walking round both at once, always + stepping on along the one whose next point comes first. + """ + verts, starts, o = [], [], 0 + for z, a, r in rows: + verts.append(np.stack([r * np.cos(a), r * np.sin(a), np.full(len(a), z)], 1)) + starts.append(o) + o += len(a) + faces, band = [], [] + for k in range(len(rows) - 1): + A, B = rows[k][1], rows[k + 1][1] + na, nb, sa, sb = len(A), len(B), starts[k], starts[k + 1] + d = np.mod(B - A[0] + math.pi, TAU) - math.pi + j0 = int(np.argmin(np.abs(d))) + b0 = A[0] + d[j0] + au = A[0] + np.append(np.mod(A - A[0], TAU), TAU) + bu = b0 + np.append(np.mod(np.roll(B, -j0) - B[j0], TAU), TAU) + i = j = 0 + while i < na or j < nb: + if j >= nb or (i < na and au[i + 1] <= bu[j + 1]): + faces.append((sa + i % na, sa + (i + 1) % na, sb + (j0 + j) % nb)) + i += 1 + else: + faces.append((sa + i % na, sb + (j0 + j + 1) % nb, sb + (j0 + j) % nb)) + j += 1 + band.append(k) + verts = np.concatenate(verts) + first = np.arange(starts[0], starts[0] + len(rows[0][1])) + last = np.arange(starts[-1], starts[-1] + len(rows[-1][1])) + return verts, np.array(faces, dtype=np.int64), np.array(band), first, last + + +def grid_side_xy(z, P): + """The wall from rows of outline points (K, N, 2), point i of each row joined to point i of the next. + + Returns (verts, faces, band of each face, first ring, last ring). Each cell + is split along its shorter diagonal. + """ + K, N = P.shape[:2] + verts = np.concatenate([P, np.broadcast_to(z[:, None, None], (K, N, 1))], axis=2).reshape(-1, 3) + idx = np.arange(K * N).reshape(K, N) + nxt = np.roll(idx, -1, axis=1) + v00, v01, v10, v11 = idx[:-1], nxt[:-1], idx[1:], nxt[1:] + d1 = np.linalg.norm(verts[v00] - verts[v11], axis=-1) + d2 = np.linalg.norm(verts[v01] - verts[v10], axis=-1) + along = (d1 <= d2)[..., None] + t1 = np.where(along, np.stack([v00, v01, v11], -1), np.stack([v00, v01, v10], -1)) + t2 = np.where(along, np.stack([v00, v11, v10], -1), np.stack([v01, v11, v10], -1)) + faces = np.concatenate([t1.reshape(-1, 3), t2.reshape(-1, 3)]) + band = np.tile(np.repeat(np.arange(K - 1), N), 2) + return verts, faces, band, idx[0].copy(), idx[-1].copy() + + +def limit_slope(v, step): + """A 1D curve kept within a most change per step (step[i] between v[i] and v[i+1]). + + The largest curve under it and the smallest over it that keep within the + limit, averaged - as the columns of a radial vase are softened. + """ + lo, hi = v.copy(), v.copy() + for i in range(1, len(v)): + lo[i] = min(lo[i], lo[i - 1] + step[i - 1]) + hi[i] = max(hi[i], hi[i - 1] - step[i - 1]) + for i in range(len(v) - 2, -1, -1): + lo[i] = min(lo[i], lo[i + 1] + step[i]) + hi[i] = max(hi[i], hi[i + 1] - step[i]) + return 0.5 * (lo + hi) + + +def fall_gently(v, step: float): + """The largest curve under v that changes by at most step from one value to the next.""" + lo = v.copy() + for i in range(1, len(v)): + lo[i] = min(lo[i], lo[i - 1] + step) + for i in range(len(v) - 2, -1, -1): + lo[i] = min(lo[i], lo[i + 1] + step) + return lo + + +def fan(center: int, ring, up: bool): + """A flat cap: triangles from the centre to each side of a ring.""" + ring = np.asarray(ring) + nxt = np.roll(ring, -1) + c = np.full(len(ring), center) + return np.stack([c, ring, nxt] if up else [c, nxt, ring], 1) + + +def annulus(outer, inner): + """A flat, upward-facing ring between two rings of the same angles.""" + o, i = np.asarray(outer), np.asarray(inner) + o1, i1 = np.roll(o, -1), np.roll(i, -1) + return np.concatenate([np.stack([o, o1, i1], 1), np.stack([o, i1, i], 1)]) + + +def lighter(verts, faces): + """The mesh with its triangles merged wherever that moves the surface by no more than a few hundredths of a + millimetre - the slicer's Simplify model, done before saving, so a fine mesh makes a file the slicer opens + quickly: with pyfqmr in the desktop app, meshoptimizer in the web version. As it was if neither is there, or + if the result wouldn't be closed.""" + try: + import pyfqmr + except ImportError: + pyfqmr = None + for k in range(3): # each time a little more careful, if it didn't stay closed + if pyfqmr is not None: + s = pyfqmr.Simplify() + s.setMesh(np.asarray(verts, dtype=np.float64), np.asarray(faces, dtype=np.int32)) + s.simplify_mesh_lossless(epsilon=SIMPLIFY_ERROR / 4 ** k, verbose=False) + v, f, _ = s.getMesh() + else: + try: + import meshopt # the web version: given to its Python by the page + except ImportError: + return verts, faces + kept = meshopt.simplify(np.asarray(verts, dtype=np.float32).tobytes(), + np.asarray(faces, dtype=np.uint32).tobytes(), SIMPLIFY_MM / 2 ** k) + f = np.frombuffer(bytes(kept.to_py()), dtype=np.uint32).reshape(-1, 3).astype(np.int64) + used = np.unique(f) # only the corners still in use + v, f = np.asarray(verts)[used], np.searchsorted(used, f) + # a triangle and its twin facing the other way are a fin with nothing inside: neither is needed + f = np.asarray(f, dtype=np.int64) + _, which, count = np.unique(np.sort(f, axis=1), axis=0, return_inverse=True, return_counts=True) + f = f[count[which.ravel()] == 1] + if len(f) and edge_check(f)[0]: + return np.asarray(v, dtype=np.float64), f + return verts, faces + + +def face_normals(verts, faces): + tri = verts[faces] + n = np.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0]) + ln = np.linalg.norm(n, axis=1, keepdims=True) + ln[ln == 0] = 1.0 + return n / ln + + +def lean_angles(verts, faces): + """How far each face leans from upright (degrees), out or in alike.""" + n = face_normals(verts, faces) + return np.degrees(np.arcsin(np.clip(np.abs(n[:, 2]), 0.0, 1.0))) + + +def mesh_volume(verts, faces) -> float: + tri = verts[faces] + return float(np.einsum("ij,ij->i", tri[:, 0], np.cross(tri[:, 1], tri[:, 2])).sum() / 6.0) + + +def edge_check(faces): + """Whether a mesh is closed: every edge used once each way, by exactly two faces. + + Returns (closed, number of bad edges). + """ + f = np.asarray(faces, dtype=np.int64) + a = f.ravel() + b = np.roll(f, -1, axis=1).ravel() + n = int(f.max()) + 1 + fwd = np.sort(a * n + b) + rev = np.sort(b * n + a) + twice = int((np.diff(fwd) == 0).sum()) # an edge used twice the same way + unmatched = int((fwd != rev).sum()) # an edge with no face the other way + return twice == 0 and unmatched == 0, twice + unmatched + + +def inset(angles, radii, width): + """Radii of a ring moved in by ``width`` square to the wall (as a radial map).""" + a = np.asarray(angles) + r = np.asarray(radii) + da = np.mod(np.roll(a, -1, axis=-1) - np.roll(a, 1, axis=-1), TAU) + da[da == 0] = 1e-9 + rth = (np.roll(r, -1, axis=-1) - np.roll(r, 1, axis=-1)) / da + out = r - width * np.sqrt(r * r + rth * rth) / np.maximum(r, 1e-6) + return np.maximum(out, np.maximum(0.15 * r, 0.2)) + + +def soften(R, dth, dz, angle_deg, scale=None, rounds=4): + """Limit the radius so that no part of the wall leans more than angle_deg from upright. + + Column by column, the largest surface under R and the smallest one over + it that keep within the lean are found and averaged: peaks are cut back + and hollows filled in by the same amount. ``scale`` tightens the limit + point by point where the finished triangles showed it was needed. + """ + t = math.tan(math.radians(max(angle_deg, 1.0))) + R = R.copy() + for _ in range(rounds): + # how far the wall faces sideways there - smoothed round the vase, so that neighbouring + # columns are allowed much the same and a sharp tip passing across them doesn't streak + rth = (np.roll(R, -1, axis=1) - np.roll(R, 1, axis=1)) / (2.0 * dth) + k = np.sqrt(R * R + rth * rth) / np.maximum(R, 1e-6) + k = t * np.exp(blur_around(np.log(k), 1.5)) + if scale is not None: + k = k * scale + step = np.minimum(k[:-1], k[1:]) * dz # most each row may differ from the next + lo, hi = R.copy(), R.copy() + for j in range(1, len(R)): + np.minimum(lo[j], lo[j - 1] + step[j - 1], out=lo[j]) + np.maximum(hi[j], hi[j - 1] - step[j - 1], out=hi[j]) + for j in range(len(R) - 2, -1, -1): + np.minimum(lo[j], lo[j + 1] + step[j], out=lo[j]) + np.maximum(hi[j], hi[j + 1] - step[j], out=hi[j]) + new = 0.5 * (lo + hi) + moved = float(np.abs(new - R).max()) + R = new + if moved < 1e-4: + break + return R + + +def ease_twist(R, dth, omega, angle_deg, rounds=3): + """Soften the flanks of a fast-twisting shape round the vase, layer by layer. + + A flank that faces sideways and is carried sideways by the twist leans by + tan(lean) = r·ω·|r_θ| / sqrt(r² + r_θ²). Where r·ω is more than the limit + allows, only a gentler flank can keep within it, so |r_θ| is limited round + each layer (envelopes from above and below, averaged, as for the columns). + Every layer of a twisted shape is softened alike, so it stays one clean + twisted shape. A little is left in hand for the outline's own slope. + """ + t = math.tan(math.radians(max(angle_deg, 1.0))) * 0.9 + N = R.shape[1] + for _ in range(rounds): + rw = R * omega[:, None] + if not (rw > t).any(): + break + bound = np.where(rw > t, R * t / np.sqrt(np.maximum(rw * rw - t * t, 1e-12)), np.inf) + step = np.minimum(bound, np.roll(bound, -1, axis=1)) * dth # between column i and i + 1 + lo, hi = R.copy(), R.copy() + for _ in range(2): # twice round, for the wrap + for i in range(N): + np.minimum(lo[:, i], lo[:, i - 1] + step[:, i - 1], out=lo[:, i]) + np.maximum(hi[:, i], hi[:, i - 1] - step[:, i - 1], out=hi[:, i]) + for i in range(N - 1, -1, -1): + np.minimum(lo[:, i], lo[:, (i + 1) % N] + step[:, i], out=lo[:, i]) + np.maximum(hi[:, i], hi[:, (i + 1) % N] - step[:, i], out=hi[:, i]) + R = 0.5 * (lo + hi) + return R + + +def blur_around(a, sigma_cols): + """Smooth a grid round the vase (each row on its own), sigma in columns.""" + N = a.shape[1] + k = np.fft.rfftfreq(N, d=1.0 / N) + gain = np.exp(-0.5 * (k * sigma_cols * TAU / N) ** 2) + return np.fft.irfft(np.fft.rfft(a, axis=1) * gain[None, :], n=N, axis=1) + + +def layer_top(z: float, layer: float, first: float | None = None) -> tuple[int, float]: + """The first layer that starts at height z or above: (its number from 1, the height of its top). + + The first layer may be thicker than the rest, as slicers often make it. + """ + first = layer if first is None else first + if z <= first * 0.5: + return 1, first + k = max(math.ceil((z - first) / layer - 0.5), 0) # whole layers above the first one, below z + return k + 2, round(first + (k + 1) * layer, 4) + + +def duration(seconds: float) -> str: + m = int(round(seconds / 60.0)) + return f"{m // 60} h {m % 60:02d} min" if m >= 60 else f"{max(m, 1)} min" + + +# ===================================================================== design +class VaseDesign: + """A vase built from its parameters: the solid to export, the shell to show, and its sums.""" + + def __init__(self, p: VaseParams, draft: bool = False): + self.errors: list[str] = [] + self.warnings: list[str] = [] + self.notes: list[str] = [] + self.asked = p.copy() # the settings as given: p below is as built, a box's shape and all + self.box_room = 0.0 + if p.box: # a tissue box cover: its shape comes from the box it fits + self.box_room = box_room(p) + p = box_shape(p) + if p.vase_mode == REAL_VASE and needs_normal(p): + p = p.copy() + p.vase_mode = FAKE_VASE + self.notes.append("Holes, a face, a tissue box cover, an open bottom and a wall part way round need a " + "normal print, with walls (spiral vase mode prints one closed line a layer): it is " + "printed normally.") + if p.closed_top and p.around < 360.0: + p = p.copy() + p.closed_top = False + self.notes.append("A closed top needs the wall all the way round: the top is left open.") + self.p = p + self.draft = draft + self.pot_rows = None + self.bridge = 0.0 + self.holes_open = False + self.pattern_open = False + self.bridge_allowed = 0.0 # how wide a hole's top may be bridged (mm): Voronoi ovals + self.hole_share = 0.0 + self.head_fit = 0.0 + self.ceiling = 0.0 + self.floor_hole = None # a tissue box cover's hole through its top, on the bed + self.tissue = None # ... its length and width (mm) + self.fn = None + self.closed = None + self.free = DRAWN in (self.p.shape1, self.p.shape2) # a drawn outline: built point by point + self.outer_rows = None + try: + if self._check(): + if self.free: + self._free_surface() + if self.errors: + return + self._free_printable() + inner = np.stack([sections.inset(row, self.wall) for row in self.P]) + self._pot(self.P, inner) + self.outer_rows = [(zz, np.arctan2(r[:, 1], r[:, 0]), np.hypot(r[:, 0], r[:, 1])) + for zz, r in zip(self.z, self.P)] + else: + self._grid_size() + self._surface() + self._printable() + if self.rows is None: + c, s = np.cos(self.theta), np.sin(self.theta) + Ri = inset(self.theta, self.R, self.wall) + self._pot(np.stack([self.R * c, self.R * s], -1), np.stack([Ri * c, Ri * s], -1)) + else: + self._shell() # low-poly: the facets' own shell + self._measure() + self._colours() + self._advice() + except ExprError as exc: + self.errors.append(f"Your own function: {exc}") + + # ------------------------------------------------------------ checks + def _check(self) -> bool: + p = self.p + if p.height < 5.0: + self.errors.append("The vase must be at least 5 mm tall.") + if len(p.silhouette) < 2 or min(d for _, d in p.silhouette) <= 0: + self.errors.append("The outline needs at least two points, every one wider than nothing.") + for key, label in (("line_width", "Line width"), ("layer_height", "Layer height"), + ("filament_d", "Filament diameter"), ("speed", "Print speed"), + ("max_flow", "Max flow"), ("density", "Filament density")): + if getattr(p, key) <= 0: + self.errors.append(f"{label} must be more than zero.") + if p.facet_size <= 0: + self.errors.append("Mesh detail must be more than zero.") + try: + self.fn = compile_expression(p.expression) + except ExprError as exc: + self.errors.append(f"Your own function: {exc}") + return not self.errors + + def _grid_size(self): + p = self.p + self.H = H = float(p.height) + sil = np.asarray(p.silhouette, dtype=float) + tex = ((abs(p.rib_depth) if p.rib_count > 0 else 0.0) + (abs(p.ring_depth) if p.ring_count > 0 else 0.0) + + (abs(p.bump_depth) if p.bump_around > 0 else 0.0) + 1.2 * max(p.organic_depth, 0.0)) + r_est = 0.5 * sil[:, 1].max() + tex + step = max(p.facet_size, 0.15) * (2.6 if self.draft else 1.0) + per = 10 if self.draft else 22 # points round each lobe or rib + lobes = [lobes_needed(p.shape1, p.count1)] + if p.shape2 != SAME: + lobes.append(lobes_needed(p.shape2, p.count2)) + if p.rib_count > 0: + lobes.append(p.rib_count * (2 if p.rib_cross else 1)) + if p.bump_around > 0: + lobes.append(p.bump_around) + N = max(math.ceil(TAU * r_est / step), per * max(lobes), 96) + M = max(math.ceil(H / step), (8 if self.draft else 16) * max(p.ring_count, 1), 24) + if p.bump_around > 0: + M = max(M, (8 if self.draft else 16) * p.bump_up) + if p.organic_depth > 0: # a few points across every lump + fine = max(p.organic_scale, 2.0) / (5 if self.draft else 10) + N = max(N, math.ceil(TAU * r_est / fine)) + M = max(M, math.ceil(H / fine)) + if self.fn is not None: + N = max(N, 180 if self.draft else 480) + limit = 140_000 if self.draft else 1_200_000 + if N * M > limit: + f = math.sqrt(limit / (N * M)) + N, M = max(int(N * f), 64), max(int(M * f), 16) + spacing = step / f + if not self.draft and spacing > 0.4: # finer than that, nobody would see it + self.notes.append(f"The surface detail was capped at about {spacing:.1f} mm to keep the mesh a " + "manageable size.") + self.N = (N + 3) // 4 * 4 + self.M = M + + # ----------------------------------------------------------- surface + def twist_at(self, h): + """How far the shape has turned at height h (radians).""" + p = self.p + if p.twist_style == OWN: + deg = progress(OWN, h, p.twist_curve) + else: + deg = p.twist * progress(p.twist_style, h, swings=p.swings) + return np.radians(deg) + + def _texture(self, frame, z, mean_r: float): + """The surface patterns in mm, out (+) or in, at angles ``frame`` (rows, columns) and heights z.""" + p = self.p + h = z / self.H + tex = np.zeros(np.broadcast(frame, z[:, None]).shape) + if p.rib_count > 0 and p.rib_depth != 0: + k = 1.0 + 7.0 * p.rib_sharp ** 2 + lean = math.radians(p.rib_lean) * h[:, None] + rib = (0.5 * (1.0 + np.cos(p.rib_count * (frame - lean)))) ** k + if p.rib_cross: + rib = np.maximum(rib, (0.5 * (1.0 + np.cos(p.rib_count * (frame + lean)))) ** k) + tex += p.rib_depth * rib + if p.ring_count > 0 and p.ring_depth != 0: + k = 1.0 + 7.0 * p.ring_sharp ** 2 + tex += p.ring_depth * ((0.5 * (1.0 - np.cos(TAU * p.ring_count * h))) ** k)[:, None] + if p.bump_around > 0 and p.bump_depth != 0: + tex += p.bump_depth * (0.5 * (1.0 + np.cos(p.bump_around * frame) + * np.cos(TAU * max(p.bump_up, 1) * h)[:, None])) ** 1.5 + if p.organic_depth > 0: + tex += p.organic_depth * organic(frame, z[:, None], p.organic_scale, mean_r, p.organic_seed) + return tex + + def _envelope(self, z): + """How strong the surface patterns are at each height: the strength curve, faded at foot and rim.""" + p = self.p + env = strength(p.strength, z / self.H, p.strength_curve) + if p.fade_foot > 0: + env = env * smoothstep(z / p.fade_foot) + if p.fade_rim > 0: + env = env * smoothstep((self.H - z) / p.fade_rim) + return env + + def _surface(self): + p, H, N, M = self.p, self.H, self.N, self.M + self.z = z = np.linspace(0.0, H, M + 1) + self.theta = theta = np.arange(N) * (TAU / N) + self.dz, self.dth = H / M, TAU / N + h = z / H + sil = np.asarray(p.silhouette, dtype=float) + R0, q = self._dome(z, 0.5 * pchip(sil[:, 0], sil[:, 1], h)) + self.tau = tau = self.twist_at(h) + TH, TA = theta[None, :], tau[:, None] + + tab1 = blur(shape_table(p.shape1, p.count1, p.depth1, p.sharp1), self.dth) + if p.shape2 != SAME: + tab2 = blur(shape_table(p.shape2, p.count2, p.depth2, p.sharp2), self.dth) + mix = change(p.morph, h, p.morph_curve, p.morph_swings)[:, None] + mix = np.clip(mix, 0.0, 1.0) + a1, a2 = math.radians(p.turn1), math.radians(p.turn2) + + def section(angle): + s = sample(tab1, angle - a1) + if p.shape2 != SAME: + s = (1.0 - mix) * s + mix * sample(tab2, angle - a2) + return s + + s = section(TH - TA) + if p.lattice: + # round the crossing ridges over a couple of mesh columns, so they don't alias + steep = float(np.abs(np.diff(tab1)).max()) * TABLE / TAU + s = smooth_max(s, section(TH + TA), 3.0 * self.dth * steep) + r = R0[:, None] * s + + frame = TH - TA if p.texture_twist else np.broadcast_to(TH, r.shape) + if p.even_spacing: + frame = self._round_the_wall(r, tau if p.texture_twist else np.zeros_like(tau)) + r = r + (q * self._envelope(z))[:, None] * self._texture(frame, z, float(R0.mean())) + if p.nose == RAISED_NOSE: + # along the surface from the front (-y: the column a quarter turn before the start) + front = r[:, 3 * N // 4] + s = (np.mod(TH - 1.5 * math.pi + math.pi, TAU) - math.pi) * front[:, None] + r = r + hollow.nose(s, z, p, 0.9 * math.tan(math.radians(p.max_overhang))) + + if self.fn is not None: + env_vars = dict(theta=np.broadcast_to(TH, r.shape), phi=frame, z=z[:, None], h=h[:, None], r=r, + H=np.float64(H)) + add = self.fn(**env_vars) + # does the function meet itself round the back? Try it a whole turn on. + wrap = self.fn(theta=np.full((M + 1, 1), TAU), phi=frame[:, :1] + TAU, z=z[:, None], + h=h[:, None], r=r[:, :1], H=np.float64(H)) + if float(np.abs(wrap[:, 0] - add[:, 0]).max()) > 0.05: + self.warnings.append( + "Your own function doesn't meet itself round the vase, so there is a step " + "up the back. Use whole numbers with the angle, e.g. sin(5*theta), not sin(2.5*theta).") + r = r + add + if p.closed_top: # nothing sticks out past the point, patterns and all + r = np.minimum(r, self._cone(z)[:, None]) + + floor_r = max(1.5, 2.0 * self.wall) + near = r < floor_r + if p.closed_top: # a closed top's point comes in to it, as it should + near &= (self._cone(z) > 2.0 * floor_r)[:, None] + if near.any(): + self.notes.append(f"Where the shape came near the middle it is kept {floor_r:.1f} mm out from it.") + self.R_design = np.maximum(r, floor_r) + + def _round_the_wall(self, r, start): + """How far round the wall each grid point is, as an angle - 2 pi all the way round - measured along the + wall from the direction ``start`` (each row's), rather than as the angle from the middle: patterns laid + out by it are as close together on a box's long sides as round its ends.""" + N = self.N + c, s = np.cos(self.theta), np.sin(self.theta) + x, y = r * c, r * s + step = np.hypot(np.roll(x, -1, axis=1) - x, np.roll(y, -1, axis=1) - y) + along = np.concatenate([np.zeros((len(r), 1)), np.cumsum(step, axis=1)], axis=1) # (rows, N + 1) + share = along / along[:, -1:] * TAU + f = np.mod(start, TAU) / self.dth # where each row starts, between two columns + i0 = np.floor(f).astype(np.intp) % N + t = f - np.floor(f) + rows = np.arange(len(r)) + first = share[rows, i0] * (1.0 - t) + share[rows, i0 + 1] * t + return share[:, :N] - first[:, None] + + def _cone(self, z): + """A closed top's point: the widest the vase may be at each height, leaning in at a little under the + overhang limit up to a point the width of the narrowest the wall may come to the axis.""" + tip = max(1.5, 2.0 * self.wall) + return tip + (self.H - z) * 0.92 * math.tan(math.radians(self.p.max_overhang)) + + def _dome(self, z, R0): + """The outline's radius at each height, closed over in a point if the top is closed; and how far each + height was narrowed (1: not at all), for the patterns to shrink with it.""" + if not self.p.closed_top: + return R0, np.ones_like(R0) + # the lesser of the outline and the point, rounded where they meet (never leaning more than either) + cone = self._cone(z) + k = 0.12 * float(R0.max()) + capped = np.minimum(R0, cone) - k * np.log1p(np.exp(-np.abs(R0 - cone) / k)) + q = capped / np.maximum(R0, 1e-9) + narrowed = np.nonzero(q < 0.999)[0] + self.dome_from = float(z[narrowed[0]]) if len(narrowed) else self.H + return capped, q + + # ----------------------------------------------------- printable solid + def _rows(self, R): + """The low-poly rings: (z, angles, radii) for each row of facets.""" + p, H = self.p, self.H + rng = np.random.default_rng(int(p.facet_seed)) + Nf, Mf = max(int(p.facets_around), 3), max(int(p.facets_up), 1) + rows = [] + for j in range(Mf + 1): + zj = H * j / Mf + a = TAU * np.arange(Nf) / Nf + (j % 2) * math.pi / Nf + float(self.twist_at(np.array([zj / H]))[0]) + if p.facet_rough > 0: + a = a + rng.uniform(-0.35, 0.35, Nf) * p.facet_rough * TAU / Nf + r = self._sample(R, zj, a) + if p.facet_rough > 0: + r = r * (1.0 + rng.uniform(-0.05, 0.05, Nf) * p.facet_rough) + rows.append((zj, a, r)) + return rows + + def _sample(self, R, zq, aq): + """The radius between the grid points (bilinear, round the back too).""" + fj = min(max(zq / self.dz, 0.0), self.M) + j0 = min(int(fj), self.M - 1) + tj = fj - j0 + fi = np.mod(aq, TAU) / self.dth + i0 = np.floor(fi).astype(np.int64) + ti = fi - i0 + i0 %= self.N + i1 = (i0 + 1) % self.N + lo = R[j0, i0] * (1 - ti) + R[j0, i1] * ti + hi = R[j0 + 1, i0] * (1 - ti) + R[j0 + 1, i1] * ti + return lo * (1 - tj) + hi * tj + + def _solid(self, R): + """The solid to export from a surface: (verts, faces, band of each wall face, wall face count, rows).""" + rows = self._rows(R) if self.p.facets else None + if rows is None: + verts, faces, band, first, last = grid_side(self.z, self.theta, R) + else: + verts, faces, band, first, last = loft_side(rows) + nv = len(verts) + verts = np.concatenate([verts, [[0.0, 0.0, 0.0], [0.0, 0.0, self.H]]]) + side = len(faces) + faces = np.concatenate([faces, fan(nv, first, up=False), fan(nv + 1, last, up=True)]) + return verts, faces, band, side, rows + + def _soften(self, target, scale): + """The design softened, with the correction smoothed round the vase and softened again. + + Each column is softened on its own, so on its own the correction can + differ a little from column to column and leave fine streaks; smoothing + it sideways and softening once more keeps the surface clean. + """ + base = self.R_base + R = soften(base, self.dth, self.dz, target, scale) + for _ in range(2): + fix = blur_around(R - base, 2.0) + R = soften(base + fix, self.dth, self.dz, target, scale) + return R + + def _printable(self): + p = self.p + target = p.max_overhang + scale = np.ones_like(self.R_design) # where the limit has to be tighter than it looks + R = self.R_design + self.R_base = self.R_design + if p.soften: # a fast twist first: gentler flanks, layer by layer + omega = np.abs(np.gradient(self.tau, self.dz)) + self.R_base = ease_twist(self.R_design, self.dth, omega, p.max_overhang) + for attempt in range(14 if p.soften else 1): + R = self._soften(target, scale) if p.soften else self.R_design + verts, faces, band, side, rows = self._solid(R) + lean = lean_angles(verts, faces[:side]) + worst = float(lean.max()) + if worst <= p.max_overhang + TOLERANCE or not p.soften: + break + if rows is None: + # tighten only round the faces that still lean too far + # by as much as each face is over, spread a little and feathered over a few columns + # and rows, so the tightening itself leaves no crease + over = lean > p.max_overhang + 0.5 * TOLERANCE + cut = 1.0 - math.tan(math.radians(p.max_overhang - 0.25)) / np.tan(np.radians(lean[over])) + cut = np.clip(cut + 0.03, 0.03, 0.6) + corner = faces[:side][over].ravel() + keep = corner < (self.M + 1) * self.N + j, i = np.divmod(corner[keep], self.N) + tight = np.zeros_like(scale) + np.maximum.at(tight, (j, i), np.repeat(cut, 3)[keep]) + for axis in (0, 1): + tight = np.maximum(tight, np.maximum(np.roll(tight, 1, axis), np.roll(tight, -1, axis))) + tight = blur_around(tight, 1.5) + scale *= 1.0 - np.clip(tight, 0.0, 0.6) + if attempt >= 6: # still not there: ask a little less everywhere too + target -= max(1.0, worst - p.max_overhang) + else: + # low-poly: the facets cut across the softened surface, so ask for less everywhere + target -= max(0.75, worst - p.max_overhang) + if target < 2.0: + break + self.R, self.rows = R, rows + self.verts, self.faces, self.side = verts, faces, side + self.band, self.lean = band, lean + self.worst = worst + self.softened = float(np.abs(R - self.R_design).max()) if p.soften else 0.0 + self.row_moved = np.abs(R - self.R_design).max(axis=1) if rows is None else None + if not self.draft: + self.closed = edge_check(faces)[0] + nb = int(band.max()) + 1 + self.band_lean = np.zeros(nb) + np.maximum.at(self.band_lean, band, lean) + self.triangles = len(faces) + lo, hi = verts.min(0), verts.max(0) + self.size = hi - lo + self.top = self.H + + # --------------------------------------------------------- shown shell + @property + def wall(self) -> float: + """How thick the printed wall is: one spiral line, or a fake vase's walls side by side.""" + p = self.p + return p.line_width * (max(int(p.walls), 1) if p.vase_mode == FAKE_VASE else 1) + + @property + def normal(self) -> bool: + """Printed normally, with walls - rather than seamless, in spiral vase mode.""" + return self.p.vase_mode == FAKE_VASE + + def _pot(self, O, I): + """What the printer makes: the hollow pot - the outer face O and inner face I (K, N, 2) at the grid's + heights, the floor or an open bottom, the rim - with any holes cut right through. For a normal print it + is what is exported; seamless, it is what the single line makes, for the 3D view.""" + p = self.p + zb = 0.0 if p.open_bottom else min(max(p.bottom, p.layer_height), 0.5 * self.H) + self.zb = zb + f = self._holes(O, zb) + if p.open_bottom: + zs, (Os, Is, fs), kb = self.z, (O, I, f), None + else: + zs, (Os, Is, fs), at = hollow.insert_rows(self.z, [O, I, f], [zb]) + kb = at[zb] + if p.box: # the ledges a lid clicks in behind + heights = ledge_heights(p) + zs, (Os, Is, fs), _ = hollow.insert_rows(zs, [Os, Is, fs], [self.H - h for h in heights]) + Is = ledges(zs, Is, self.H, heights) + b = None + if p.hole_swell > 0 and (fs > 0).any(): + # rounded edges: the wall swells out and in round every hole, curving from outside to in + far = max(6.0, 0.5 * p.hole_size) if p.holes != NO_HOLES else 6.0 + b = hollow.swell(fs, zs, p.hole_swell, far, 0.6 * math.tan(math.radians(p.max_overhang))) + kr = self._roof(zs, Os, kb) if p.closed_top else None + if p.box and p.tissue_hole != NO_TISSUE and kb is not None: + self.floor_hole = self._tissue_hole(Is[kb]) + if not self.draft: + # leave out the rows and columns that lie in line with their neighbours: far fewer triangles where the + # wall is flat or straight, every one kept where a pattern, a hole or a curve needs it + b = np.zeros_like(fs) if b is None else b + zs, Os, Is, fs, b, rk = hollow.simplify(zs, Os, Is, fs, b, [k for k in (kb, kr) if k is not None]) + kb = None if kb is None else int(np.searchsorted(rk, kb)) + kr = None if kr is None else int(np.searchsorted(rk, kr)) + verts, faces, kinds = hollow.pot(zs, Os, Is, fs, floor_row=kb, roof_row=kr, swell=b, + floor_hole=self.floor_hole) + kinds, self.bridge, side_worst = hollow.bridges(verts, faces, kinds, p.max_overhang, + longest=max(8.0, self.bridge_allowed + 1.0)) + self.pot_rows = (zs, Os, Is, fs, kb, kr, np.zeros_like(fs) if b is None else b) + lo, hi = (0 if kb is None else kb), (len(zs) - 1 if kr is None else kr) + if kr is not None: + self.ceiling = hollow.bridge_span(Is[kr]) + fs = np.where(np.arange(len(zs))[:, None] >= kr, np.minimum(fs, -1e-9), fs) + # the head it fits: round the inside, where the head goes in - the narrowest place below the + # widest; a mask, the widest place of the head it is shaped round + per = hollow.row_lengths(Is[lo:hi + 1])[2] + widest = int(np.argmax(per)) + fit = float(per.max() if p.around < 360.0 else per[:widest + 1].min()) + self.head_fit = max(fit - HEAD_ROOM, 0.0) + self.holes_open = bool((fs > 0).any()) + self.hole_share = float((fs > 0).mean()) if self.holes_open else 0.0 + self._set_shell(verts, faces, kinds) + self.worst = max(self.worst, side_worst) + if self.normal: # what is exported, and what the panel counts + self.triangles = len(faces) + self.size = verts.max(0) - verts.min(0) + if not self.draft: + self.closed = bool(self.closed) and edge_check(faces)[0] + self.inner_rows = [(zz, np.arctan2(r[:, 1], r[:, 0]), np.hypot(r[:, 0], r[:, 1])) + for zz, r in zip(zs[lo:hi + 1], Is[lo:hi + 1])] + + def _tissue_hole(self, inside): + """The hole in a tissue box cover's top for the tissues to come out of, as a loop round the middle, + anticlockwise - kept TISSUE_MARGIN clear of the walls inside. None if there is no room for it.""" + p = self.p + along_y = p.turn1 % 180.0 > 45.0 # the box's length runs along y + half = np.abs(np.asarray(inside, dtype=float)).max(axis=0) - TISSUE_MARGIN + long_room, short_room = (half[1], half[0]) if along_y else (half[0], half[1]) + w = min(0.5 * p.tissue_width, short_room) + length = w if p.tissue_hole == TISSUE_ROUND else max(min(0.5 * p.tissue_length, long_room), w) + if p.tissue_hole == TISSUE_ROUND: + w = length = min(w, long_room) + if w < 3.0: + self.notes.append("There is no room in the top for the tissue hole.") + return None + if w < 0.5 * p.tissue_width - 0.05 or (p.tissue_hole != TISSUE_ROUND and length < 0.5 * p.tissue_length - 0.05): + self.notes.append(f"The tissue hole was made smaller, to keep {TISSUE_MARGIN:g} mm of the top round it.") + step = max(p.facet_size, 0.3) * (2.6 if self.draft else 1.0) + if p.tissue_hole == TISSUE_OVAL: + n = max(int(2.0 * math.pi * max(length, w) / step), 48) + t = np.arange(n) * (TAU / n) + xs, ys = length * np.cos(t), w * np.sin(t) + else: # a slot: round ends; a circle + n = max(int((4.0 * length + 2.3 * w) / step), 48) + xs, ys = rounded_rectangle(length, w, w, per=n) + self.tissue = (2.0 * length, 2.0 * w) + return np.stack([-ys, xs] if along_y else [xs, ys], axis=1) + + def _roof(self, zs, Os, kb): + """The row a closed top's ceiling is on: as high as leaves the point above it as thick as the floor, + where the wall still has room inside it.""" + p = self.p + rmin = np.hypot(Os[..., 0], Os[..., 1]).min(axis=1) + top = self.H - max(p.bottom, 2.0 * p.layer_height) + ok = np.nonzero((rmin >= self.wall + 0.6) & (zs <= top + 1e-9) & (np.arange(len(zs)) > (kb or 0) + 1))[0] + if not len(ok): + self.notes.append("The vase is too small to close over at the top: it is left open.") + return None + return int(ok[-1]) + + def _set_shell(self, verts, faces, kinds=None): + """The shell to show, and how far each face leans: a hole's side only counts where it leans out over + the hole (its sill faces up, and prints as it is); the bridge at a hole's very top is as steep as allowed.""" + lean = lean_angles(verts, faces) + if kinds is not None: + over = np.degrees(np.arcsin(np.clip(-face_normals(verts, faces)[:, 2], 0.0, 1.0))) + lean = np.where(kinds == hollow.SIDE, over, lean) + lean = np.where(kinds == hollow.TIP, np.minimum(over, self.p.max_overhang), lean) + lean = np.where(kinds == hollow.CAP, -1.0, lean) + self.shell_verts, self.shell_faces, self.shell_kind, self.shell_lean = verts, faces, kinds, lean + + def _holes(self, O, zb): + """The hole pattern on the grid: how far inside a hole each point is (mm, below nought in the wall), + with pointed tops that print without support.""" + p = self.p + K, N = O.shape[:2] + f = np.full((K, N), -1.0) + if not self.normal: + return f + seg, along, L = hollow.row_lengths(O) + slope = 0.85 * math.tan(math.radians(p.max_overhang)) + face = None + if face_holes(p) or p.around < 360.0: + face = hollow.openings(hollow.from_front(O, seg, along, L), self.z, L, p) + if (face > 0).any(): + face = hollow.pointed_tops(face, self.z, O, slope) + f = face + bridge = None + if p.holes != NO_HOLES: + # a strut's width of wall round the face and along the edges + room = None if face is None else -face - max(p.hole_strut, 2.0) + pattern, spans = self._pattern_holes(O, zb, seg, along, L, room) + self.pattern_open = bool((pattern > 0).any()) + f = pattern if face is None else np.maximum(face, pattern) + if spans is not None: # Voronoi ovals: their round tops close over a short bridge + bridge = np.where(pattern > 0, spans, 0.0) + self.bridge_allowed = float(bridge.max()) + if not (f > 0).any(): + return f + return hollow.pointed_tops(f, self.z, O, slope, bridge) + + def _pattern_holes(self, O, zb, seg, along, L, room=None): + """The pattern of holes, Voronoi, round, diamonds or slots, as a hole field - only where room is above + nought, if given.""" + p = self.p + K, N = O.shape[:2] + # the holes turn with the twist - but no faster than a wall may lean, so their sides stay printable + turn = np.zeros(K) + if p.texture_twist: + r = np.maximum(L / TAU, 1.0) + turn = limit_slope(self.tau.astype(float), 0.6 * math.tan(math.radians(p.max_overhang)) + * np.diff(self.z) / np.minimum(r[1:], r[:-1])) + # measured round each row from the point at that angle + ang = np.arctan2(O[..., 1], O[..., 0]) + c0 = np.argmin(np.abs(np.mod(ang - turn[:, None] + math.pi, TAU) - math.pi), axis=1) + start = along[np.arange(K), c0] + t = np.mod((along - start[:, None]) / L[:, None], 1.0) + z_lo = zb + max(p.hole_foot, 0.0) + z_hi = (self.dome_from if p.closed_top else self.H) - max(p.hole_rim, LEDGE_CLEAR if p.box else 0.0) + f, spans = hollow.hole_field(p.holes, t, self.z, L, p.hole_size, p.hole_strut, p.hole_jitter, p.hole_seed, + z_lo, z_hi, room, 0.85 * math.tan(math.radians(p.max_overhang)), + min(VORONOI_BRIDGE * p.hole_size, VORONOI_LONGEST)) + if not (f > 0).any(): + self.notes.append("There is no room for the holes between the plain foot and rim: make the vase " + "taller, the holes smaller, or the plain foot and rim less.") + return f, spans + + def print_mesh(self): + """The vase as one closed mesh for the slicer: the hollow pot for a normal print, the solid for + spiral vase mode.""" + if self.normal: + return self.shell_verts, self.shell_faces + return self.verts, self.faces + + def export_mesh(self): + """The mesh to save for the slicer: print_mesh, simplified (see ``lighter``).""" + return lighter(*self.print_mesh()) + + def export_parts(self): + """The parts to save for the slicer: print_parts, each simplified (see ``lighter``).""" + return [(fil, *lighter(v, f)) for fil, v, f in self.print_parts()] + + def print_parts(self): + """The vase for the slicer as [(filament, verts, faces)]: printed normally, the hollow pot, a part for + each band of colour; seamless, the solid.""" + if not self.normal: + return self.band_parts() + if self.pot_rows is None: + return self._loft_parts() + zs, Os, Is, fs, kb, kr, swollen = self.pot_rows + floor = kb is not None + # bands start on top of the floor, and the solid point of a closed top is one with the band below it + lowest = (zs[kb] if floor else 0.0) + 0.5 * self.p.layer_height + highest = self.H if kr is None else zs[kr] - 0.5 * self.p.layer_height + tops = sorted({max(z, lowest) for z, _ in self.bands[1:] if z < min(self.H - 1e-6, highest)}) + z2, (O2, I2, f2, s2), at = hollow.insert_rows(zs, [Os, Is, fs, swollen], tops) + kb2 = int(np.argmin(np.abs(z2 - zs[kb]))) if floor else None + kr2 = None if kr is None else int(np.argmin(np.abs(z2 - zs[kr]))) + bounds = [0] + [at[t] for t in tops] + [len(z2) - 1] + fils = [fil for _, fil in self.bands][:len(bounds) - 1] + parts = [] + for fil, a, b in zip(fils, bounds[:-1], bounds[1:]): + rows = slice(a, b + 1) + verts, faces, _ = hollow.pot(z2[rows], O2[rows], I2[rows], f2[rows], + floor_row=kb2 if (floor and a == 0) else None, + roof_row=kr2 - a if (kr2 is not None and b == len(z2) - 1) else None, + swell=s2[rows], floor_hole=self.floor_hole if a == 0 else None) + parts.append((fil, verts, faces)) + return parts + + def _loft_parts(self): + """Low-poly, printed normally: the facets' hollow shell, a part for each band of colour.""" + w, zb = self.wall, self.zb + tops = [zs for zs, _ in self.bands[1:]] + [self.H] + parts = [] + for (lo, fil), hi in zip(self.bands, tops): + lo = max(lo, zb + 0.5 * self.p.layer_height) if lo > 0 else 0.0 + rings = [self._ring_at(lo)] + [r for r in self.rows if lo + 1e-6 < r[0] < hi - 1e-6] + [self._ring_at(hi)] + if lo == 0.0: + inner = [self._ring_at(zb)] + [r for r in rings if r[0] > zb + 1e-6] + else: + inner = rings + inner = [(zz, a, inset(a, r, w)) for zz, a, r in inner] + o_v, o_f, _, o_first, o_last = loft_side(rings) + i_v, i_f, _, i_first, i_last = loft_side(inner) + no, ni = len(o_v), len(i_v) + verts = np.concatenate([o_v, i_v, [[0.0, 0.0, 0.0], [0.0, 0.0, zb]]]) + faces = [o_f, i_f[:, ::-1] + no, annulus(o_last, i_last + no)] + if lo == 0.0: + faces += [fan(no + ni, o_first, up=False), fan(no + ni + 1, i_first + no, up=True)] + else: + faces.append(annulus(o_first, i_first + no)[:, ::-1]) + parts.append((fil, verts, np.concatenate(faces))) + return parts + + def _shell(self): + """What the printer really makes: the wall over a solid bottom, for the 3D view.""" + p = self.p + if needs_normal(p) or p.closed_top: + self.notes.append("Holes, a face, open ends and a closed top work on smooth shapes, not low-poly " + "facets.") + w = self.wall + zb = min(max(p.bottom, p.layer_height), 0.5 * self.H) + self.zb = zb + if self.rows is None: + z, R = self.z, self.R + keep = z > zb + 0.3 * self.dz + fj = zb / self.dz + j0 = min(int(fj), self.M - 1) + first = R[j0] + (R[j0 + 1] - R[j0]) * (fj - j0) + zi = np.concatenate([[zb], z[keep]]) + Ri = inset(self.theta, np.vstack([first, R[keep]]), w) + o_verts, o_faces, _, o_first, o_last = grid_side(z, self.theta, R) + i_verts, i_faces, _, i_first, i_last = grid_side(zi, self.theta, Ri) + self.inner_rows = [(zz, self.theta, rr) for zz, rr in zip(zi, Ri)] + else: + rows = self.rows + inner = [(zb, rows[0][1], inset(rows[0][1], rows[0][2], w))] + inner += [(zz, a, inset(a, r, w)) for zz, a, r in rows[1:] if zz > zb + 0.3] + o_verts, o_faces, _, o_first, o_last = loft_side(rows) + i_verts, i_faces, _, i_first, i_last = loft_side(inner) + self.inner_rows = inner + no, ni = len(o_verts), len(i_verts) + verts = np.concatenate([o_verts, i_verts, [[0.0, 0.0, 0.0], [0.0, 0.0, zb]]]) + cb, cf = no + ni, no + ni + 1 + walls = np.concatenate([o_faces, i_faces[:, ::-1] + no]) + caps = np.concatenate([fan(cb, o_first, up=False), fan(cf, i_first + no, up=True), + annulus(o_last, i_last + no)]) + self.shell_verts = verts + self.shell_faces = np.concatenate([walls, caps]) + self.shell_kind = None + self.shell_lean = np.concatenate([lean_angles(verts, walls), np.full(len(caps), -1.0)]) + if self.normal: # what is exported, and what the panel counts + self.triangles = len(self.shell_faces) + + # ======================================================= drawn outlines + def _outline(self, k: int): + """Cross-section k (1 at the bottom, 2 at the top) as a loop of points, widest at radius 1.""" + p = self.p + kind = getattr(p, f"shape{k}") + if kind == DRAWN: + pts = getattr(p, f"drawn{k}") + loop = sections.spline(pts, getattr(p, f"repeat{k}")) + hits = sections.piece_crossings(loop, 48 * len(pts)) + if hits: + x, y = hits[0] + where = "bottom" if k == 1 else "top" + self.errors.append(f"The drawn {where} outline crosses itself (near {x:.2f}, {y:.2f} in the " + "drawing): move its points apart so it makes one simple loop.") + else: + loop = sections.from_table(shape_table(kind, getattr(p, f"count{k}"), getattr(p, f"depth{k}"), + getattr(p, f"sharp{k}"))) + loop = sections.normalised(loop) + a = math.radians(getattr(p, f"turn{k}")) + c, s = math.cos(a), math.sin(a) + return loop @ np.array([[c, s], [-s, c]]) + + def _free_surface(self): + """The designed surface of a vase with a drawn outline: rows of points (M + 1, N, 2).""" + p = self.p + self.H = H = float(p.height) + loops = [self._outline(1)] + ([self._outline(2)] if p.shape2 != SAME else []) + if self.errors: + return + sil = np.asarray(p.silhouette, dtype=float) + tex = ((abs(p.rib_depth) if p.rib_count > 0 else 0.0) + (abs(p.ring_depth) if p.ring_count > 0 else 0.0) + + (abs(p.bump_depth) if p.bump_around > 0 else 0.0) + 1.2 * max(p.organic_depth, 0.0)) + self.tex_depth = tex + perimeter = max(float(np.linalg.norm(np.roll(L, -1, axis=0) - L, axis=1).sum()) for L in loops) + step = max(p.facet_size, 0.15) * (2.6 if self.draft else 1.0) + per = 10 if self.draft else 22 + reps = max([getattr(p, f"repeat{k}") for k in (1, 2) if getattr(p, f"shape{k}") == DRAWN] + [1]) + N = max(math.ceil(perimeter * (0.5 * sil[:, 1].max() + tex) / step), + per * max(p.rib_count * (2 if p.rib_cross else 1), p.bump_around, reps), 160) + M = max(math.ceil(H / step), (8 if self.draft else 16) * max(p.ring_count, 1), 24) + if p.bump_around > 0: + M = max(M, (8 if self.draft else 16) * p.bump_up) + if p.organic_depth > 0: + fine = max(p.organic_scale, 2.0) / (5 if self.draft else 10) + N = max(N, math.ceil(perimeter * 0.5 * sil[:, 1].max() / fine)) + M = max(M, math.ceil(H / fine)) + if self.fn is not None: + N = max(N, 180 if self.draft else 480) + limit = 140_000 if self.draft else 1_200_000 + if N * M > limit: + f = math.sqrt(limit / (N * M)) + N, M = max(int(N * f), 64, 8 * reps), max(int(M * f), 16) # each repeat keeps a few points + self.N, self.M = (N + 3) // 4 * 4, M + self.z = z = np.linspace(0.0, H, M + 1) + self.dz = H / M + h = z / H + self.C1 = sections.resample(loops[0], self.N) + self.C2 = sections.resample(loops[1], self.N) if len(loops) > 1 else None + self.R0_d, q = self._dome(z, 0.5 * pchip(sil[:, 0], sil[:, 1], h)) + self.tau = self.tau_d = self.twist_at(h) + self.mix_d = (np.clip(change(p.morph, h, p.morph_curve, p.morph_swings), 0.0, 1.0) if self.C2 is not None + else np.zeros_like(h)) + self.env_d = self._envelope(z) * q # the patterns shrink with a closed top's point + if p.lattice and (p.twist_style == OWN or abs(p.twist) > 1e-9): + self.notes.append("Both ways (lattice) works on the built-in shapes only: a drawn outline twists one way.") + if p.facets: + self.notes.append("Low-poly facets work on the built-in shapes only: a drawn outline stays smooth.") + self.P_design = self._free_points(self.R0_d, self.tau_d, self.mix_d, self.env_d) + # a blend of two outlines, or deep patterns in a tight corner, can make a layer cross itself + for j in np.linspace(0, M, 7).astype(int): + if sections.crossings(self.P_design[j], limit=max(700, 12 * reps)): + self.warnings.append(f"The outline crosses itself at about {z[j]:.0f} mm up (where the shapes " + "blend, or patterns meet in a tight corner): the slicer may fill it wrongly " + "there. Ease the change of shape or the patterns.") + break + + def _free_points(self, R0, tau, mix, env): + """The surface's points: the outline blended, sized, turned, then patterned square to the wall.""" + p = self.p + base = (self.C1[None] if self.C2 is None + else (1.0 - mix)[:, None, None] * self.C1 + mix[:, None, None] * self.C2) + P = base * R0[:, None, None] + c, s = np.cos(tau)[:, None], np.sin(tau)[:, None] + x, y = P[..., 0], P[..., 1] + P = np.stack([x * c - y * s, x * s + y * c], -1) + # patterns run round the outline (turning with it), or round the axis if they shouldn't turn + along = np.arange(self.N) * (TAU / self.N) + frame = (np.broadcast_to(along, P.shape[:2]) if p.texture_twist + else np.mod(np.arctan2(P[..., 1], P[..., 0]), TAU)) + out = sections.normals(P) + tex = env[:, None] * self._texture(frame, self.z, float(R0.mean())) + if self.fn is not None: + tex = tex + self.fn(theta=np.mod(np.arctan2(P[..., 1], P[..., 0]), TAU), phi=frame, + z=self.z[:, None], h=(self.z / self.H)[:, None], r=np.hypot(P[..., 0], P[..., 1]), + H=np.float64(self.H)) + if p.nose == RAISED_NOSE: + s = (np.mod(np.arctan2(P[..., 1], P[..., 0]) + 0.5 * math.pi + math.pi, TAU) - math.pi) * R0[:, None] + tex = tex + hollow.nose(s, self.z, p, 0.9 * math.tan(math.radians(p.max_overhang))) + return P + tex[..., None] * out + + def _free_solid(self, P): + """The closed solid from rows of outline points: (verts, faces, band of each wall face, wall face count).""" + verts, faces, band, first, last = grid_side_xy(self.z, P) + side = len(faces) + bottom = first[sections.triangulate(P[0])][:, ::-1] # facing down, onto the bed + top = last[sections.triangulate(P[-1])] + return verts, np.concatenate([faces, bottom, top]), band, side + + def _free_printable(self): + """Soften a drawn vase until no face leans past the limit. + + A drawn outline itself stands upright; only the changes up the vase + lean it - the outline widening or narrowing, the twist, the change of + shape and the patterns. Where faces lean too far, those changes are + slowed at that height (the same envelopes as for the columns of a + radial vase) and the patterns eased, until every face is within it. + """ + p = self.p + R0, tau, mix, env = self.R0_d.copy(), self.tau_d.copy(), self.mix_d.copy(), self.env_d.copy() + aim = math.tan(math.radians(max(p.max_overhang - 0.4, 1.0))) + # patterns fade in and out over a few layers: a sudden change of depth would lean the wall itself + fade = 0.1 * aim * self.dz / max(self.tex_depth, 1e-6) + P = self.P_design + for attempt in range(24 if p.soften else 1): + if attempt: + P = self._free_points(R0, tau, mix, env) + verts, faces, band, side = self._free_solid(P) + lean = lean_angles(verts, faces[:side]) + worst = float(lean.max()) + if worst <= p.max_overhang + TOLERANCE or not p.soften: + break + over = np.zeros(self.M) + np.maximum.at(over, band, lean) + f = np.where(over > p.max_overhang, aim / np.tan(np.radians(np.minimum(over, 89.0))), 1.0) + f = np.clip(f, 0.1, 1.0) + pad = np.concatenate([[f[0]] * 2, f, [f[-1]] * 2]) + f = np.minimum.reduce([pad[k:k + self.M] for k in range(5)]) # a little room each side + R0 = limit_slope(R0, np.abs(np.diff(R0)) * f) + tau = limit_slope(tau, np.abs(np.diff(tau)) * f) + mix = limit_slope(mix, np.abs(np.diff(mix)) * f) + env = fall_gently(env * np.minimum(np.concatenate([[f[0]], f]), np.concatenate([f, [f[-1]]])), fade) + self.P, self.tau, self.rows = P, tau, None + # what softening changed, in the design's own terms + with np.errstate(divide="ignore", invalid="ignore"): + kept = np.where(self.env_d > 1e-6, env / self.env_d, 1.0) + self.eased = dict(twist=float(np.degrees(np.abs(tau - self.tau_d)).max()), + width=float(2.0 * np.abs(R0 - self.R0_d).max()), + change=float(np.abs(mix - self.mix_d).max()), patterns=float(np.min(kept))) + self.verts, self.faces, self.side = verts, faces, side + self.band, self.lean, self.worst = band, lean, worst + moved = np.linalg.norm(P - self.P_design, axis=-1) + self.row_moved = moved.max(axis=1) + self.softened = float(moved.max()) if p.soften else 0.0 + if not self.draft: + self.closed = edge_check(faces)[0] + self.band_lean = np.zeros(self.M) + np.maximum.at(self.band_lean, band, lean) + self.triangles = len(faces) + self.size = verts.max(0) - verts.min(0) + self.top = self.H + + def _P_at(self, zq): + """The outline's points at height zq, between the rows.""" + fj = min(max(zq / self.dz, 0.0), self.M) + j = min(int(fj), self.M - 1) + return self.P[j] + (fj - j) * (self.P[j + 1] - self.P[j]) + + # ---------------------------------------------------------- the sums + def _ring_stats(self, rows): + """Perimeter and enclosed area of each ring.""" + per, area = [], [] + for _, a, r in rows: + x, y = r * np.cos(a), r * np.sin(a) + x1, y1 = np.roll(x, -1), np.roll(y, -1) + per.append(float(np.hypot(x1 - x, y1 - y).sum())) + area.append(float(0.5 * (x * y1 - x1 * y).sum())) + return np.array(per), np.array(area) + + def _measure(self): + p = self.p + w, lh = self.wall, p.layer_height + loops = max(int(p.walls), 1) if p.vase_mode == FAKE_VASE else 1 # lines round each layer + if self.outer_rows is not None: + outer = self.outer_rows + elif self.rows is not None: + outer = self.rows + else: + outer = [(zz, self.theta, rr) for zz, rr in zip(self.z, self.R)] + oz = np.array([o[0] for o in outer]) + o_per, o_area = self._ring_stats(outer) + iz = np.array([i[0] for i in self.inner_rows]) + _, i_area = self._ring_stats(self.inner_rows) + zb = self.zb + above = oz >= zb + zs = np.concatenate([[zb], oz[above]]) + ps = np.concatenate([[np.interp(zb, oz, o_per)], o_per[above]]) + wall_area = float(np.sum(0.5 * (ps[1:] + ps[:-1]) * np.diff(zs))) # mm² of wall, unrolled + bottom_vol = float(np.interp(0.0, oz, o_area)) * zb + self.capacity = float(np.sum(0.5 * (i_area[1:] + i_area[:-1]) * np.diff(iz))) / 1000.0 # ml + if self.holes_open or p.open_bottom or p.closed_top or p.box: + self.capacity = 0.0 + fil_area = math.pi * (p.filament_d / 2.0) ** 2 + vol = mesh_volume(self.shell_verts, self.shell_faces) # the plastic, holes and all + wall_area *= 1.0 - self.hole_share + self.grams = vol / 1000.0 * p.density + self.metres = vol / fil_area / 1000.0 + v = min(p.speed, p.max_flow / max(p.line_width * lh, 1e-6)) + layers_bottom = 0 if p.open_bottom else max(1, math.ceil(zb / lh - 1e-6)) + path = wall_area * loops / lh + layers_bottom * float(np.interp(0.0, oz, o_area)) / p.line_width + self.seconds = path / max(v, 0.1) + 120.0 + self.layers = int(round(self.H / lh)) + self.bottom_layers = layers_bottom + # filament used up to each height, for a gradient spool's colours + cum = np.concatenate([[0.0], np.cumsum(0.5 * (o_per[1:] + o_per[:-1]) * np.diff(oz))]) * w + cum += np.minimum(oz, zb) * float(np.interp(0.0, oz, o_area)) + self.filament_z, self.filament_at = oz, cum / fil_area # mm of filament + self.solid_volume = mesh_volume(self.verts, self.faces) / 1000.0 # cm³ + r0, rt = outer[0][2], self.inner_rows[-1][2] + self.base_d = (2 * float(r0.min()), 2 * float(r0.max())) + self.top_d = (2 * float(rt.min()), 2 * float(rt.max())) + self.widest = 2 * float(max(o[2].max() for o in outer)) + # for the profile picture: the widest and narrowest radius at each height + self.profile_z = oz + self.profile_max = np.array([o[2].max() for o in outer]) + self.profile_min = np.array([o[2].min() for o in outer]) + + # ----------------------------------------------------------- colours + def _colours(self): + """The filament for each height: [(from z, filament)], snapped to layers.""" + p = self.p + lh, H = p.layer_height, self.H + used = max(1, min(int(p.colours_used), 4)) + bands = [(0.0, 1)] + if colour_style_of(p) == BANDS: + k = max(int(p.band_count), 1) + gamma = 2.0 ** (1.3 * p.band_spacing) + bands = [(H * (i / k) ** gamma, 1 + i % used) for i in range(k)] + elif colour_style_of(p) == STRIPES: + stripe = max(p.stripe_height, lh) + n = math.ceil(H / stripe - 1e-9) + if n > MAX_BANDS: + stripe = H / MAX_BANDS + n = MAX_BANDS + self.warnings.append(f"Stripes that thin would be more than {MAX_BANDS} colour changes: " + f"they are made {stripe:.1f} mm tall.") + bands = [(i * stripe, 1 + i % used) for i in range(n)] + snapped = [] + for i, (zs, fil) in enumerate(bands): + zs = 0.0 if i == 0 else round(zs / lh) * lh + if zs >= H - 0.5 * lh: # starts at the very top: never printed + continue + if snapped and zs <= snapped[-1][0] + 0.5 * lh: + snapped[-1] = (snapped[-1][0], fil) # the band before has no layers: this one takes over + else: + snapped.append((zs, fil)) + merged = [] + for zs, fil in snapped: + if not merged or merged[-1][1] != fil: + merged.append((zs, fil)) + self.bands = merged or [(0.0, 1)] + self.gradient = colour_style_of(p) == GRADIENT + self.gradient_fils = list(range(1, used + 1)) + + def filament_of(self, k: int) -> str: + return getattr(self.p, f"color{k}").upper() + + def band_parts(self): + """The vase as one closed solid per colour band: [(filament, verts, faces)], bottom to top. + + Neighbouring bands meet on a shared flat face at the height where the + colour changes, so together they fill the vase exactly once - the way + a slicer wants the parts of a multi-colour object. + """ + tops = [zs for zs, _ in self.bands[1:]] + [self.H] + parts = [] + for (lo, fil), hi in zip(self.bands, tops): + if self.free: + inside = (self.z > lo + 1e-6) & (self.z < hi - 1e-6) + zs = np.concatenate([[lo], self.z[inside], [hi]]) + Ps = np.concatenate([self._P_at(lo)[None], self.P[inside], self._P_at(hi)[None]]) + verts, faces, _, first, last = grid_side_xy(zs, Ps) + faces = np.concatenate([faces, first[sections.triangulate(Ps[0])][:, ::-1], + last[sections.triangulate(Ps[-1])]]) + parts.append((fil, verts, faces)) + continue + if self.rows is None: + inside = (self.z > lo + 1e-6) & (self.z < hi - 1e-6) + zs = np.concatenate([[lo], self.z[inside], [hi]]) + Rs = np.vstack([self._row_at(lo), self.R[inside], self._row_at(hi)]) + verts, faces, _, first, last = grid_side(zs, self.theta, Rs) + else: + rings = [self._ring_at(lo)] + [r for r in self.rows if lo + 1e-6 < r[0] < hi - 1e-6] + [self._ring_at(hi)] + verts, faces, _, first, last = loft_side(rings) + n = len(verts) + verts = np.concatenate([verts, [[0.0, 0.0, lo], [0.0, 0.0, hi]]]) + faces = np.concatenate([faces, fan(n, first, up=False), fan(n + 1, last, up=True)]) + parts.append((fil, verts, faces)) + return parts + + def _row_at(self, zq): + """The surface's radii at height zq, between the grid's rows (straight up each column).""" + fj = min(max(zq / self.dz, 0.0), self.M) + j = min(int(fj), self.M - 1) + return self.R[j] + (fj - j) * (self.R[j + 1] - self.R[j]) + + def _ring_at(self, zq): + """The low-poly wall's cross-section at height zq, as a ring (z, angles, radii).""" + for row in self.rows: + if abs(row[0] - zq) < 1e-6: + return row + j = max(k for k, row in enumerate(self.rows) if row[0] < zq) + verts, faces, *_ = loft_side(self.rows[j:j + 2]) + tri = verts[faces] + d = tri[:, :, 2] - zq + pts = [] + for a, b in ((0, 1), (1, 2), (2, 0)): + hit = d[:, a] * d[:, b] < 0 + t = d[hit, a] / (d[hit, a] - d[hit, b]) + pts.append(tri[hit, a] + (tri[hit, b] - tri[hit, a]) * t[:, None]) + pts = np.unique(np.round(np.concatenate(pts)[:, :2], 6), axis=0) + ang = np.mod(np.arctan2(pts[:, 1], pts[:, 0]), TAU) + order = np.argsort(ang) + ang, rad = ang[order], np.hypot(pts[order, 0], pts[order, 1]) + keep = np.concatenate([[True], np.diff(ang) > 1e-7]) + return (zq, ang[keep], rad[keep]) + + def colour_changes(self, first_layer: float | None = None): + """[(layer number, height of that layer's top, filament)] for each change after the first. + + first_layer: the slicer's first layer height, if it differs from the rest. + """ + lh = self.p.layer_height + return [(*layer_top(zs, lh, first_layer), fil) for zs, fil in self.bands[1:]] + + def colour_phase(self, z): + """Colour cycles of a gradient spool used by each height.""" + length = max(self.p.gradient_length, 0.05) * 1000.0 + return np.interp(z, self.filament_z, self.filament_at) / length + + def colour_plan(self, first_layer: float | None = None) -> str: + p = self.p + lines = [f"Vaseinator: {p.name} - {'a normal print' if self.normal else 'spiral vase mode'}, " + f"{p.layer_height:g} mm layers"] + if self.gradient: + lines.append(f"Print with one gradient spool (one colour cycle every {p.gradient_length:g} m).") + return "\n".join(lines) + lines.append(f"Start with filament {self.bands[0][1]} ({self.filament_of(self.bands[0][1])})") + for layer, ztop, fil in self.colour_changes(first_layer): + lines.append(f"Layer {layer} ({ztop:.2f} mm): change to filament {fil} ({self.filament_of(fil)})") + return "\n".join(lines) + + # ------------------------------------------------------------ advice + def _advice(self): + p = self.p + if self.worst > p.max_overhang + TOLERANCE: + if p.soften: + self.warnings.append( + f"Some of the wall still leans {self.worst:.0f}° (the limit is {p.max_overhang:g}°). " + "With low-poly facets, more facets round the vase or fewer up it lean less.") + else: + self.warnings.append( + f"The wall leans up to {self.worst:.0f}°, over the {p.max_overhang:g}° limit: it would " + "sag or fail there (red in the Overhangs view). Turn on Soften to fit, or make the " + "shape gentler.") + elif self.softened > 0.05 and self.free: + e = self.eased + what = [f"the twist by up to {e['twist']:.0f}°" if e["twist"] > 0.5 else "", + f"the outline's width by up to {e['width']:.1f} mm" if e["width"] > 0.05 else "", + f"the change of shape by up to {100 * e['change']:.0f}%" if e["change"] > 0.005 else "", + f"the patterns to {100 * e['patterns']:.0f}% at the steepest place" if e["patterns"] < 0.995 else ""] + what = [w for w in what if w] + self.notes.append("Overhangs softened, so no part of the wall leans more than " + f"{p.max_overhang:g}°: " + (", ".join(what) if what else "slightly") + ".") + elif self.softened > 0.05: + self.notes.append(f"Overhangs softened: the surface was moved by up to {self.softened:.1f} mm so " + f"no part of the wall leans more than {p.max_overhang:g}°.") + if self.H > p.printer_height: + self.warnings.append(f"The vase is {self.H:.0f} mm tall: taller than the printer ({p.printer_height:.0f} mm).") + sx, sy = self.size[0], self.size[1] + if sx > p.bed_x or sy > p.bed_y: + self.warnings.append(f"It is {sx:.0f} × {sy:.0f} mm across: bigger than the bed " + f"({p.bed_x:.0f} × {p.bed_y:.0f} mm).") + if self.H > 3.2 * self.base_d[1] and self.H > 60: + self.warnings.append(f"The base is narrow for the height (Ø {self.base_d[1]:.0f} mm under a " + f"{self.H:.0f} mm vase): it may topple, on the bed or with flowers in. " + "A brim helps it print.") + if self.top_d[0] < 8.0 and not p.closed_top and not p.box: + self.notes.append(f"The opening at the top is only {self.top_d[0]:.0f} mm across: a bud vase.") + changes = len(self.bands) - 1 + if changes > 20: + self.warnings.append(f"{changes} colour changes: each one takes time and leaves a small seam. " + "Fewer, taller bands print cleaner.") + if self.pattern_open and p.hole_strut < 2.0 * p.line_width: + self.warnings.append(f"The wall between the holes ({p.hole_strut:g} mm) is thinner than two lines " + f"({2 * p.line_width:g} mm): it may print thin or break.") + if p.line_width < 0.45: + self.notes.append("A wider line (0.6 to 0.8 mm on a 0.4 mm nozzle) makes a stronger, more surely " + "watertight wall.") + if self.closed is False: # pragma: no cover - the construction should never allow it + self.warnings.append("The mesh is not closed: please report this design.") + + # -------------------------------------------------------------- view + def info(self) -> dict: + p = self.p + lean = f"{self.worst:.1f}° from upright (limit {p.max_overhang:g}°)" + lean += " ✓" if self.worst <= p.max_overhang + TOLERANCE else " ✗" + mesh = f"{self.triangles:,} triangles" + if self.closed: + mesh += ", closed and watertight ✓" + d = { + "Height": f"{self.H:.1f} mm, {self.layers} layers of {p.layer_height:g} mm", + "Widest": f"Ø {self.widest:.1f} mm", + "Footprint": f"{self.size[0]:.1f} × {self.size[1]:.1f} mm", + "Base": f"Ø {self.base_d[0]:.0f}–{self.base_d[1]:.0f} mm", + **({"Top": ("closed in a point" + (f", over a ceiling {self.ceiling:.0f} mm across" if self.ceiling else "")) + if self.normal else "comes in to a point (spiral vase mode prints no top: a hole a few mm " + "across stays at the very top)"} + if p.closed_top else {"Opening at the top": f"Ø {self.top_d[0]:.0f}–{self.top_d[1]:.0f} mm"}), + **({"Goes round": f"{p.around:g}° of the way round"} if p.around < 360.0 else {}), + **({"Fits a head": f"up to {self.head_fit / 10.0:.1f} cm round (round the forehead, above the ears)"} + if p.worn and self.head_fit > 0 else {}), + **({"Fits over": f"a box of tissues {p.box_length / 10.0:g} × {p.box_width / 10.0:g} × " + f"{p.box_height / 10.0:g} cm, with {self.box_room:.1f} mm to spare each side and " + f"{BOX_TOP_ROOM:g} mm over it", + "Tissues come out of": (f"{TISSUE_WORDS[p.tissue_hole]} {self.tissue[0]:.0f}" + + ("" if p.tissue_hole == TISSUE_ROUND else f" × {self.tissue[1]:.0f}") + + " mm, through its bottom layers") if self.tissue else "nowhere: no hole", + "Printed": "upside down, as it is shown - the hole on the bed, the open top for the box to go " + "in: nothing needs supports. Turn it over to put it on."} if p.box else {}), + "Holds": ("nothing: it goes only part way round" if p.around < 360.0 else + "nothing: it is open at the bottom" if p.open_bottom else + "nothing: it has holes" if self.holes_open else + "nothing: it is closed at the top" if p.closed_top else + f"about {self.capacity:.0f} ml of water"), + "Wall": (f"one {p.line_width:g} mm spiral line" if p.vase_mode != FAKE_VASE else + f"{max(int(p.walls), 1)} walls of {p.line_width:g} mm (about {self.wall:.1f} mm)") + + (", open at the bottom" if p.open_bottom else + f", on a {self.zb:g} mm solid bottom ({self.bottom_layers} layers)"), + **({"Holes": self._hole_words()} if self.pattern_open else {}), + **({"Face": self._face_words()} if self._face_words() else {}), + "Steepest overhang": lean, + "Filament": f"{self.grams:.0f} g, {self.metres:.1f} m", + "Print time": f"about {duration(self.seconds)}", + "Mesh": mesh, + } + if p.around < 360.0: # widths round the whole oval it is cut from would mislead + for key in ("Widest", "Base", "Opening at the top"): + d.pop(key, None) + if p.box: # a box isn't round, and holds tissues, not water + for key in ("Widest", "Base", "Opening at the top", "Holds"): + d.pop(key, None) + d["Wall"] = d["Wall"].replace("solid bottom", "solid top") + return d + + def _hole_words(self) -> str: + p = self.p + if p.holes == VORONOI: + text = (f"Voronoi ovals, {p.hole_size:g} mm apart with at least {p.hole_strut:g} mm between; round tops" + + (f", bridging at most {self.bridge:.1f} mm" if self.bridge > 0 else "")) + else: + text = (f"{HOLE_WORDS[p.holes]}, {p.hole_size:g} mm apart with {p.hole_strut:g} mm between; pointed tops" + + (f" ({self.bridge:.1f} mm bridges at their points)" if self.bridge > 0 else "")) + if p.hole_swell > 0: + text += f"; edges rounded, the wall swelling {p.hole_swell:g} mm each side round them" + return text + + def _face_words(self) -> str: + p = self.p + words = [{ALMOND_EYES: "almond eyes"}.get(p.eyes, "round eyes") if p.eyes != NO_EYES else "", + "a raised nose" if p.nose == RAISED_NOSE else "", + {OPEN_MOUTH: "an open mouth", GRILLE_MOUTH: f"a grille of {p.mouth_slots} slots for a mouth", + CHIN_MOUTH: "open down to the chin"}.get(p.mouth, ""), + "slots for a strap" if p.straps else ""] + words = [w for w in words if w] + if not words: + return "" + text = ", ".join(words) + if face_holes(p) and not self.pattern_open: + text += "; pointed tops" + (f" ({self.bridge:.1f} mm bridges at their points)" if self.bridge > 0 else "") + return text[:1].upper() + text[1:] + + def contour(self, zc: float): + """Where the wall crosses height zc: line segments (K, 2, 3), for the print animation.""" + if not hasattr(self, "_tri"): + if self.holes_open or self.floor_hole is not None: # the pot's wall: each piece between holes a loop + keep = np.isin(self.shell_kind, (hollow.OUTER, hollow.INNER, hollow.SIDE, hollow.TIP)) + tri = self.shell_verts[self.shell_faces[keep]] + else: + tri = self.verts[self.faces[:self.side]] + self._tri = tri + self._tz = (tri[:, :, 2].min(1), tri[:, :, 2].max(1)) + zc = float(zc) + 1e-7 + sel = self._tri[(self._tz[0] < zc) & (self._tz[1] > zc)] + if not len(sel): + return np.zeros((0, 2, 3)) + pts, crosses = [], [] + for a, b in ((0, 1), (1, 2), (2, 0)): + za, zb = sel[:, a, 2], sel[:, b, 2] + cross = (za - zc) * (zb - zc) < 0 + t = np.where(cross, (zc - za) / np.where(zb == za, 1.0, zb - za), 0.0) + pts.append(sel[:, a] + (sel[:, b] - sel[:, a]) * t[:, None]) + crosses.append(cross) + P, C = np.stack(pts, 1), np.stack(crosses, 1) + first_two = np.argsort(~C, axis=1, kind="stable")[:, :2] # the two edges each face crosses + seg = np.take_along_axis(P, first_two[:, :, None], axis=1) + return seg[C.sum(1) >= 2] + + +# ================================================================ a tissue box cover +def box_room(p: VaseParams) -> float: + """How much bigger a tissue box cover is inside than the box of tissues, each side (mm): BOX_ROOM, more where + rounded corners need it to clear a square-cornered box, and more for patterns and rounded hole edges that + reach in.""" + wall = p.line_width * max(int(p.walls), 1) + inward = ((max(-p.rib_depth, 0.0) if p.rib_count > 0 else 0.0) + (max(-p.ring_depth, 0.0) if p.ring_count > 0 + else 0.0) + (max(-p.bump_depth, 0.0) if p.bump_around > 0 else 0.0) + 1.2 * max(p.organic_depth, 0.0) + + (p.hole_swell if p.holes != NO_HOLES else 0.0)) + corner = max(p.box_corner - wall, 1.0) # inside + return max(BOX_ROOM, corner - (corner - BOX_ROOM) / math.sqrt(2.0)) + inward + + +def box_shape(p: VaseParams) -> VaseParams: + """A tissue box cover's own shape, from the box of tissues it goes over: a rectangle seen from above with + rounded corners, straight up. It is made upside down, as it prints - the top, with the hole for the tissues, + is the floor on the bed, and the rim is the open bottom the box goes in by - so nothing needs supports.""" + q = p.copy() + wall = q.line_width * max(int(q.walls), 1) + room = box_room(q) + a = 0.5 * max(q.box_length, q.box_width) + room + BOX_END_ROOM + wall # half its length and width, outside + b = 0.5 * min(q.box_length, q.box_width) + room + wall + c = min(max(q.box_corner, wall + 1.0), b) + reach = math.hypot(a - c, b - c) + c # to the furthest point, round a corner + q.shape1, q.depth1, q.sharp1, q.shape2 = RECTANGLE, min(1.0 - b / a, 0.9), c / b, SAME + q.turn1 = 90.0 if q.box_width > q.box_length else 0.0 + q.height = round(q.box_height + BOX_TOP_ROOM + max(q.bottom, q.layer_height), 3) + q.profile, q.silhouette = 1, [[0.0, round(2.0 * reach, 3)], [1.0, round(2.0 * reach, 3)]] + q.twist, q.twist_style, q.lattice, q.facets = 0.0, EVENLY, False, False + q.open_bottom, q.closed_top, q.around, q.worn = False, False, 360.0, False + q.eyes, q.nose, q.mouth, q.straps = NO_EYES, NO_NOSE, NO_MOUTH, False + q.vase_mode = FAKE_VASE + return q + + +def ledge_lean(p: VaseParams) -> float: + """How far a cover's ledges' steep sides lean from upright (degrees): LEDGE_LEAN, or less, as the wall may.""" + return max(min(LEDGE_LEAN, p.max_overhang - 10.0), 10.0) + + +def ledge_heights(p: VaseParams): + """Where a cover's ledges change, measured up from the rim with the cover on: the foot of the ramp, the top + of the ramp and the end of the flat, both LEDGE_DEPTH in, and the top of the steep side.""" + h0 = LEDGE_FROM + h1 = h0 + LEDGE_RAMP + h2 = h1 + LEDGE_FLAT + return h0, h1, h2, h2 + LEDGE_DEPTH / math.tan(math.radians(ledge_lean(p))) + + +def ledges(z, I, H: float, heights): + """The inside of a cover's wall I (K, N, 2) at heights z, with a ledge across each end - the straight part of + the end, on the long way - standing in from the wall near the rim at H, for its lid's tabs to click in + behind. As the cover prints, upside down, the ramp faces up and the steep side down, no steeper than a wall + may lean, so nothing needs supports.""" + h0, h1, h2, h3 = heights + h = H - np.asarray(z, dtype=float) + depth = LEDGE_DEPTH * np.interp(h, [h0, h1, h2, h3], [0.0, 1.0, 1.0, 0.0], left=0.0, right=0.0) + rim = I[-1] + X, Y = np.abs(rim).max(axis=0) + U = np.array([1.0, 0.0]) if X >= Y else np.array([0.0, 1.0]) + V = np.array([-U[1], U[0]]) + u, v = rim @ U, rim @ V + flat = v[np.abs(u) >= np.abs(u).max() - 0.05] + half = max(0.5 * float(flat.max() - flat.min()) - 1.0, 12.0) # the straight part of each end, or its middle + across = np.clip((half - np.abs(I @ V)) / 3.0, 0.0, 1.0) # each ledge tapers off at its ends ... + on_end = np.abs(I @ U) > 0.5 * np.abs(u).max() # ... and is only on the ends + off = depth[:, None] * across * on_end + return I - sections.normals(I) * off[..., None] + + +# ================================================================ sizing for a head +def scaled(p: VaseParams, k: float) -> VaseParams: + """The design k times the size: its height, outline and face (patterns and holes stay as they are).""" + q = p.copy() + q.silhouette = [[h, round(d * k, 2)] for h, d in p.silhouette] + for key in HEAD_SCALED: + setattr(q, key, round(getattr(p, key) * k, 2)) + return q + + +def fit_head(p: VaseParams, head: float) -> VaseParams: + """The design sized to fit a head ``head`` mm round (round the forehead, above the ears), with a little + room: the inside measured where the head goes in, the whole design scaled to fit.""" + q = p.copy() + for _ in range(4): + d = VaseDesign(q, draft=True) + if d.errors or d.head_fit <= 0: + return q + if abs(d.head_fit - head) < 0.5: + break + extra = HEAD_ROOM + TAU * d.wall # the inside is this much less round than the outside + q = scaled(q, (head + extra) / (d.head_fit + extra)) + return q diff --git a/vaseinator/params.py b/vaseinator/params.py index 03bccca..6fcf32b 100644 --- a/vaseinator/params.py +++ b/vaseinator/params.py @@ -1,336 +1,346 @@ -"""Design parameters for a spiral vase, with JSON persistence.""" - -from __future__ import annotations - -import json -from dataclasses import asdict, dataclass, field, fields - -FILE_FORMAT = "vaseinator" -FILE_VERSION = 1 -FILAMENTS = (1, 2, 3, 4) - -# cross-section shapes; SAME (for the top shape) keeps the bottom one all the way up -SAME, CIRCLE, POLYGON, STAR, FLOWER, GEAR, ELLIPSE, PEBBLE, DRAWN, RECTANGLE = -1, 0, 1, 2, 3, 4, 5, 6, 7, 8 -SHAPES = (CIRCLE, POLYGON, RECTANGLE, STAR, FLOWER, GEAR, ELLIPSE, PEBBLE, DRAWN) # DRAWN: any outline, from points - -# how something changes on the way up (the twist, the change of shape, the texture strength) -EVENLY, EASE, SPEED_UP, SLOW_DOWN, SWING, OWN = 0, 1, 2, 3, 4, 5 -EVERYWHERE, GROWS, FADES, MIDDLE = 0, 1, 2, 3 # texture strength (OWN = own curve) -OWN_PROFILE = -1 # the outline has been drawn by hand - -ONE_COLOUR, BANDS, STRIPES, GRADIENT = 0, 1, 2, 3 # how the colours go up the vase -REAL_VASE, FAKE_VASE = 0, 1 # spiral vase mode, or an ordinary print with walls -NO_HOLES, VORONOI, ROUND, DIAMOND, SLOTS = 0, 1, 2, 3, 4 # holes right through the wall -NO_EYES, ALMOND_EYES, ROUND_EYES = 0, 1, 2 # a face: its eyes ... -NO_NOSE, RAISED_NOSE = 0, 1 # ... its nose ... -NO_MOUTH, OPEN_MOUTH, GRILLE_MOUTH, CHIN_MOUTH = 0, 1, 2, 3 # ... and its mouth (CHIN: open down to the chin) -NO_TISSUE, TISSUE_SLOT, TISSUE_OVAL, TISSUE_ROUND = 0, 1, 2, 3 # the hole in a tissue box cover's top -LID_GENTLE, LID_SNUG, LID_FIRM = 0, 1, 2 # how hard a cover's lid holds on -MATTE, SILK, GLOSSY, TRANSLUCENT = 0, 1, 2, 3 # how the filament looks in the 3D view - -MAX_REPEAT = 100 # most copies of a drawn piece round the axis -MAX_BANDS = 64 # colour bands the 3D view can show - - -def _pts(*pairs): - return field(default_factory=lambda: [list(p) for p in pairs]) - - -# one lobe, from neck to neck: repeated five times round the axis it makes a five-lobed flower -FIVE_LOBES = ((0.42, -0.12, True), (0.62, -0.38, False), (0.95, -0.22, False), (0.95, 0.22, False), - (0.62, 0.38, False), (0.42, 0.12, True)) - - -@dataclass -class VaseParams: - name: str = "lagoon_twist" - - # --- size --------------------------------------------------------------- - height: float = 180.0 - profile: int = 0 # outline preset (shapes.PROFILES), or OWN_PROFILE once drawn by hand - silhouette: list = _pts((0.0, 66.0), (0.36, 104.0), (0.8, 62.0), (1.0, 74.0)) # (height 0..1, - # diameter mm) points, joined by a smooth curve that never overshoots - - # --- cross-section ------------------------------------------------------ - shape1: int = FLOWER # the shape at the bottom - count1: int = 7 # sides, points, petals, teeth (or the pebble's number) - depth1: float = 0.16 # how far in the shape cuts, as a share of the radius - sharp1: float = 0.45 # sharpness / corner rounding / petal shape / tooth width (0..1) - turn1: float = 0.0 # turned round the axis (degrees) - drawn1: list = field(default_factory=lambda: [list(p) for p in FIVE_LOBES]) # a drawn outline's - # control points [x, y, cusp]; the axis is the origin - repeat1: int = 5 # ... repeated this many times round the axis - shape2: int = SAME # the shape it turns into at the top - count2: int = 5 - depth2: float = 0.0 - sharp2: float = 0.5 - turn2: float = 0.0 - drawn2: list = field(default_factory=lambda: [list(p) for p in FIVE_LOBES]) - repeat2: int = 5 - morph: int = EVENLY # how the change of shape happens on the way up - morph_swings: int = 3 # back and forth: how many changes (3: bottom, top, bottom, top shape) - morph_curve: list = _pts((0.0, 0.0), (1.0, 1.0)) - - # --- spin --------------------------------------------------------------- - twist: float = 150.0 # degrees the shape turns from bottom to top - twist_style: int = EASE - swings: int = 3 # half-waves of a back-and-forth twist - twist_curve: list = _pts((0.0, 0.0), (1.0, 150.0)) # (height, degrees) for an own twist - lattice: bool = False # twist both ways at once: a lattice of crossing ridges - - # --- surface ------------------------------------------------------------ - rib_count: int = 0 # ribs running up the vase (0 = none) - rib_depth: float = 1.2 # mm out (+) or grooves in (-) - rib_sharp: float = 0.3 # 0 smooth flutes ... 1 narrow ridges - rib_lean: float = 0.0 # the ribs spiral this far round on top of the twist (degrees) - rib_cross: bool = False # a second set spirals the other way: diamond knurling - ring_count: int = 0 # rings round the vase (0 = none) - ring_depth: float = 1.0 - ring_sharp: float = 0.3 - bump_around: int = 0 # bumps round the vase (0 = none) - bump_up: int = 8 # ... and up it - bump_depth: float = 1.5 - organic_depth: float = 0.0 # smooth random lumps, in and out (mm, 0 = none) - organic_scale: float = 22.0 # size of one lump (mm) - organic_seed: int = 7 - texture_twist: bool = True # the surface patterns turn with the twist - even_spacing: bool = False # ... spaced by distance round the wall, not by angle from the middle - strength: int = EVERYWHERE # how strong the patterns are on the way up - strength_curve: list = _pts((0.0, 1.0), (1.0, 1.0)) - fade_foot: float = 3.0 # patterns fade out over this much of the bottom (mm) - fade_rim: float = 4.0 # ... and of the top - - # --- low-poly ----------------------------------------------------------- - facets: bool = False # flat facets, like a cut gem - facets_around: int = 14 - facets_up: int = 10 - facet_rough: float = 0.0 # 0 regular ... 1 rough cut - facet_seed: int = 1 - - # --- your own function -------------------------------------------------- - expression: str = "" # mm added to the radius: f(theta, phi, z, h, r, H) - - # --- colours ------------------------------------------------------------ - color1: str = "#0E7C86" - color2: str = "#3FC1C9" - color3: str = "#F5F5F5" - color4: str = "#FC5185" - colour_style: int = BANDS - colours_used: int = 3 # filaments the bands, stripes or gradient go through - band_count: int = 3 - band_spacing: float = 0.0 # -1 bands thicker at the bottom ... 0 even ... 1 thicker at the top - stripe_height: float = 12.0 # mm - gradient_length: float = 8.0 # metres of filament for one colour cycle of a gradient spool - look: int = SILK - - # --- holes and an open bottom (a normal print) --------------------------- - holes: int = NO_HOLES # holes right through the wall: Voronoi, round, diamonds, slots - hole_size: float = 14.0 # from one hole to the next (mm) - hole_strut: float = 3.0 # the wall left between holes (mm) - hole_jitter: float = 0.7 # Voronoi: 0 a regular honeycomb ... 1 quite irregular - hole_seed: int = 5 - hole_foot: float = 18.0 # solid wall kept at the foot, above the floor (mm) - hole_rim: float = 12.0 # ... and at the rim - hole_swell: float = 0.0 # the wall swells this much each side round the holes, rounding their edges (mm) - open_bottom: bool = False # no floor: open at both ends (a lampshade, a sleeve) - closed_top: bool = False # the top closes over in a point no steeper than the overhang limit - around: float = 360.0 # how far round the wall goes (degrees): less for a mask - - # --- masks and helmets: a face at the front (-y), sizes and heights in mm -------------------- - worn: bool = False # made for a head: sized by the head it fits - eyes: int = NO_EYES - eye_width: float = 34.0 - eye_height: float = 18.0 - eye_apart: float = 64.0 # between the eyes' middles, along the surface - eye_up: float = 112.0 # the eyes' middles, from the bottom - eye_tilt: float = 10.0 # the outer corners raised (degrees) - nose: int = NO_NOSE - nose_width: float = 30.0 - nose_length: float = 46.0 # from its tip up to between the eyes - nose_depth: float = 14.0 # how far its tip stands out - nose_up: float = 70.0 # its tip, from the bottom - mouth: int = NO_MOUTH - mouth_width: float = 44.0 - mouth_height: float = 14.0 - mouth_up: float = 40.0 # its middle (the top of an opening down to the chin), from the bottom - mouth_slots: int = 5 # a grille's slots - mouth_smile: float = 0.0 # ... its outer slots this much higher (mm): a smile - straps: bool = False # a slot each side for an elastic strap - strap_up: float = 112.0 - - # --- a tissue box cover: sized by the box of tissues it goes over, printed upside down, on its top --- - box: bool = False # a box open at the bottom with a hole in its top, to go over a box of tissues - box_length: float = 220.0 # the box of tissues it goes over (mm) - box_width: float = 120.0 - box_height: float = 90.0 - box_corner: float = 10.0 # how round the cover's corners are seen from above: their radius outside (mm) - tissue_hole: int = TISSUE_SLOT # the hole in its top the tissues come out of - tissue_length: float = 130.0 # ... along the box - tissue_width: float = 36.0 # ... and across it (a round hole: its diameter) - lid_grip: int = LID_SNUG # the lid for its open end, printed on its own: how far its latches reach - lid_open: bool = True # ... a frame, open in the middle - or a solid plate - - # --- printing ----------------------------------------------------------- - vase_mode: int = FAKE_VASE # real: spiral vase mode, one solid, one colour (slicers won't change - # filament in it); fake: an ordinary print with walls, the colour - # bands as parts of their own filaments - walls: int = 2 # fake vase: walls round the vase - max_overhang: float = 45.0 # steepest lean of the wall from upright, either way (degrees) - soften: bool = True # soften anything steeper until it prints without supports - line_width: float = 0.6 # the slicer's spiral wall line width: the printed wall - layer_height: float = 0.2 - bottom: float = 1.2 # solid bottom the slicer prints first (mm) - printer_height: float = 250.0 - bed_x: float = 256.0 - bed_y: float = 256.0 - speed: float = 40.0 # spiral wall speed (mm/s) - max_flow: float = 12.0 # the hot end's flow limit (mm³/s) - filament_d: float = 1.75 - density: float = 1.24 # g/cm³ (PLA 1.24, PETG 1.27) - facet_size: float = 0.2 # mesh detail: spacing of the points on the surface (mm) - - # ---------------------------------------------------------------- utils - def copy(self) -> "VaseParams": - return VaseParams.from_dict(self.to_dict()) - - def to_dict(self) -> dict: - return asdict(self) - - def colors(self) -> dict: - """Filament number -> (r, g, b) in 0..1.""" - return {k: hex_to_rgb(getattr(self, f"color{k}")) for k in FILAMENTS} - - @classmethod - def from_dict(cls, d: dict) -> "VaseParams": - p = cls() - fresh = cls() - for f in fields(cls): - if f.name not in d: - continue - cur, v = getattr(p, f.name), d[f.name] - try: - if isinstance(cur, bool): - v = bool(v) - elif isinstance(cur, int): - v = int(v) - elif isinstance(cur, float): - v = float(v) - elif isinstance(cur, list): - v = clean_outline(v) if f.name in ("drawn1", "drawn2") else clean_curve(v) - if v is None: - continue - else: - v = str(v) - except (TypeError, ValueError): - continue - setattr(p, f.name, v) - allowed = {"shape1": SHAPES, "shape2": (SAME,) + SHAPES, - "morph": (EVENLY, EASE, SPEED_UP, SLOW_DOWN, SWING, OWN), - "twist_style": (EVENLY, EASE, SPEED_UP, SLOW_DOWN, SWING, OWN), - "strength": (EVERYWHERE, GROWS, FADES, MIDDLE, OWN), - "colour_style": (ONE_COLOUR, BANDS, STRIPES, GRADIENT), "colours_used": (1, 2, 3, 4), - "look": (MATTE, SILK, GLOSSY, TRANSLUCENT), "vase_mode": (REAL_VASE, FAKE_VASE), - "holes": (NO_HOLES, VORONOI, ROUND, DIAMOND, SLOTS), - "eyes": (NO_EYES, ALMOND_EYES, ROUND_EYES), "nose": (NO_NOSE, RAISED_NOSE), - "mouth": (NO_MOUTH, OPEN_MOUTH, GRILLE_MOUTH, CHIN_MOUTH), - "tissue_hole": (NO_TISSUE, TISSUE_SLOT, TISSUE_OVAL, TISSUE_ROUND), - "lid_grip": (LID_GENTLE, LID_SNUG, LID_FIRM)} - for key, values in allowed.items(): - if getattr(p, key) not in values: - setattr(p, key, getattr(fresh, key)) - if any(d <= 0 for _, d in p.silhouette): - p.silhouette = fresh.silhouette - p.walls = min(max(p.walls, 1), 10) - p.repeat1, p.repeat2 = (min(max(r, 1), MAX_REPEAT) for r in (p.repeat1, p.repeat2)) - p.mouth_slots = min(max(p.mouth_slots, 1), 40) - for key, lo, hi in (("depth1", 0.0, 0.9), ("depth2", 0.0, 0.9), ("sharp1", 0.0, 1.0), - ("sharp2", 0.0, 1.0), ("rib_sharp", 0.0, 1.0), ("ring_sharp", 0.0, 1.0), - ("facet_rough", 0.0, 1.0), ("band_spacing", -1.0, 1.0), ("hole_jitter", 0.0, 1.0), - ("hole_size", 3.0, 200.0), ("hole_strut", 0.8, 50.0), ("hole_foot", 0.0, 2000.0), - ("hole_rim", 0.0, 2000.0), ("hole_swell", 0.0, 10.0), ("around", 30.0, 360.0), ("eye_width", 2.0, 400.0), - ("eye_height", 2.0, 400.0), ("eye_apart", 0.0, 2000.0), ("eye_tilt", -80.0, 80.0), - ("nose_width", 2.0, 400.0), ("nose_length", 2.0, 1000.0), ("nose_depth", 0.0, 100.0), - ("mouth_width", 2.0, 1000.0), ("mouth_height", 2.0, 400.0), - ("mouth_smile", -400.0, 400.0), ("box_length", 20.0, 1000.0), - ("box_width", 20.0, 1000.0), ("box_height", 10.0, 1000.0), ("box_corner", 0.0, 500.0), - ("tissue_length", 5.0, 1000.0), ("tissue_width", 5.0, 1000.0)): - setattr(p, key, min(max(getattr(p, key), lo), hi)) - for k in FILAMENTS: - try: - hex_to_rgb(getattr(p, f"color{k}")) - except ValueError: - setattr(p, f"color{k}", getattr(fresh, f"color{k}")) - return p - - -def face_holes(p: VaseParams) -> bool: - """Whether the face has holes of its own: eyes, a mouth or strap slots.""" - return p.eyes != NO_EYES or p.mouth != NO_MOUTH or p.straps - - -def needs_normal(p: VaseParams) -> bool: - """Whether the wall is cut open - holes, an open bottom, part way round, a tissue box cover - so it must be - printed normally: spiral vase mode prints one closed line a layer.""" - return p.holes != NO_HOLES or p.open_bottom or p.around < 360.0 or face_holes(p) or p.box - - -def colour_style_of(p: VaseParams) -> int: - """The colour style that really prints: a real spiral vase is one filament, so no bands or stripes.""" - if p.vase_mode == REAL_VASE and p.colour_style in (BANDS, STRIPES): - return ONE_COLOUR - return p.colour_style - - -def clean_outline(points): - """A drawn outline's control points made safe: [[x, y, cusp], ...], at least three; None if unusable.""" - try: - out = [[float(p[0]), float(p[1]), bool(p[2]) if len(p) > 2 else False] for p in points] - except (TypeError, ValueError, IndexError): - return None - out = [p for p in out if all(map(lambda c: abs(c) < 1e6, p[:2]))] - return out if len(out) >= 3 else None - - -def clean_curve(points): - """A curve's points made safe: [[h, v], ...] sorted, from h = 0 to h = 1, at least two. - - Returns None if there is nothing usable. - """ - try: - pts = sorted((min(max(float(h), 0.0), 1.0), float(v)) for h, v in points) - except (TypeError, ValueError): - return None - out = [] - for h, v in pts: - if out and h - out[-1][0] < 0.005: # two points at one height: keep the later one - out[-1] = [out[-1][0], v] - else: - out.append([h, v]) - if len(out) < 2: - return None - out[0][0], out[-1][0] = 0.0, 1.0 - if out[-1][0] - out[-2][0] < 0.005 and len(out) > 2: - del out[-2] - return out - - -def hex_to_rgb(text: str): - """'#RRGGBB' -> (r, g, b) in 0..1.""" - t = text.strip().lstrip("#") - if len(t) != 6: - raise ValueError(f"not a colour: {text!r}") - return tuple(int(t[i:i + 2], 16) / 255.0 for i in (0, 2, 4)) - - -def save_design(path: str, params: VaseParams): - data = {"format": FILE_FORMAT, "version": FILE_VERSION, "params": params.to_dict()} - with open(path, "w", encoding="utf-8") as fh: - json.dump(data, fh, indent=2) - - -def load_design(path: str) -> VaseParams: - with open(path, "r", encoding="utf-8") as fh: - data = json.load(fh) - if data.get("format") != FILE_FORMAT: - raise ValueError("Not a Vaseinator design file") - return VaseParams.from_dict(data.get("params", {})) +"""Design parameters for a spiral vase, with JSON persistence.""" + +from __future__ import annotations + +import json +import math +from dataclasses import asdict, dataclass, field, fields + +FILE_FORMAT = "vaseinator" +FILE_VERSION = 1 +FILAMENTS = (1, 2, 3, 4) + +# cross-section shapes; SAME (for the top shape) keeps the bottom one all the way up +SAME, CIRCLE, POLYGON, STAR, FLOWER, GEAR, ELLIPSE, PEBBLE, DRAWN, RECTANGLE = -1, 0, 1, 2, 3, 4, 5, 6, 7, 8 +SHAPES = (CIRCLE, POLYGON, RECTANGLE, STAR, FLOWER, GEAR, ELLIPSE, PEBBLE, DRAWN) # DRAWN: any outline, from points + +# how something changes on the way up (the twist, the change of shape, the texture strength) +EVENLY, EASE, SPEED_UP, SLOW_DOWN, SWING, OWN = 0, 1, 2, 3, 4, 5 +EVERYWHERE, GROWS, FADES, MIDDLE = 0, 1, 2, 3 # texture strength (OWN = own curve) +OWN_PROFILE = -1 # the outline has been drawn by hand + +ONE_COLOUR, BANDS, STRIPES, GRADIENT = 0, 1, 2, 3 # how the colours go up the vase +REAL_VASE, FAKE_VASE = 0, 1 # spiral vase mode, or an ordinary print with walls +NO_HOLES, VORONOI, ROUND, DIAMOND, SLOTS = 0, 1, 2, 3, 4 # holes right through the wall +NO_EYES, ALMOND_EYES, ROUND_EYES = 0, 1, 2 # a face: its eyes ... +NO_NOSE, RAISED_NOSE = 0, 1 # ... its nose ... +NO_MOUTH, OPEN_MOUTH, GRILLE_MOUTH, CHIN_MOUTH = 0, 1, 2, 3 # ... and its mouth (CHIN: open down to the chin) +NO_TISSUE, TISSUE_SLOT, TISSUE_OVAL, TISSUE_ROUND = 0, 1, 2, 3 # the hole in a tissue box cover's top +LID_GENTLE, LID_SNUG, LID_FIRM = 0, 1, 2 # how hard a cover's lid holds on +MATTE, SILK, GLOSSY, TRANSLUCENT = 0, 1, 2, 3 # how the filament looks in the 3D view + +MAX_REPEAT = 100 # most copies of a drawn piece round the axis +MAX_BANDS = 64 # colour bands the 3D view can show + + +def _pts(*pairs): + return field(default_factory=lambda: [list(p) for p in pairs]) + + +# one lobe, from neck to neck: repeated five times round the axis it makes a five-lobed flower +FIVE_LOBES = ((0.42, -0.12, True), (0.62, -0.38, False), (0.95, -0.22, False), (0.95, 0.22, False), + (0.62, 0.38, False), (0.42, 0.12, True)) + + +@dataclass +class VaseParams: + name: str = "lagoon_twist" + + # --- size --------------------------------------------------------------- + height: float = 180.0 + profile: int = 0 # outline preset (shapes.PROFILES), or OWN_PROFILE once drawn by hand + silhouette: list = _pts((0.0, 66.0), (0.36, 104.0), (0.8, 62.0), (1.0, 74.0)) # (height 0..1, + # diameter mm) points, joined by a smooth curve that never overshoots + + # --- cross-section ------------------------------------------------------ + shape1: int = FLOWER # the shape at the bottom + count1: int = 7 # sides, points, petals, teeth (or the pebble's number) + depth1: float = 0.16 # how far in the shape cuts, as a share of the radius + sharp1: float = 0.45 # sharpness / corner rounding / petal shape / tooth width (0..1) + turn1: float = 0.0 # turned round the axis (degrees) + drawn1: list = field(default_factory=lambda: [list(p) for p in FIVE_LOBES]) # a drawn outline's + # control points [x, y, cusp]; the axis is the origin + repeat1: int = 5 # ... repeated this many times round the axis + shape2: int = SAME # the shape it turns into at the top + count2: int = 5 + depth2: float = 0.0 + sharp2: float = 0.5 + turn2: float = 0.0 + drawn2: list = field(default_factory=lambda: [list(p) for p in FIVE_LOBES]) + repeat2: int = 5 + morph: int = EVENLY # how the change of shape happens on the way up + morph_swings: int = 3 # back and forth: how many changes (3: bottom, top, bottom, top shape) + morph_curve: list = _pts((0.0, 0.0), (1.0, 1.0)) + + # --- spin --------------------------------------------------------------- + twist: float = 150.0 # degrees the shape turns from bottom to top + twist_style: int = EASE + swings: int = 3 # half-waves of a back-and-forth twist + twist_curve: list = _pts((0.0, 0.0), (1.0, 150.0)) # (height, degrees) for an own twist + lattice: bool = False # twist both ways at once: a lattice of crossing ridges + + # --- surface ------------------------------------------------------------ + rib_count: int = 0 # ribs running up the vase (0 = none) + rib_depth: float = 1.2 # mm out (+) or grooves in (-) + rib_sharp: float = 0.3 # 0 smooth flutes ... 1 narrow ridges + rib_lean: float = 0.0 # the ribs spiral this far round on top of the twist (degrees) + rib_cross: bool = False # a second set spirals the other way: diamond knurling + ring_count: int = 0 # rings round the vase (0 = none) + ring_depth: float = 1.0 + ring_sharp: float = 0.3 + bump_around: int = 0 # bumps round the vase (0 = none) + bump_up: int = 8 # ... and up it + bump_depth: float = 1.5 + organic_depth: float = 0.0 # smooth random lumps, in and out (mm, 0 = none) + organic_scale: float = 22.0 # size of one lump (mm) + organic_seed: int = 7 + texture_twist: bool = True # the surface patterns turn with the twist + even_spacing: bool = False # ... spaced by distance round the wall, not by angle from the middle + strength: int = EVERYWHERE # how strong the patterns are on the way up + strength_curve: list = _pts((0.0, 1.0), (1.0, 1.0)) + fade_foot: float = 3.0 # patterns fade out over this much of the bottom (mm) + fade_rim: float = 4.0 # ... and of the top + + # --- low-poly ----------------------------------------------------------- + facets: bool = False # flat facets, like a cut gem + facets_around: int = 14 + facets_up: int = 10 + facet_rough: float = 0.0 # 0 regular ... 1 rough cut + facet_seed: int = 1 + + # --- your own function -------------------------------------------------- + expression: str = "" # mm added to the radius: f(theta, phi, z, h, r, H) + + # --- colours ------------------------------------------------------------ + color1: str = "#0E7C86" + color2: str = "#3FC1C9" + color3: str = "#F5F5F5" + color4: str = "#FC5185" + colour_style: int = BANDS + colours_used: int = 3 # filaments the bands, stripes or gradient go through + band_count: int = 3 + band_spacing: float = 0.0 # -1 bands thicker at the bottom ... 0 even ... 1 thicker at the top + stripe_height: float = 12.0 # mm + gradient_length: float = 8.0 # metres of filament for one colour cycle of a gradient spool + look: int = SILK + + # --- holes and an open bottom (a normal print) --------------------------- + holes: int = NO_HOLES # holes right through the wall: Voronoi, round, diamonds, slots + hole_size: float = 14.0 # from one hole to the next (mm) + hole_strut: float = 3.0 # the wall left between holes (mm) + hole_jitter: float = 0.7 # Voronoi: 0 a regular honeycomb ... 1 quite irregular + hole_seed: int = 5 + hole_foot: float = 18.0 # solid wall kept at the foot, above the floor (mm) + hole_rim: float = 12.0 # ... and at the rim + hole_swell: float = 0.0 # the wall swells this much each side round the holes, rounding their edges (mm) + open_bottom: bool = False # no floor: open at both ends (a lampshade, a sleeve) + closed_top: bool = False # the top closes over in a point no steeper than the overhang limit + around: float = 360.0 # how far round the wall goes (degrees): less for a mask + + # --- masks and helmets: a face at the front (-y), sizes and heights in mm -------------------- + worn: bool = False # made for a head: sized by the head it fits + eyes: int = NO_EYES + eye_width: float = 34.0 + eye_height: float = 18.0 + eye_apart: float = 64.0 # between the eyes' middles, along the surface + eye_up: float = 112.0 # the eyes' middles, from the bottom + eye_tilt: float = 10.0 # the outer corners raised (degrees) + nose: int = NO_NOSE + nose_width: float = 30.0 + nose_length: float = 46.0 # from its tip up to between the eyes + nose_depth: float = 14.0 # how far its tip stands out + nose_up: float = 70.0 # its tip, from the bottom + mouth: int = NO_MOUTH + mouth_width: float = 44.0 + mouth_height: float = 14.0 + mouth_up: float = 40.0 # its middle (the top of an opening down to the chin), from the bottom + mouth_slots: int = 5 # a grille's slots + mouth_smile: float = 0.0 # ... its outer slots this much higher (mm): a smile + straps: bool = False # a slot each side for an elastic strap + strap_up: float = 112.0 + + # --- a tissue box cover: sized by the box of tissues it goes over, printed upside down, on its top --- + box: bool = False # a box open at the bottom with a hole in its top, to go over a box of tissues + box_length: float = 220.0 # the box of tissues it goes over (mm) + box_width: float = 120.0 + box_height: float = 90.0 + box_corner: float = 10.0 # how round the cover's corners are seen from above: their radius outside (mm) + tissue_hole: int = TISSUE_SLOT # the hole in its top the tissues come out of + tissue_length: float = 130.0 # ... along the box + tissue_width: float = 36.0 # ... and across it (a round hole: its diameter) + lid_grip: int = LID_SNUG # the lid for its open end, printed on its own: how far its latches reach + lid_open: bool = True # ... a frame, open in the middle - or a solid plate + + # --- printing ----------------------------------------------------------- + vase_mode: int = FAKE_VASE # real: spiral vase mode, one solid, one colour (slicers won't change + # filament in it); fake: an ordinary print with walls, the colour + # bands as parts of their own filaments + walls: int = 2 # fake vase: walls round the vase + max_overhang: float = 45.0 # steepest lean of the wall from upright, either way (degrees) + soften: bool = True # soften anything steeper until it prints without supports + line_width: float = 0.6 # the slicer's spiral wall line width: the printed wall + layer_height: float = 0.2 + bottom: float = 1.2 # solid bottom the slicer prints first (mm) + printer_height: float = 250.0 + bed_x: float = 256.0 + bed_y: float = 256.0 + speed: float = 40.0 # spiral wall speed (mm/s) + max_flow: float = 12.0 # the hot end's flow limit (mm³/s) + filament_d: float = 1.75 + density: float = 1.24 # g/cm³ (PLA 1.24, PETG 1.27) + facet_size: float = 0.2 # mesh detail: spacing of the points on the surface (mm) + + # ---------------------------------------------------------------- utils + def copy(self) -> "VaseParams": + return VaseParams.from_dict(self.to_dict()) + + def to_dict(self) -> dict: + return asdict(self) + + def colors(self) -> dict: + """Filament number -> (r, g, b) in 0..1.""" + return {k: hex_to_rgb(getattr(self, f"color{k}")) for k in FILAMENTS} + + @classmethod + def from_dict(cls, d: dict) -> "VaseParams": + if not isinstance(d, dict): + raise ValueError("Design settings must be an object.") + p = cls() + fresh = cls() + for f in fields(cls): + if f.name not in d: + continue + cur, v = getattr(p, f.name), d[f.name] + if isinstance(v, float) and not math.isfinite(v): + raise ValueError(f"{f.name} must be a finite number.") + try: + if isinstance(cur, bool): + v = bool(v) + elif isinstance(cur, int): + v = int(v) + elif isinstance(cur, float): + v = float(v) + elif isinstance(cur, list): + v = clean_outline(v) if f.name in ("drawn1", "drawn2") else clean_curve(v) + if v is None: + continue + else: + v = str(v) + except (TypeError, ValueError, OverflowError): + continue + if isinstance(v, float) and not math.isfinite(v): + raise ValueError(f"{f.name} must be a finite number.") + setattr(p, f.name, v) + allowed = {"shape1": SHAPES, "shape2": (SAME,) + SHAPES, + "morph": (EVENLY, EASE, SPEED_UP, SLOW_DOWN, SWING, OWN), + "twist_style": (EVENLY, EASE, SPEED_UP, SLOW_DOWN, SWING, OWN), + "strength": (EVERYWHERE, GROWS, FADES, MIDDLE, OWN), + "colour_style": (ONE_COLOUR, BANDS, STRIPES, GRADIENT), "colours_used": (1, 2, 3, 4), + "look": (MATTE, SILK, GLOSSY, TRANSLUCENT), "vase_mode": (REAL_VASE, FAKE_VASE), + "holes": (NO_HOLES, VORONOI, ROUND, DIAMOND, SLOTS), + "eyes": (NO_EYES, ALMOND_EYES, ROUND_EYES), "nose": (NO_NOSE, RAISED_NOSE), + "mouth": (NO_MOUTH, OPEN_MOUTH, GRILLE_MOUTH, CHIN_MOUTH), + "tissue_hole": (NO_TISSUE, TISSUE_SLOT, TISSUE_OVAL, TISSUE_ROUND), + "lid_grip": (LID_GENTLE, LID_SNUG, LID_FIRM)} + for key, values in allowed.items(): + if getattr(p, key) not in values: + setattr(p, key, getattr(fresh, key)) + if any(d <= 0 for _, d in p.silhouette): + p.silhouette = fresh.silhouette + p.walls = min(max(p.walls, 1), 10) + p.repeat1, p.repeat2 = (min(max(r, 1), MAX_REPEAT) for r in (p.repeat1, p.repeat2)) + p.mouth_slots = min(max(p.mouth_slots, 1), 40) + for key, lo, hi in (("depth1", 0.0, 0.9), ("depth2", 0.0, 0.9), ("sharp1", 0.0, 1.0), + ("sharp2", 0.0, 1.0), ("rib_sharp", 0.0, 1.0), ("ring_sharp", 0.0, 1.0), + ("facet_rough", 0.0, 1.0), ("band_spacing", -1.0, 1.0), ("hole_jitter", 0.0, 1.0), + ("hole_size", 3.0, 200.0), ("hole_strut", 0.8, 50.0), ("hole_foot", 0.0, 2000.0), + ("hole_rim", 0.0, 2000.0), ("hole_swell", 0.0, 10.0), ("around", 30.0, 360.0), ("eye_width", 2.0, 400.0), + ("eye_height", 2.0, 400.0), ("eye_apart", 0.0, 2000.0), ("eye_tilt", -80.0, 80.0), + ("nose_width", 2.0, 400.0), ("nose_length", 2.0, 1000.0), ("nose_depth", 0.0, 100.0), + ("mouth_width", 2.0, 1000.0), ("mouth_height", 2.0, 400.0), + ("mouth_smile", -400.0, 400.0), ("box_length", 20.0, 1000.0), + ("box_width", 20.0, 1000.0), ("box_height", 10.0, 1000.0), ("box_corner", 0.0, 500.0), + ("tissue_length", 5.0, 1000.0), ("tissue_width", 5.0, 1000.0)): + setattr(p, key, min(max(getattr(p, key), lo), hi)) + for k in FILAMENTS: + try: + hex_to_rgb(getattr(p, f"color{k}")) + except ValueError: + setattr(p, f"color{k}", getattr(fresh, f"color{k}")) + return p + + +def face_holes(p: VaseParams) -> bool: + """Whether the face has holes of its own: eyes, a mouth or strap slots.""" + return p.eyes != NO_EYES or p.mouth != NO_MOUTH or p.straps + + +def needs_normal(p: VaseParams) -> bool: + """Whether the wall is cut open - holes, an open bottom, part way round, a tissue box cover - so it must be + printed normally: spiral vase mode prints one closed line a layer.""" + return p.holes != NO_HOLES or p.open_bottom or p.around < 360.0 or face_holes(p) or p.box + + +def colour_style_of(p: VaseParams) -> int: + """The colour style that really prints: a real spiral vase is one filament, so no bands or stripes.""" + if p.vase_mode == REAL_VASE and p.colour_style in (BANDS, STRIPES): + return ONE_COLOUR + return p.colour_style + + +def clean_outline(points): + """A drawn outline's control points made safe: [[x, y, cusp], ...], at least three; None if unusable.""" + try: + out = [[float(p[0]), float(p[1]), bool(p[2]) if len(p) > 2 else False] for p in points] + except (TypeError, ValueError, IndexError): + return None + out = [p for p in out if all(map(lambda c: abs(c) < 1e6, p[:2]))] + return out if len(out) >= 3 else None + + +def clean_curve(points): + """A curve's points made safe: [[h, v], ...] sorted, from h = 0 to h = 1, at least two. + + Returns None if there is nothing usable. + """ + try: + raw = [(float(h), float(v)) for h, v in points] + if not all(math.isfinite(h) and math.isfinite(v) for h, v in raw): + return None + pts = sorted((min(max(h, 0.0), 1.0), v) for h, v in raw) + except (TypeError, ValueError): + return None + out = [] + for h, v in pts: + if out and h - out[-1][0] < 0.005: # two points at one height: keep the later one + out[-1] = [out[-1][0], v] + else: + out.append([h, v]) + if len(out) < 2: + return None + out[0][0], out[-1][0] = 0.0, 1.0 + if out[-1][0] - out[-2][0] < 0.005 and len(out) > 2: + del out[-2] + return out + + +def hex_to_rgb(text: str): + """'#RRGGBB' -> (r, g, b) in 0..1.""" + t = text.strip().lstrip("#") + if len(t) != 6: + raise ValueError(f"not a colour: {text!r}") + return tuple(int(t[i:i + 2], 16) / 255.0 for i in (0, 2, 4)) + + +def save_design(path: str, params: VaseParams): + data = {"format": FILE_FORMAT, "version": FILE_VERSION, "params": params.to_dict()} + with open(path, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2) + + +def load_design(path: str) -> VaseParams: + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + if data.get("format") != FILE_FORMAT: + raise ValueError("Not a Vaseinator design file") + return VaseParams.from_dict(data.get("params", {})) diff --git a/vaseinator/workspace.py b/vaseinator/workspace.py new file mode 100644 index 0000000..9d603de --- /dev/null +++ b/vaseinator/workspace.py @@ -0,0 +1,313 @@ +"""Portable design tools shared by desktop and web; storage belongs to each UI.""" + +from __future__ import annotations + +import copy +import json +import math +import uuid +from datetime import datetime, timezone +from html import escape + +import numpy as np + +from .params import VaseParams +from .presets import PRINTER_KEYS, surprise +from .shapes import pchip + +LOCKS = { + "Dimensions": ("height",), + "Silhouette": ("profile", "silhouette"), + "Cross-section": tuple( + f"{key}{n}" + for n in (1, 2) + for key in ("shape", "count", "depth", "sharp", "turn", "drawn", "repeat") + ) + + ("morph", "morph_swings", "morph_curve"), + "Twist": ("twist", "twist_style", "swings", "twist_curve", "lattice"), + "Colours": ( + "color1", + "color2", + "color3", + "color4", + "colour_style", + "colours_used", + "band_count", + "band_spacing", + "stripe_height", + "gradient_length", + "look", + ), +} +MATERIAL_KEYS = ("max_flow", "density", "filament_d", "speed") +PROFILE_KEYS = { + "printer": tuple(k for k in PRINTER_KEYS if k not in MATERIAL_KEYS), + "material": MATERIAL_KEYS, +} + + +def locked_surprise(p, seed, locks): + q = surprise(p, seed) + width = max(v for _, v in p.silhouette) + for group in locks: + for key in LOCKS.get(group, ()): + setattr(q, key, copy.deepcopy(getattr(p, key))) + if "Dimensions" in locks: + factor = width / max(v for _, v in q.silhouette) + q.silhouette = [[h, v * factor] for h, v in q.silhouette] + elif "Silhouette" in locks: + # A locked silhouette includes its width, rather than distorting it. + q.silhouette = copy.deepcopy(p.silhouette) + return q + + +def thumbnail(p): + """A portable silhouette thumbnail, explicitly a sketch rather than a render.""" + raw = np.asarray(p.silhouette, dtype=float) + heights = np.linspace(0, 1, 50) + points = list(zip(heights, pchip(raw[:, 0], raw[:, 1], heights))) + width = max(v for _, v in points) + right = [(60 + 42 * v / width, 112 - 104 * h) for h, v in points] + left = [(120 - x, y) for x, y in reversed(right)] + coords = " ".join(f"{x:.2f},{y:.2f}" for x, y in right + left) + return f'Silhouette sketch' + + +class Workspace: + def __init__(self, data=None): + self.data = { + "format": "vaseinator-library", + "version": 1, + "designs": [], + "profiles": [], + "recovery": [], + "locks": [], + } + if data is not None: + if ( + not isinstance(data, dict) + or data.get("format") != self.data["format"] + or data.get("version") != 1 + ): + raise ValueError("Not a supported Vaseinator library file.") + for kind in ("designs", "profiles", "recovery"): + entries = data.get(kind, []) + if not isinstance(entries, list) or len(entries) > 500: + raise ValueError( + "A library may hold at most 500 entries per category." + ) + for entry in entries: + if not isinstance(entry, dict) or not isinstance( + entry.get("params"), dict + ): + raise ValueError("Invalid library entry.") + p = VaseParams.from_dict(entry["params"]) + self.data[kind].append( + { + "id": str(entry.get("id") or uuid.uuid4()), + "name": str(entry.get("name", p.name))[:120], + "created": str(entry.get("created", "")), + "favorite": bool(entry.get("favorite", False)), + "kind": entry.get("kind", "printer") + if entry.get("kind") in PROFILE_KEYS + else "printer", + "params": p.to_dict(), + } + ) + self.data["locks"] = [k for k in data.get("locks", []) if k in LOCKS] + self.data["recovery"] = self.data["recovery"][-30:] + + def entry(self, category, ident): + return next(e for e in self.data[category] if e["id"] == ident) + + def add(self, category, p, name=None, kind="printer"): + if category not in ("designs", "profiles", "recovery"): + raise ValueError("Unknown library category.") + if category != "recovery" and len(self.data[category]) >= 500: + raise ValueError( + "This category is full. Export a backup and remove an entry first." + ) + if ( + category == "recovery" + and self.data[category] + and self.data[category][-1]["params"] == p.to_dict() + ): + return + entry = dict( + id=str(uuid.uuid4()), + name=(name or p.name).strip()[:120] or "Untitled", + created=datetime.now(timezone.utc).isoformat(timespec="seconds"), + favorite=False, + kind=kind, + params=p.to_dict(), + ) + self.data[category].append(entry) + if category == "recovery": + self.data[category] = self.data[category][-30:] + return entry + + def apply_profile(self, p, ident): + entry = self.entry("profiles", ident) + q = p.to_dict() + q.update({k: entry["params"][k] for k in PROFILE_KEYS[entry["kind"]]}) + return VaseParams.from_dict(q) + + def merge(self, other): + incoming = Workspace(other) + for category in ("designs", "profiles", "recovery"): + ids = {e["id"] for e in self.data[category]} + additions = [e for e in incoming.data[category] if e["id"] not in ids] + if ( + category != "recovery" + and len(self.data[category]) + len(additions) > 500 + ): + raise ValueError("The imported library would exceed 500 entries.") + for category in ("designs", "profiles", "recovery"): + ids = {e["id"] for e in self.data[category]} + self.data[category].extend( + e for e in incoming.data[category] if e["id"] not in ids + ) + self.data["recovery"] = sorted( + self.data["recovery"], key=lambda e: e["created"] + )[-30:] + self.data["locks"] = [ + k for k in LOCKS if k in self.data["locks"] or k in incoming.data["locks"] + ] + + def dumps(self): + return json.dumps(self.data, allow_nan=False) + + def command(self, op, p, payload=None): + payload = payload or {} + result = None + if op == "locks": + self.data["locks"] = [k for k in payload["locks"] if k in LOCKS] + elif op == "save": + self.add( + payload["category"], + p, + payload.get("name"), + payload.get("kind", "printer"), + ) + elif op == "favorite": + e = self.entry("designs", payload["id"]) + e["favorite"] = not e["favorite"] + elif op == "delete": + category = payload["category"] + self.data[category] = [ + e for e in self.data[category] if e["id"] != payload["id"] + ] + elif op == "restore": + category = payload["category"] + result = ( + self.apply_profile(p, payload["id"]) + if category == "profiles" + else VaseParams.from_dict(self.entry(category, payload["id"])["params"]) + ) + elif op == "import": + self.merge(payload) + elif op == "recover": + self.add("recovery", p) + elif op != "read": + raise ValueError("Unknown library action.") + return dict( + workspace=self.data, + params=result.to_dict() if result else None, + locks=list(LOCKS), + thumbnails={ + e["id"]: thumbnail(VaseParams.from_dict(e["params"])) + for e in self.data["designs"] + }, + ) + + +def comparison(d): + """Same original/final side envelope and movement values in both interfaces.""" + if d.errors: + raise ValueError("; ".join(d.errors)) + if d.free: + before = np.linalg.norm(d.P_design, axis=2) + after = np.linalg.norm(d.P, axis=2) + z = d.z + else: + before, after, z = d.R_design, d.R, d.z + old, new = before.max(axis=1), after.max(axis=1) + pick = np.unique(np.linspace(0, len(z) - 1, min(160, len(z))).astype(int)) + scale = 240 / max(float(old.max()), float(new.max()), 1) + + def line(values, side): + return " ".join( + f"{300 + side * float(values[i]) * scale:.2f},{330 - 300 * float(z[i]) / d.H:.2f}" + for i in pick + ) + + paths = [] + for side in (-1, 1): + for values, colour in ((old, "#008fa8"), (new, "#e88224")): + paths.append( + f'' + ) + for i in pick: + if abs(old[i] - new[i]) > 0.05: + y = 330 - 300 * float(z[i]) / d.H + for side in (-1, 1): + paths.append( + f'' + ) + svg = ( + '' + + "".join(paths) + + "" + ) + return dict( + svg=svg, + moved=float(d.softened), + text="Blue: requested surface. Orange: softened surface. Side envelopes are shown; angular changes may overlap. Maximum surface movement: " + + f"{d.softened:.2f} mm.", + ) + + +def calibration_parts(): + """Five closed sockets and matching pins; gaps are radial, in millimetres.""" + from .geometry import grid_side, fan, annulus, edge_check + + angles = np.arange(96) * 2 * math.pi / 96 + parts = [] + for k, gap in enumerate((0.1, 0.2, 0.3, 0.4, 0.5)): + z = np.array([0.0, 3.0]) + v, f, _, first, last = grid_side(z, angles, np.full((2, 96), 8.0)) + vi, fi, _, a, b = grid_side(z, angles, np.full((2, 96), 5 + gap)) + n = len(v) + faces = np.concatenate( + [f, fi[:, ::-1] + n, annulus(last, b + n), annulus(first, a + n)[:, ::-1]] + ) + verts = np.concatenate([v, vi]) + verts[:, 0] += (k - 2) * 19 + assert edge_check(faces)[0] + parts.append( + ( + f"Socket {k + 1}: radial gap {gap:.1f} mm", + verts, + faces, + 1, + (0.1, 0.5, 0.6), + ) + ) + v, f, _, a, b = grid_side(z, angles, np.full((2, 96), 5.0)) + n = len(v) + v = np.concatenate([v, [[0, 0, 0], [0, 0, 3]]]) + f = np.concatenate([f, fan(n, a, False), fan(n + 1, b, True)]) + v[:, 0] += (k - 2) * 19 + v[:, 1] -= 20 + assert edge_check(f)[0] + parts.append((f"Pin {k + 1}: 10 mm diameter", v, f, 1, (0.1, 0.5, 0.6))) + return parts + + +CALIBRATION_HELP = ( + "Print all five socket-and-pin pairs with the material and settings intended for your design. " + "Left to right: 0.1, 0.2, 0.3, 0.4 and 0.5 mm radial clearance (diametral gaps are twice these). " + "Pins are 10 mm across; every piece is 3 mm tall. Try each pin in its socket. " + "Choose the clearance that slides or grips as you need. This is a general fit test, not a lid-latch test; " + "it does not automatically change the design. Keep the pairs in order when arranging them in the slicer." +) diff --git a/web/app.js b/web/app.js index 9336f7a..6b3eaf2 100644 --- a/web/app.js +++ b/web/app.js @@ -1,1106 +1,1186 @@ -// Vaseinator on the web: the desktop app, Simple and Advanced. The vase is built by the app's own Python (in -// worker.js, with Pyodide) - the settings, the rules for which are shown and what each change brings with it, -// and the report beside the view are the desktop app's own - and drawn here with three.js. - -import { CurveEditor } from "./curves.js"; -import { OutlineEditor } from "./outline.js"; -import { drawSide, drawTop } from "./plots.js"; -import { View } from "./view.js"; - -const $ = (id) => document.getElementById(id); - -// what this browser remembers: the design, the printer, the mode and the look - if it may -const store = { - get(key) { - try { return JSON.parse(localStorage.getItem("vaseinator." + key)); } catch { return null; } - }, - set(key, value) { - try { localStorage.setItem("vaseinator." + key, JSON.stringify(value)); } catch { /* private window */ } - }, -}; - -// ================================================================== Python, in two workers -// one builds the vase (which can take a few seconds); the other answers everything else straight away -class Python { - constructor() { - this.worker = new Worker("worker.js"); - this.waiting = new Map(); - this.next = 1; - this.worker.onmessage = (event) => { - const { id, ok, result, error } = event.data; - const w = this.waiting.get(id); - this.waiting.delete(id); - if (ok) w.resolve(result); - else w.reject(new Error(lastLine(error))); - }; - this.worker.onerror = (event) => failed(event.message || "Python stopped"); - } - - call(name, ...args) { - return new Promise((resolve, reject) => { - const id = this.next++; - this.waiting.set(id, { resolve, reject }); - this.worker.postMessage({ id, call: name, args }); - }); - } -} - -function lastLine(text) { - // a Python error comes with its whole traceback: its last line says what went wrong - const lines = String(text).trim().split("\n"); - return lines[lines.length - 1].replace(/^\w*Error: /, ""); -} - -const builder = new Python(); -const helper = new Python(); - -// ================================================================== the design -let C = null; // the settings, choices, constants and help, from Python -let K = null; // C.constants -let params = null; // the design's settings -let meta = null; // what the last build says about it -let shown = new Set(); // the Advanced settings shown for it -const undoStack = []; -const redoStack = []; -let liveFrom = null; // the settings before a drag began, for Undo -let queue = Promise.resolve(); - -function pushUndo(snapshot) { - undoStack.push(snapshot); - if (undoStack.length > 100) undoStack.shift(); - redoStack.length = 0; - undoButtons(); -} - -function undoButtons() { - $("undo").disabled = !undoStack.length; - $("redo").disabled = !redoStack.length; -} - -function status(text) { - $("status").textContent = text || ""; -} - -function needsNormal(p) { - // holes, a face, an open bottom or a wall part way round: spiral vase mode prints one closed line a layer - return p.holes !== 0 || p.open_bottom || p.around < 360 || p.eyes !== 0 || p.mouth !== 0 || p.straps || p.box; -} - -function change(updates) { - // several settings at once, as a picture in Simple mode sets them - if (!C) return; - pushUndo(JSON.stringify(params)); - Object.assign(params, updates); - if (params.vase_mode === K.REAL_VASE && needsNormal(params)) params.vase_mode = K.FAKE_VASE; - settled({}); -} - -function commit(key, value, live = false) { - // one setting, as the desktop app's panel changes it: Python applies the app's own rules - if (!C) return Promise.resolve(); - if (live) { - if (liveFrom === null) liveFrom = JSON.stringify(params); - params[key] = value; - if (key === "silhouette") params.profile = K.OWN_PROFILE; - rebuild({ live: true }); - return Promise.resolve(); - } - queue = queue.then(async () => { - const before = liveFrom !== null ? liveFrom : JSON.stringify(params); - liveFrom = null; - try { - const r = JSON.parse(await helper.call("apply", JSON.stringify(params), key, JSON.stringify(value))); - pushUndo(before); - params = r.params; - shown = new Set(r.shown); - status(r.message); - settled({ known: true }); - } catch (err) { - showProblem(err.message); - } - }); - return queue; -} - -function settled({ refit = false, known = false }) { - store.set("design", params); - refreshSimple(); - refreshAdvanced(); - if (!known) { - helper.call("shown", JSON.stringify(params)).then((keys) => { - shown = new Set(JSON.parse(keys)); - refreshAdvanced(); - }).catch(() => {}); - } - rebuild({ refit }); -} - -function replace(next, refit = true) { - pushUndo(JSON.stringify(params)); - params = next; - settled({ refit }); -} - -function step(from, to) { - if (!from.length) return; - to.push(JSON.stringify(params)); - params = JSON.parse(from.pop()); - undoButtons(); - settled({}); -} - -// ================================================================== building it -let seq = 0; // the newest design asked for -let wanted = null; // the next build to do -let busy = false; -let refit = true; -let refineTimer = 0; - -function rebuild({ refit: fit = false, live = false } = {}) { - seq += 1; - refit = refit || fit; - clearTimeout(refineTimer); - wanted = { seq, draft: true, live, text: JSON.stringify(params) }; - pump(); -} - -async function pump() { - if (busy || !wanted) return; - const job = wanted; - wanted = null; - busy = true; - showBusy(job.draft ? "Building…" : "Adding the fine detail…"); - let result = null; - try { - result = await builder.call("build", job.text, job.draft); - } catch (err) { - showProblem(err.message); - } - busy = false; - if (result && job.seq === seq) { - show(result); - if (job.draft && !job.live) { - // a quick draft first; then, if nothing changes for a moment, the vase in full detail - refineTimer = setTimeout(() => { - if (job.seq === seq) { - wanted = { seq: job.seq, draft: false, text: job.text }; - pump(); - } - }, 600); - } - } - if (wanted) pump(); - else showBusy(null); -} - -function showBusy(text) { - $("busy").hidden = !text; - if (text) $("busytext").textContent = text; -} - -// ================================================================== the view, the pictures, the report -const view = new View($("canvas"), $("viewer"), (text) => { $("hint").textContent = text; }); -window.vaseinator = { view, get params() { return params; }, get meta() { return meta; } }; // for checking it - -function show(result) { - const m = JSON.parse(result.meta); - if (m.errors && m.errors.length) { - $("report").innerHTML = m.report; - flagCrossing(m.errors); - return; - } - flagCrossing([]); - meta = m; - view.show(result, m, refit); - refit = false; - setNumber($("head"), m.head); - setNumber(widgetInput("head"), m.head); - lightTile($("pattern"), m.pattern); - $("report").innerHTML = m.report; - drawPlots(); -} - -function drawPlots() { - if (!meta) return; - drawSide($("sideplot"), meta.plots); - drawTop($("topplot"), meta.plots); -} -new ResizeObserver(drawPlots).observe($("plots")); - -function showProblem(text) { - const p = document.createElement("p"); - p.className = "e"; - p.textContent = `✗ ${text}`; - $("report").prepend(p); -} - -function flagCrossing(errors) { - // a drawn outline that crosses itself: the build says so; the editors show it red themselves - const crossing = errors.some((e) => e.includes("crosses itself")); - document.body.classList.toggle("crossing", crossing); -} - -// ================================================================== Simple: the pictures -const ACCENT = "#F0A356"; -const EDGE = "#966636"; -const DEEP = "#82582E"; -const svg = (body, size = 52) => ``; - -function aboveIcon(outline) { - const pts = outline.map(([x, y]) => `${(26 + 22.9 * x).toFixed(1)},${(26 - 22.9 * y).toFixed(1)}`).join(" "); - return svg(``); -} - -function sideIcon(widths) { - const n = widths.length; - const left = []; - const right = []; - widths.forEach((w, i) => { - const y = 48 - (44 * i) / (n - 1); - left.push(`${(26 - 18.7 * w).toFixed(1)},${y.toFixed(1)}`); - right.unshift(`${(26 + 18.7 * w).toFixed(1)},${y.toFixed(1)}`); - }); - return svg(``); -} - -function patternIcon(name) { - // a little vase body with the pattern drawn on it, as the desktop app draws them - const [l, t, r, b] = [10, 4, 42, 48]; - const lines = []; - const line = (x1, y1, x2, y2) => lines.push(``); - const dots = []; - const holes = []; - const range = (a, z, n) => Array.from({ length: n }, (_, i) => a + ((z - a) * i) / (n - 1)); - if (name === "Ribs") range(l + 4, r - 4, 6).forEach((x) => line(x, t, x, b)); - if (name === "Spiral ribs" || name === "Diamonds") { - range(l - 30, r + 4, 9).forEach((x) => { - line(x, b, x + 26, t); - if (name === "Diamonds") line(x, t, x + 26, b); - }); - } - if (name === "Rings") range(t + 5, b - 5, 6).forEach((y) => line(l, y, r, y)); - if (name === "Bumps") { - range(t + 6, b - 6, 6).forEach((y, row) => - range(l + 5 + (row % 2) * 3, r - 5, 4).forEach((x) => dots.push(``))); - } - if (name === "Lumpy") { - [[18, 12, 5], [31, 18, 6], [21, 30, 7], [34, 37, 5], [17, 44, 4]].forEach(([x, y, rr]) => - dots.push(``)); - } - if (name === "Gem") { - range(t, b, 5).forEach((y) => line(l, y, r, y)); - range(t, b, 5).slice(0, -1).forEach((y, k) => - range(l - 8 + 4 * (k % 2), r, 4).forEach((x) => line(x, y, x + 8, y + (b - t) / 4))); - } - if (name === "Round holes") { - range(t + 7, b - 7, 4).forEach((y, row) => - range(l + 6 + (row % 2) * 4, r - 6, 3).forEach((x) => holes.push(``))); - } - if (name === "Voronoi holes") { - [[[14, 8], [22, 6], [25, 13], [19, 18], [13, 15]], [[28, 9], [35, 7], [38, 15], [31, 19]], - [[13, 22], [21, 22], [23, 30], [15, 33]], [[27, 24], [36, 22], [38, 31], [29, 33]], - [[15, 38], [24, 37], [25, 45], [14, 46]], [[29, 38], [37, 37], [36, 46], [28, 45]]].forEach((cell) => - holes.push(``)); - } - const id = "p" + name.replace(/\W/g, ""); - return svg( - `` + - `${holes.join("")}` + - `` + - `${lines.join("")}${dots.join("")}`); -} - -function tissueIcon(kind) { - // a tissue box cover from above, with its hole for the tissues cut out, as the desktop app draws them - const hole = kind === C.constants.TISSUE_ROUND ? `` - : kind === C.constants.TISSUE_OVAL ? `` - : ``; - return svg(`${hole}` + - ``); -} - -function swatch(colours) { - const n = colours.length; - const id = "s" + colours.join("").replace(/#/g, ""); - const bands = colours.map((c, k) => - ``).join(""); - return ``; -} - -function tiles(box, items, onPick, cls = "tile") { - box.replaceChildren(); - items.forEach(({ label, icon, tip }, k) => { - const b = document.createElement("button"); - b.className = cls; - b.title = tip || label; - b.setAttribute("aria-pressed", "false"); - b.innerHTML = cls === "tile" ? `${icon}` : `${icon}`; - if (cls === "tile") b.querySelector("span").textContent = label; - else b.setAttribute("aria-label", label); - b.addEventListener("click", () => { - lightTile(box, k); - onPick(k); - }); - box.append(b); - }); -} - -function lightTile(box, k) { - [...box.children].forEach((b, i) => b.setAttribute("aria-pressed", String(i === k))); -} - -function widest() { - return Math.max(...params.silhouette.map(([, d]) => d)); -} - -function setNumber(input, value) { - if (input && document.activeElement !== input && value !== undefined && value !== null) input.value = value; -} - -function twistWords(deg) { - const words = { 0: "No twist", 90: "A quarter turn", 180: "Half a turn", 270: "Three quarters of a turn", - 360: "A whole turn", 720: "Two whole turns" }; - for (const [k, w] of Object.entries(words)) if (Math.abs(Math.abs(deg) - Number(k)) < 0.5) return w; - return `${Math.round(deg)}°`; -} - -function titleOf(p) { - const match = C.gallery.find((g) => g.slug === p.name); - if (match) return match.title; - if (p.name === "plain_cylinder") return "Plain cylinder"; - const name = p.name.replace(/_/g, " ").trim(); - return name ? name[0].toUpperCase() + name.slice(1) : "Your vase"; -} - -function refreshSimple() { - const p = params; - $("name").textContent = titleOf(p); - document.title = `${titleOf(p)} – Vaseinator`; - setNumber($("tall"), Math.round(p.height) / 10); - setNumber($("wide"), Math.round(widest()) / 10); - // a mask or helmet is sized by the head; a tissue box cover by the box of tissues, and is a box from every side - $("size").hidden = p.worn || p.box; - $("headrow").hidden = !p.worn || p.box; - $("sidetitle").hidden = p.worn || p.box; - $("side").hidden = p.worn || p.box; - for (const id of ["abovetitle", "above", "drawown", "twisttitle", "twistrow"]) $(id).hidden = p.box; - $("boxrow").hidden = !p.box; - for (const b of document.querySelectorAll(".lidonly")) b.hidden = !p.box; - for (const input of document.querySelectorAll("#boxrow input")) setNumber(input, Math.round(p[input.dataset.key]) / 10); - lightTile($("tissue"), C.tissue_holes.findIndex((t) => t.value === p.tissue_hole)); - lightTile($("above"), C.shapes.findIndex(({ settings: s }) => - p.shape2 === K.SAME && p.shape1 === s.shape1 && (p.shape1 !== K.POLYGON || p.count1 === s.count1))); - lightTile($("side"), p.profile >= 0 && p.profile < C.profiles.length ? p.profile : -1); - const twist = $("twist"); - if (document.activeElement !== twist) twist.value = Math.round(Math.min(Math.max(p.twist, 0), 360) / 15); - $("twistwords").textContent = p.twist_style === K.OWN ? "Your own curve" : twistWords(p.twist); - if (p.colour_style === K.ONE_COLOUR) { - lightTile($("colour"), C.colours.findIndex((c) => c.colour.toLowerCase() === p.color1.toLowerCase())); - } else { - const k = C.many_colours.findIndex((m) => m.settings.colour_style === p.colour_style); - lightTile($("colour"), k < 0 ? -1 : C.colours.length + k); - } - for (const input of document.querySelectorAll("#printer input")) setNumber(input, p[input.dataset.key]); -} - -function buildSimple() { - tiles($("above"), C.shapes.map((s) => ({ label: s.name, icon: aboveIcon(s.outline) })), - (k) => change({ shape2: K.SAME, turn1: 0, ...C.shapes[k].settings })); - tiles($("side"), C.profiles.map((s) => ({ label: s.name, icon: sideIcon(s.widths), tip: s.full })), - (k) => { - const w = widest(); - change({ profile: k, silhouette: C.profiles[k].points.map(([h, f]) => [h, Math.round(f * w * 100) / 100]) }); - }); - tiles($("pattern"), C.patterns.map((s) => ({ label: s.name, icon: patternIcon(s.name) })), - (k) => change({ ...C.no_pattern, ...C.patterns[k].settings })); - const colourItems = C.colours.map((c) => ({ label: c.name, icon: swatch([c.colour]) })) - .concat(C.many_colours.map((m) => ({ label: `${m.name} of colour (${m.palette})`, icon: swatch(m.colours.slice(0, 3)) }))); - tiles($("colour"), colourItems, (k) => { - if (k < C.colours.length) { - change({ colour_style: K.ONE_COLOUR, color1: C.colours[k].colour }); - } else { - const m = C.many_colours[k - C.colours.length]; - change({ ...m.settings, ...Object.fromEntries(m.colours.map((c, i) => [`color${i + 1}`, c])) }); - } - }, "swatch"); - $("tall").addEventListener("change", (e) => { - const cm = parseFloat(e.target.value); - if (cm > 0) change({ height: Math.round(cm * 100) / 10 }); - }); - $("wide").addEventListener("change", (e) => { - const cm = parseFloat(e.target.value); - if (cm > 0) commit("widest", cm * 10); - }); - $("head").addEventListener("change", (e) => { - const cm = parseFloat(e.target.value); - if (cm > 0) fitHead(cm); - }); - tiles($("tissue"), C.tissue_holes.map((t) => ({ label: t.name, icon: tissueIcon(t.value) })), - (k) => commit("tissue_hole", C.tissue_holes[k].value)); - for (const input of document.querySelectorAll("#boxrow input")) { - input.addEventListener("change", () => { - const cm = parseFloat(input.value); - if (cm > 0) commit(input.dataset.key, Math.round(cm * 100) / 10); - }); - } - $("twist").addEventListener("input", (e) => { - $("twistwords").textContent = twistWords(15 * e.target.value); - }); - $("twist").addEventListener("change", (e) => { - const updates = { twist: 15 * Number(e.target.value) }; - if (params.twist_style === K.OWN) updates.twist_style = K.EVENLY; - change(updates); - }); - for (const input of document.querySelectorAll("#printer input")) { - input.addEventListener("change", () => { - const v = parseFloat(input.value); - if (!(v > 0)) return; - change({ [input.dataset.key]: input.dataset.key === "walls" ? Math.round(v) : v }); - rememberPrinter(); - }); - } - $("drawown").addEventListener("click", async () => { - if (params.shape1 !== K.DRAWN) await commit("shape1", K.DRAWN); - openOutline(1); - }); -} - -function rememberPrinter() { - store.set("printer", Object.fromEntries(C.printer_keys.map((k) => [k, params[k]]))); -} - -async function fitHead(cm) { - await withBusy("Fitting it to the head…", async () => - replace(JSON.parse(await helper.call("fit", JSON.stringify(params), cm * 10)), false)); - status(`Sized for a head ${cm} cm round — Undo to go back`); -} - -// ================================================================== Advanced: every setting -const widgets = new Map(); // key -> {rows: [elements], set(p), input} -const editors = {}; // the outline editors in the panel, by k - -function widgetInput(key) { - const w = widgets.get(key); - return w ? w.input : null; -} - -function colourStyleOf(p) { - // a seamless vase prints one filament: no bands or stripes - return p.vase_mode === K.REAL_VASE && (p.colour_style === 1 || p.colour_style === 2) ? K.ONE_COLOUR : p.colour_style; -} - -function numberBox(row, { decimals = 2 } = {}) { - const input = document.createElement("input"); - input.type = "number"; - input.min = row.lo; - input.max = row.hi; - input.step = row.step; - input.dataset.decimals = decimals; - return input; -} - -function makeRow(row, rowsBox) { - const { key, label, kind } = row; - const [unit, none] = String(row.extra || "").split("|"); - const lab = document.createElement("label"); - lab.className = "label"; - lab.textContent = label; - lab.title = row.tip || ""; - const control = document.createElement("div"); - control.className = "control"; - control.title = row.tip || ""; - let set = () => {}; - let input = null; - const whole = ["curve", "expr", "outline"].includes(kind); - if (kind === "float" || kind === "int" || kind === "seed" || kind === "pct" || kind === "widest" || kind === "head") { - const scale = kind === "pct" ? 100 : 1; // a percent is shown ×100; its limits are in percent already - input = numberBox(row); - lab.htmlFor = input.id = `set-${key}`; - control.append(input); - if (unit) { - const u = document.createElement("span"); - u.className = "unit"; - u.textContent = unit.trim(); - control.append(u); - } - let noneLabel = null; - if (none) { - noneLabel = document.createElement("span"); - noneLabel.className = "none"; - noneLabel.textContent = `(${none.toLowerCase()})`; - control.append(noneLabel); - } - if (kind === "seed") { - const dice = document.createElement("button"); - dice.className = "dice"; - dice.textContent = "🎲"; - dice.title = "Another pattern"; - dice.addEventListener("click", () => commit(key, Math.floor(Math.random() * 99999))); - control.append(dice); - } - input.addEventListener("change", () => { - let v = parseFloat(input.value); - if (Number.isNaN(v)) return; - v = Math.min(Math.max(v, row.lo), row.hi) / scale; - if (kind === "int" || kind === "seed") v = Math.round(v); - if (kind === "head") return fitHead(v); - commit(key, v); - }); - set = (p) => { - let v; - if (kind === "widest") v = Math.round(widest() * 10) / 10; - else if (kind === "head") v = meta ? meta.head : 57; - else v = p[key] * scale; - setNumber(input, kind === "pct" ? Math.round(v * 10) / 10 : Math.round(v * 1000) / 1000); - if (noneLabel) noneLabel.hidden = !(p[key] === row.lo); - if (/^count[12]$/.test(key)) input.min = p[`shape${key.slice(-1)}`] === K.POLYGON ? 3 : 1; - }; - } else if (kind === "bool") { - input = document.createElement("input"); - input.type = "checkbox"; - lab.htmlFor = input.id = `set-${key}`; - control.append(input); - input.addEventListener("change", () => commit(key, input.checked)); - set = (p) => { input.checked = Boolean(p[key]); }; - } else if (kind === "choice") { - input = document.createElement("select"); - lab.htmlFor = input.id = `set-${key}`; - const fill = (choices) => { - const now = JSON.stringify(choices); - if (input.dataset.choices === now) return; - input.dataset.choices = now; - input.replaceChildren(...choices.map(([v, t]) => new Option(t, v))); - }; - fill(row.choices); - control.append(input); - input.addEventListener("change", () => commit(key, Number(input.value))); - set = (p) => { - if (key === "colour_style") { - fill(p.vase_mode === K.REAL_VASE ? C.real_colour_styles : C.colour_styles); - input.value = colourStyleOf(p); - } else { - input.value = p[key]; - } - }; - } else if (kind === "color") { - input = document.createElement("input"); - input.type = "color"; - lab.htmlFor = input.id = `set-${key}`; - const hex = document.createElement("span"); - hex.className = "unit"; - control.append(input, hex); - input.addEventListener("change", () => commit(key, input.value.toUpperCase())); - set = (p) => { - input.value = p[key].toLowerCase(); - hex.textContent = p[key].toUpperCase(); - }; - } else if (kind === "slider") { - input = document.createElement("input"); - input.type = "range"; - input.min = row.lo; - input.max = row.hi; - input.step = row.step; - lab.htmlFor = input.id = `set-${key}`; - const note = document.createElement("span"); - note.className = "unit"; - control.append(input, note); - const words = (v) => (Math.abs(v) < 0.05 ? "Even" : v > 0 ? "Thicker towards the top" : "Thicker towards the bottom"); - input.addEventListener("input", () => { - note.textContent = words(Number(input.value)); - commit(key, Number(input.value), true); - }); - input.addEventListener("change", () => commit(key, Number(input.value))); - set = (p) => { - if (document.activeElement !== input) input.value = p[key]; - note.textContent = words(p[key]); - }; - } else if (kind === "palette") { - input = document.createElement("select"); - lab.htmlFor = input.id = `set-${key}`; - input.append(new Option("Choose a palette…", ""), ...C.palettes.map((pal) => new Option(pal.name, pal.name))); - control.append(input); - input.addEventListener("change", () => { - const pal = C.palettes.find((x) => x.name === input.value); - input.value = ""; - if (pal) change(Object.fromEntries(pal.colours.map((c, i) => [`color${i + 1}`, c]))); - }); - } else if (kind === "curve") { - control.className = "whole"; - const canvas = document.createElement("canvas"); - control.append(canvas); - const o = row.curve; - const editor = new CurveEditor(canvas, { lo: o.lo, hi: o.hi, unit: o.unit || "", mirror: Boolean(o.mirror), - height: o.height || 110, floor: o.floor ?? null, grow: Boolean(o.grow), decimals: o.decimals || 0 }, - (pts, live) => commit(key, pts, live)); - set = (p) => editor.setPoints(p[key]); - } else if (kind === "expr") { - control.className = "whole"; - const text = document.createElement("textarea"); - text.spellcheck = false; - text.placeholder = "1.5 * sin(6*theta + 4*pi*h)"; - text.setAttribute("aria-label", "Your own function"); - input = text; - const tools = document.createElement("div"); - tools.className = "row"; - const examples = document.createElement("select"); - examples.append(new Option("Examples…", ""), ...C.examples.map(([n, f]) => new Option(`${n}: ${f}`, f))); - const help = document.createElement("button"); - help.textContent = "Help…"; - const clear = document.createElement("button"); - clear.textContent = "Clear"; - tools.append(examples, help, clear); - control.append(text, tools); - let timer = 0; - text.addEventListener("input", () => { - clearTimeout(timer); - timer = setTimeout(() => commit(key, text.value), 600); - }); - examples.addEventListener("change", () => { - if (examples.value) setFormula(examples.value); - examples.value = ""; - }); - help.addEventListener("click", () => openHelp("function")); - clear.addEventListener("click", () => setFormula("")); - set = (p) => { if (document.activeElement !== text && text.value !== p[key]) text.value = p[key]; }; - } else if (kind === "outline") { - control.className = "whole outlinebox"; - const k = Number(key.slice(-1)); - const canvas = document.createElement("canvas"); - const tools = document.createElement("div"); - tools.className = "row"; - const start = startFromMenu(k); - const close = document.createElement("button"); - close.textContent = "Close up"; - close.title = "Show one repeat close up, turned to point upwards - or the whole outline. Above 8 repeats it starts close up."; - const big = document.createElement("button"); - big.textContent = "Full window…"; - big.title = "Draw it in a window as big as the screen: the wheel zooms, a right-drag moves it."; - tools.append(start, close, big); - control.append(canvas, tools); - const editor = new OutlineEditor(canvas, {}, (pts, live) => commit(key, pts, live), - () => close.setAttribute("aria-pressed", String(editor.closeUp()))); - editors[k] = editor; - close.addEventListener("click", () => editor.setCloseUp(!editor.closeUp())); - big.addEventListener("click", () => openOutline(k)); - set = (p) => editor.setOutline(p[key], p[`repeat${k}`]); - } - if (whole) { - if (label) { - lab.className = "label wholelabel"; - rowsBox.append(lab); - } - rowsBox.append(control); - widgets.set(key, { rows: label ? [lab, control] : [control], set, input, lab }); - } else { - rowsBox.append(lab, control); - widgets.set(key, { rows: [lab, control], set, input, lab }); - } -} - -function startFromMenu(k) { - const start = document.createElement("select"); - start.setAttribute("aria-label", "Start from"); - start.title = "Start again from a ready-made outline, or trace one from a picture: dark lines drawn round a shape on a light or see-through ground."; - start.append(new Option("Start from…", ""), ...C.outlines.map((o, i) => new Option(o.name, String(i))), - new Option("Trace a picture…", "trace")); - start.addEventListener("change", () => { - const v = start.value; - start.value = ""; - if (v === "trace") tracePicture(k); - else if (v !== "") useOutline(k, C.outlines[Number(v)].points, C.outlines[Number(v)].repeat); - }); - return start; -} - -function useOutline(k, points, repeat) { - // start drawn outline k again from given control points - liveFrom = JSON.stringify(params); - params[`repeat${k}`] = repeat; - return commit(`drawn${k}`, points); -} - -let traceFor = 1; -function tracePicture(k) { - traceFor = k; - $("picturefile").click(); -} - -async function traceFile(file) { - await withBusy("Tracing the picture…", async () => { - const bitmap = await createImageBitmap(file); - const scale = Math.min(1, 1080 / Math.max(bitmap.width, bitmap.height)); - const w = Math.max(Math.round(bitmap.width * scale), 1); - const h = Math.max(Math.round(bitmap.height * scale), 1); - const canvas = new OffscreenCanvas(w, h); - const g = canvas.getContext("2d"); - g.drawImage(bitmap, 0, 0, w, h); - const pixels = g.getImageData(0, 0, w, h).data; - let text = ""; - for (let i = 0; i < pixels.length; i += 0x8000) text += String.fromCharCode(...pixels.subarray(i, i + 0x8000)); - const pts = JSON.parse(await helper.call("trace", btoa(text), w, h)); - await useOutline(traceFor, pts, 1); - status(`Traced ${file.name}: ${pts.length} points, ${pts.filter((q) => q[2]).length} sharp corners.`); - }).catch(() => {}); -} - -function setFormula(text) { - const w = widgets.get("expression"); - if (w && w.input) w.input.value = text; - commit("expression", text); -} - -function buildAdvanced() { - const box = $("groups"); - box.replaceChildren(); - for (const group of C.spec) { - const section = document.createElement("details"); - section.className = "group"; - section.open = !store.get(`closed.${group.title}`); - section.addEventListener("toggle", () => store.set(`closed.${group.title}`, !section.open)); - const summary = document.createElement("summary"); - summary.textContent = group.title; - const rows = document.createElement("div"); - rows.className = "rows"; - section.append(summary, rows); - box.append(section); - group.rows.forEach((row) => makeRow(row, rows)); - group.element = section; - } - $("designname").addEventListener("change", (e) => commit("name", e.target.value.trim() || "vase")); -} - -function refreshAdvanced() { - if (!C || !params) return; - const p = params; - setNumber($("designname"), p.name); - for (const [key, w] of widgets) { - const show = shown.size === 0 || shown.has(key); - w.rows.forEach((el) => { el.hidden = !show; }); - if (show) w.set(p); - } - for (const group of C.spec) group.element.hidden = !group.rows.some((r) => shown.size === 0 || shown.has(r.key)); - // the settings named for the shape: sides, points, petals, teeth - for (const k of [1, 2]) { - const names = C.shape_labels[String(p[`shape${k}`])] || [null, null, null]; - ["count", "depth", "sharp"].forEach((what, i) => { - const w = widgets.get(`${what}${k}`); - if (w && names[i]) w.lab.textContent = names[i]; - }); - } - const c1 = widgets.get("color1"); - if (c1) c1.lab.textContent = colourStyleOf(p) === K.ONE_COLOUR ? "Colour" : "Filament 1"; - if (outlineWindow.k) { - const k = outlineWindow.k; - outlineWindow.editor.setOutline(p[`drawn${k}`], p[`repeat${k}`]); - setNumber($("winrepeat"), p[`repeat${k}`]); - } -} - -function setMode(advanced) { - $("simple").hidden = advanced; - $("advanced").hidden = !advanced; - $("simplebtn").setAttribute("aria-selected", String(!advanced)); - $("advancedbtn").setAttribute("aria-selected", String(advanced)); - store.set("advanced", advanced); - if (advanced) refreshAdvanced(); -} - -// ================================================================== drawing in a window of its own -const outlineWindow = { k: 0, editor: null }; - -function openOutline(k) { - const win = $("outlinewin"); - outlineWindow.k = k; - $("outlinetitle").textContent = k === 1 ? "Drawing the cross-section" : "Drawing what it turns into"; - $("winrepeat").value = params[`repeat${k}`]; - win.showModal(); - outlineWindow.editor.view = null; - outlineWindow.editor.setOutline(params[`drawn${k}`], params[`repeat${k}`]); - $("bigoutline").focus(); -} - -function buildOutlineWindow() { - const close = $("winclose"); - outlineWindow.editor = new OutlineEditor($("bigoutline"), { big: true }, - (pts, live) => commit(`drawn${outlineWindow.k}`, pts, live), - () => close.setAttribute("aria-pressed", String(outlineWindow.editor.closeUp()))); - close.addEventListener("click", () => outlineWindow.editor.setCloseUp(!outlineWindow.editor.closeUp())); - $("winfit").addEventListener("click", () => outlineWindow.editor.fit()); - $("windone").addEventListener("click", () => $("outlinewin").close()); - $("outlinewin").addEventListener("close", () => { outlineWindow.k = 0; }); - $("winrepeat").addEventListener("change", (e) => { - const n = Math.min(Math.max(Math.round(Number(e.target.value)), 1), 100); - if (n) commit(`repeat${outlineWindow.k}`, n); - }); - const start = $("winstart"); - start.append(new Option("Start from…", ""), ...C.outlines.map((o, i) => new Option(o.name, String(i))), - new Option("Trace a picture…", "trace")); - start.addEventListener("change", () => { - const v = start.value; - start.value = ""; - if (v === "trace") tracePicture(outlineWindow.k); - else if (v !== "") useOutline(outlineWindow.k, C.outlines[Number(v)].points, C.outlines[Number(v)].repeat); - }); -} - -// ================================================================== the gallery, help and files -function buildGallery() { - const designs = $("designs"); - const entries = [{ title: "Plain cylinder", slug: "plain_cylinder", - about: "200 mm across and 200 mm tall, with everything else off: a blank start for your own shape or function" }] - .concat(C.gallery); - for (const g of entries) { - const b = document.createElement("button"); - b.className = "design"; - b.title = g.about; - b.innerHTML = ``; - b.querySelector("b").textContent = g.title; - b.addEventListener("click", () => { - $("gallery").close(); - chooseDesign(g.title); - }); - designs.append(b); - } - const menu = $("designsmenu"); - for (const g of C.gallery) { - const b = document.createElement("button"); - b.textContent = g.title; - b.title = g.about; - b.addEventListener("click", () => { - closeMenus(); - chooseDesign(g.title); - }); - menu.append(b); - } -} - -function chooseDesign(title) { - return withBusy("Getting it ready…", async () => - replace(JSON.parse(await helper.call("design_of", title, JSON.stringify(params))))); -} - -function openHelp(which) { - const titles = { printing: "Printing it watertight", function: "Your own function", drawing: "Drawing a cross-section", - about: `About Vaseinator ${C.version}` }; - $("helptitle").textContent = titles[which]; - $("helptext").innerHTML = C.help[which]; - $("helpexamples").hidden = which !== "function"; - $("help").showModal(); -} - -async function withBusy(text, work) { - showBusy(text); - try { - await work(); - } catch (err) { - showProblem(err.message); - throw err; - } finally { - if (!busy) showBusy(null); - } -} - -function download(data, name, type) { - const url = URL.createObjectURL(data instanceof Blob ? data : new Blob([data], { type })); - const a = document.createElement("a"); - a.href = url; - a.download = name; - document.body.append(a); - a.click(); - a.remove(); - setTimeout(() => URL.revokeObjectURL(url), 10000); -} - -function offer(data, name, type, title, steps = "") { - // a file that took a while to make: a browser only lets a click save a file straight away, so the file is - // offered with a button of its own to click - const link = $("stepsfile"); - if (link.href) URL.revokeObjectURL(link.href); - link.href = URL.createObjectURL(new Blob([data], { type })); - link.download = name; - link.textContent = `Save ${name}`; - $("stepstitle").textContent = title; - $("steplist").innerHTML = steps; - $("steps").showModal(); - link.focus(); -} - -function fileName() { - return (params.name || "vase").replace(/[^\w\- ]/g, "_").trim() || "vase"; -} - -// ================================================================== the menus and buttons -let currentView = 0; - -function setView(n) { - currentView = n; - view.setMode(n); - document.querySelectorAll(".views button").forEach((b) => b.setAttribute("aria-pressed", String(Number(b.dataset.view) === n))); -} - -function toggle(id, on) { - $(id).setAttribute("aria-pressed", String(on)); -} - -function effectiveLight() { - const t = document.documentElement.dataset.theme; - return t ? t === "light" : window.matchMedia("(prefers-color-scheme: light)").matches; -} - -const actions = { - new: () => replace({ ...C.defaults, ...Object.fromEntries(C.printer_keys.map((k) => [k, params[k]])) }), - plain: () => chooseDesign("Plain cylinder"), - open: () => $("openfile").click(), - savevase: async () => download(await helper.call("save_vase", JSON.stringify(params)), fileName() + ".vase", "application/json"), - print: () => withBusy("Making the file for your slicer…", async () => { - const { data, steps } = await builder.call("export_3mf", JSON.stringify(params)); - offer(data, fileName() + ".3mf", "model/3mf", "How to print it", steps); - }).catch(() => {}), - lid: () => withBusy("Making the lid…", async () => { - const { data, steps } = await builder.call("export_lid", JSON.stringify(params)); - offer(data, fileName() + " lid.3mf", "model/3mf", "How to print the lid", steps); - }).catch(() => {}), - stl: () => withBusy("Making the STL…", async () => - offer(await builder.call("export_stl", JSON.stringify(params)), fileName() + ".stl", "model/stl", - "Your STL is ready")).catch(() => {}), - "3mf": () => withBusy("Making the 3MF…", async () => { - const { data } = await builder.call("export_3mf", JSON.stringify(params)); - offer(data, fileName() + ".3mf", "model/3mf", "Your 3MF is ready", - "

In your slicer, open it with File ▸ Import.

"); - }).catch(() => {}), - picture: async () => download(await view.picture(), fileName() + "_3d.png", "image/png"), - undo: () => step(undoStack, redoStack), - redo: () => step(redoStack, undoStack), - surprise: () => withBusy("Thinking of something…", async () => - replace(JSON.parse(await helper.call("surprise_me", JSON.stringify(params), Math.floor(Math.random() * 1e6))))).catch(() => {}), - plan: async () => { - if (!meta) return; - try { - await navigator.clipboard.writeText(meta.plan); - status("Colour changes copied to the clipboard"); - } catch { - status("The browser didn't allow copying: the colour changes are in the panel"); - } - }, - gallery: () => $("gallery").showModal(), - view0: () => setView(0), - view1: () => setView(1), - view2: () => setView(2), - view3: () => setView(3), - spin: () => { - const on = $("spin").getAttribute("aria-pressed") !== "true"; - view.setSpin(on); - toggle("spin", on); - }, - printing: () => { - const on = $("printing").getAttribute("aria-pressed") !== "true"; - view.setPrinting(on); - toggle("printing", on); - }, - reset: () => view.reset(), - bigger: () => { - const on = $("bigger").getAttribute("aria-pressed") !== "true"; - document.documentElement.style.setProperty("--scale", on ? "1.25" : "1"); - toggle("bigger", on); - store.set("bigger", on); - }, - theme: () => { - const light = !effectiveLight(); - document.documentElement.dataset.theme = light ? "light" : "dark"; - store.set("theme", light ? "light" : "dark"); - drawPlots(); - Object.values(editors).forEach((e) => e.draw()); - for (const w of widgets.values()) if (w.set) w.set(params); - }, - "help-printing": () => openHelp("printing"), - "help-function": () => openHelp("function"), - "help-drawing": () => openHelp("drawing"), - "help-about": () => openHelp("about"), -}; - -function closeMenus(except) { - document.querySelectorAll(".menu[open]").forEach((m) => { if (m !== except) m.open = false; }); -} - -function wire() { - document.addEventListener("click", (e) => { - const b = e.target.closest("[data-do]"); - if (b) { - closeMenus(); - if (C && actions[b.dataset.do]) actions[b.dataset.do](); - return; - } - if (!e.target.closest(".menu")) closeMenus(); - }); - document.querySelectorAll(".menu").forEach((m) => m.addEventListener("toggle", () => { if (m.open) closeMenus(m); })); - document.querySelectorAll(".views button").forEach((b) => b.addEventListener("click", () => setView(Number(b.dataset.view)))); - $("simplebtn").addEventListener("click", () => setMode(false)); - $("advancedbtn").addEventListener("click", () => setMode(true)); - $("openfile").addEventListener("change", async (e) => { - const file = e.target.files[0]; - e.target.value = ""; - if (!file) return; - await withBusy("Opening it…", async () => replace(JSON.parse(await helper.call("open_vase", await file.text())))).catch(() => {}); - }); - $("picturefile").addEventListener("change", (e) => { - const file = e.target.files[0]; - e.target.value = ""; - if (file) traceFile(file); - }); - $("useexample").addEventListener("click", () => { - setFormula($("examplepick").value); - $("help").close(); - }); - document.addEventListener("keydown", (e) => { - if (!C) return; - const typing = e.target.matches("input, textarea, select"); - if (e.ctrlKey || e.metaKey) { - const key = e.key.toLowerCase(); - const act = { z: e.shiftKey ? "redo" : "undo", y: "redo", s: "savevase", o: "open", g: "gallery" }[key]; - if (!act || (typing && (key === "z" || key === "y"))) return; - e.preventDefault(); - actions[act](); - } else if (!typing && !e.target.matches("canvas") && (e.key === "s" || e.key === "S")) { - actions.spin(); - } - }); - const theme = store.get("theme"); - if (theme) document.documentElement.dataset.theme = theme; - if (store.get("bigger")) actions.bigger(); -} - -// ================================================================== starting up -function failed(text) { - $("loading").hidden = false; - $("loading").classList.add("failed"); - $("loadingtext").textContent = `Vaseinator couldn't start: ${text}. Check the internet connection, then reload ` + - "the page."; -} - -async function boot() { - wire(); - try { - const [choices] = await Promise.all([helper.call("start"), builder.call("start")]); - C = JSON.parse(choices); - } catch (err) { - failed(err.message); - return; - } - K = C.constants; - $("examplepick").append(...C.examples.map(([n, f]) => new Option(`${n}: ${f}`, f))); - buildSimple(); - buildAdvanced(); - buildOutlineWindow(); - buildGallery(); - const printer = store.get("printer") || {}; - params = { ...C.defaults, ...(store.get("design") || {}), ...printer }; - $("loading").hidden = true; - undoButtons(); - setMode(Boolean(store.get("advanced"))); - settled({ refit: true }); -} - -boot(); +// Vaseinator on the web: the desktop app, Simple and Advanced. The vase is built by the app's own Python (in +// worker.js, with Pyodide) - the settings, the rules for which are shown and what each change brings with it, +// and the report beside the view are the desktop app's own - and drawn here with three.js. + +import { CurveEditor } from "./curves.js"; +import { OutlineEditor } from "./outline.js"; +import { drawSide, drawTop } from "./plots.js"; +import { View } from "./view.js"; +import { libraryDialog } from "./library.js"; + +const $ = (id) => document.getElementById(id); + +// what this browser remembers: the design, the printer, the mode and the look - if it may +const store = { + get(key) { + try { return JSON.parse(localStorage.getItem("vaseinator." + key)); } catch { return null; } + }, + set(key, value) { + try { localStorage.setItem("vaseinator." + key, JSON.stringify(value)); } catch { /* private window */ } + }, +}; + +// ================================================================== Python, in two workers +// one builds the vase (which can take a few seconds); the other answers everything else straight away +class Python { + constructor() { + this.worker = new Worker("worker.js"); + this.waiting = new Map(); + this.next = 1; + this.worker.onmessage = (event) => { + const { id, ok, result, error } = event.data; + const w = this.waiting.get(id); + this.waiting.delete(id); + if (ok) w.resolve(result); + else w.reject(new Error(lastLine(error))); + }; + this.worker.onerror = (event) => { + for (const w of this.waiting.values()) w.reject(new Error(event.message || "Python stopped")); + this.waiting.clear(); + failed(event.message || "Python stopped"); + }; + } + + call(name, ...args) { + return new Promise((resolve, reject) => { + const id = this.next++; + this.waiting.set(id, { resolve, reject }); + this.worker.postMessage({ id, call: name, args }); + }); + } +} + +function lastLine(text) { + // a Python error comes with its whole traceback: its last line says what went wrong + const lines = String(text).trim().split("\n"); + return lines[lines.length - 1].replace(/^\w*Error: /, ""); +} + +const builder = new Python(); +const helper = new Python(); + +// ================================================================== the design +let C = null; // the settings, choices, constants and help, from Python +let K = null; // C.constants +let params = null; // the design's settings +let libraryState = store.get("library"); +let recoveryTimer = 0; +let meta = null; // what the last build says about it +let shown = new Set(); // the Advanced settings shown for it +const undoStack = []; +const redoStack = []; +let liveFrom = null; // the settings before a drag began, for Undo +let queue = Promise.resolve(); +let pendingText = null; +function flushText() { + if (pendingText) pendingText(); +} +function enqueue(work) { + const result = queue.then(work); + queue = result.catch((err) => showProblem(err.message)); + return queue; +} + +function pushUndo(snapshot) { + undoStack.push(snapshot); + if (undoStack.length > 100) undoStack.shift(); + redoStack.length = 0; + undoButtons(); +} + +function undoButtons() { + $("undo").disabled = !undoStack.length; + $("redo").disabled = !redoStack.length; +} + +function status(text) { + $("status").textContent = text || ""; +} + +function needsNormal(p) { + // holes, a face, an open bottom or a wall part way round: spiral vase mode prints one closed line a layer + return p.holes !== 0 || p.open_bottom || p.around < 360 || p.eyes !== 0 || p.mouth !== 0 || p.straps || p.box; +} + +function change(updates) { return enqueue(() => changeNow(updates)); } +function changeNow(updates) { + // several settings at once, as a picture in Simple mode sets them + if (!C) return; + pushUndo(JSON.stringify(params)); + Object.assign(params, typeof updates === "function" ? updates(params) : updates); + if (params.vase_mode === K.REAL_VASE && needsNormal(params)) params.vase_mode = K.FAKE_VASE; + settled({}); +} + +function commit(key, value, live = false) { return enqueue(() => commitNow(key, value, live)); } +async function commitNow(key, value, live = false) { + // one setting, as the desktop app's panel changes it: Python applies the app's own rules + if (!C) return Promise.resolve(); + if (live) { + if (liveFrom === null) liveFrom = JSON.stringify(params); + params[key] = value; + if (key === "silhouette") params.profile = K.OWN_PROFILE; + rebuild({ live: true }); + return Promise.resolve(); + } + { + const before = liveFrom !== null ? liveFrom : JSON.stringify(params); + liveFrom = null; + try { + const r = JSON.parse(await helper.call("apply", JSON.stringify(params), key, JSON.stringify(value))); + pushUndo(before); + params = r.params; + shown = new Set(r.shown); + status(r.message); + settled({ known: true }); + } catch (err) { + showProblem(err.message); + } + } +} + +function settled({ refit = false, known = false }) { + store.set("design", params); + rememberPrinter(); + clearTimeout(recoveryTimer); + const snapshot = JSON.stringify(params); + recoveryTimer = setTimeout(() => enqueue(() => libraryWork("recover", {}, snapshot)), 1500); + refreshSimple(); + refreshAdvanced(); + if (!known) { + const snapshot = JSON.stringify(params); + helper.call("shown", snapshot).then((keys) => { + if (JSON.stringify(params) !== snapshot) return; + shown = new Set(JSON.parse(keys)); + refreshAdvanced(); + }).catch(() => {}); + } + rebuild({ refit }); +} + +function replace(next, refit = true) { return enqueue(() => replaceNow(next, refit)); } +function replaceNow(next, refit = true) { + pushUndo(JSON.stringify(params)); + params = next; + settled({ refit }); +} + +function step(from, to) { return enqueue(() => stepNow(from, to)); } +function stepNow(from, to) { + if (!from.length) return; + to.push(JSON.stringify(params)); + params = JSON.parse(from.pop()); + undoButtons(); + settled({}); +} + +// ================================================================== building it +let seq = 0; // the newest design asked for +let wanted = null; // the next build to do +let busy = false; +let refit = true; +let refineTimer = 0; + +function rebuild({ refit: fit = false, live = false } = {}) { + seq += 1; + refit = refit || fit; + clearTimeout(refineTimer); + wanted = { seq, draft: true, live, text: JSON.stringify(params) }; + pump(); +} + +async function pump() { + if (busy || !wanted) return; + const job = wanted; + wanted = null; + busy = true; + showBusy(job.draft ? "Building…" : "Adding the fine detail…"); + let result = null; + try { + result = await builder.call("build", job.text, job.draft); + } catch (err) { + showProblem(err.message); + } + busy = false; + if (result && job.seq === seq) { + show(result); + if (job.draft && !job.live) { + // a quick draft first; then, if nothing changes for a moment, the vase in full detail + refineTimer = setTimeout(() => { + if (job.seq === seq) { + wanted = { seq: job.seq, draft: false, text: job.text }; + pump(); + } + }, 600); + } + } + if (wanted) pump(); + else showBusy(null); +} + +function showBusy(text) { + $("busy").hidden = !text; + if (text) $("busytext").textContent = text; +} + +// ================================================================== the view, the pictures, the report +const view = new View($("canvas"), $("viewer"), (text) => { $("hint").textContent = text; }); +window.vaseinator = { view, get params() { return params; }, get meta() { return meta; } }; // for checking it + +function show(result) { + const m = JSON.parse(result.meta); + if (m.errors && m.errors.length) { + $("report").innerHTML = m.report; + flagCrossing(m.errors); + return; + } + flagCrossing([]); + meta = m; + view.show(result, m, refit); + refit = false; + setNumber($("head"), m.head); + setNumber(widgetInput("head"), m.head); + lightTile($("pattern"), m.pattern); + $("report").innerHTML = m.report; + drawPlots(); +} + +function drawPlots() { + if (!meta) return; + drawSide($("sideplot"), meta.plots); + drawTop($("topplot"), meta.plots); +} +new ResizeObserver(drawPlots).observe($("plots")); + +function showProblem(text) { + const p = document.createElement("p"); + p.className = "e"; + p.textContent = `✗ ${text}`; + $("report").prepend(p); +} + +function flagCrossing(errors) { + // a drawn outline that crosses itself: the build says so; the editors show it red themselves + const crossing = errors.some((e) => e.includes("crosses itself")); + document.body.classList.toggle("crossing", crossing); +} + +// ================================================================== Simple: the pictures +const ACCENT = "#F0A356"; +const EDGE = "#966636"; +const DEEP = "#82582E"; +const svg = (body, size = 52) => ``; + +function aboveIcon(outline) { + const pts = outline.map(([x, y]) => `${(26 + 22.9 * x).toFixed(1)},${(26 - 22.9 * y).toFixed(1)}`).join(" "); + return svg(``); +} + +function sideIcon(widths) { + const n = widths.length; + const left = []; + const right = []; + widths.forEach((w, i) => { + const y = 48 - (44 * i) / (n - 1); + left.push(`${(26 - 18.7 * w).toFixed(1)},${y.toFixed(1)}`); + right.unshift(`${(26 + 18.7 * w).toFixed(1)},${y.toFixed(1)}`); + }); + return svg(``); +} + +function patternIcon(name) { + // a little vase body with the pattern drawn on it, as the desktop app draws them + const [l, t, r, b] = [10, 4, 42, 48]; + const lines = []; + const line = (x1, y1, x2, y2) => lines.push(``); + const dots = []; + const holes = []; + const range = (a, z, n) => Array.from({ length: n }, (_, i) => a + ((z - a) * i) / (n - 1)); + if (name === "Ribs") range(l + 4, r - 4, 6).forEach((x) => line(x, t, x, b)); + if (name === "Spiral ribs" || name === "Diamonds") { + range(l - 30, r + 4, 9).forEach((x) => { + line(x, b, x + 26, t); + if (name === "Diamonds") line(x, t, x + 26, b); + }); + } + if (name === "Rings") range(t + 5, b - 5, 6).forEach((y) => line(l, y, r, y)); + if (name === "Bumps") { + range(t + 6, b - 6, 6).forEach((y, row) => + range(l + 5 + (row % 2) * 3, r - 5, 4).forEach((x) => dots.push(``))); + } + if (name === "Lumpy") { + [[18, 12, 5], [31, 18, 6], [21, 30, 7], [34, 37, 5], [17, 44, 4]].forEach(([x, y, rr]) => + dots.push(``)); + } + if (name === "Gem") { + range(t, b, 5).forEach((y) => line(l, y, r, y)); + range(t, b, 5).slice(0, -1).forEach((y, k) => + range(l - 8 + 4 * (k % 2), r, 4).forEach((x) => line(x, y, x + 8, y + (b - t) / 4))); + } + if (name === "Round holes") { + range(t + 7, b - 7, 4).forEach((y, row) => + range(l + 6 + (row % 2) * 4, r - 6, 3).forEach((x) => holes.push(``))); + } + if (name === "Voronoi holes") { + [[[14, 8], [22, 6], [25, 13], [19, 18], [13, 15]], [[28, 9], [35, 7], [38, 15], [31, 19]], + [[13, 22], [21, 22], [23, 30], [15, 33]], [[27, 24], [36, 22], [38, 31], [29, 33]], + [[15, 38], [24, 37], [25, 45], [14, 46]], [[29, 38], [37, 37], [36, 46], [28, 45]]].forEach((cell) => + holes.push(``)); + } + const id = "p" + name.replace(/\W/g, ""); + return svg( + `` + + `${holes.join("")}` + + `` + + `${lines.join("")}${dots.join("")}`); +} + +function tissueIcon(kind) { + // a tissue box cover from above, with its hole for the tissues cut out, as the desktop app draws them + const hole = kind === C.constants.TISSUE_ROUND ? `` + : kind === C.constants.TISSUE_OVAL ? `` + : ``; + return svg(`${hole}` + + ``); +} + +function swatch(colours) { + const n = colours.length; + const id = "s" + colours.join("").replace(/#/g, ""); + const bands = colours.map((c, k) => + ``).join(""); + return ``; +} + +function tiles(box, items, onPick, cls = "tile") { + box.replaceChildren(); + items.forEach(({ label, icon, tip }, k) => { + const b = document.createElement("button"); + b.className = cls; + b.title = tip || label; + b.setAttribute("aria-pressed", "false"); + b.innerHTML = cls === "tile" ? `${icon}` : `${icon}`; + if (cls === "tile") b.querySelector("span").textContent = label; + else b.setAttribute("aria-label", label); + b.addEventListener("click", () => { + lightTile(box, k); + onPick(k); + }); + box.append(b); + }); +} + +function lightTile(box, k) { + [...box.children].forEach((b, i) => b.setAttribute("aria-pressed", String(i === k))); +} + +function widest() { + return Math.max(...params.silhouette.map(([, d]) => d)); +} + +function setNumber(input, value) { + if (input && document.activeElement !== input && value !== undefined && value !== null) input.value = value; +} + +function twistWords(deg) { + const words = { 0: "No twist", 90: "A quarter turn", 180: "Half a turn", 270: "Three quarters of a turn", + 360: "A whole turn", 720: "Two whole turns" }; + for (const [k, w] of Object.entries(words)) if (Math.abs(Math.abs(deg) - Number(k)) < 0.5) return w; + return `${Math.round(deg)}°`; +} + +function titleOf(p) { + const match = C.gallery.find((g) => g.slug === p.name); + if (match) return match.title; + if (p.name === "plain_cylinder") return "Plain cylinder"; + const name = p.name.replace(/_/g, " ").trim(); + return name ? name[0].toUpperCase() + name.slice(1) : "Your vase"; +} + +function refreshSimple() { + const p = params; + $("name").textContent = titleOf(p); + document.title = `${titleOf(p)} – Vaseinator`; + setNumber($("tall"), Math.round(p.height) / 10); + setNumber($("wide"), Math.round(widest()) / 10); + // a mask or helmet is sized by the head; a tissue box cover by the box of tissues, and is a box from every side + $("size").hidden = p.worn || p.box; + $("headrow").hidden = !p.worn || p.box; + $("sidetitle").hidden = p.worn || p.box; + $("side").hidden = p.worn || p.box; + for (const id of ["abovetitle", "above", "drawown", "twisttitle", "twistrow"]) $(id).hidden = p.box; + $("boxrow").hidden = !p.box; + for (const b of document.querySelectorAll(".lidonly")) b.hidden = !p.box; + for (const input of document.querySelectorAll("#boxrow input")) setNumber(input, Math.round(p[input.dataset.key]) / 10); + lightTile($("tissue"), C.tissue_holes.findIndex((t) => t.value === p.tissue_hole)); + lightTile($("above"), C.shapes.findIndex(({ settings: s }) => + p.shape2 === K.SAME && p.shape1 === s.shape1 && (p.shape1 !== K.POLYGON || p.count1 === s.count1))); + lightTile($("side"), p.profile >= 0 && p.profile < C.profiles.length ? p.profile : -1); + const twist = $("twist"); + if (document.activeElement !== twist) twist.value = Math.round(Math.min(Math.max(p.twist, 0), 360) / 15); + $("twistwords").textContent = p.twist_style === K.OWN ? "Your own curve" : twistWords(p.twist); + if (p.colour_style === K.ONE_COLOUR) { + lightTile($("colour"), C.colours.findIndex((c) => c.colour.toLowerCase() === p.color1.toLowerCase())); + } else { + const k = C.many_colours.findIndex((m) => m.settings.colour_style === p.colour_style); + lightTile($("colour"), k < 0 ? -1 : C.colours.length + k); + } + for (const input of document.querySelectorAll("#printer input")) setNumber(input, p[input.dataset.key]); +} + +function buildSimple() { + tiles($("above"), C.shapes.map((s) => ({ label: s.name, icon: aboveIcon(s.outline) })), + (k) => change({ shape2: K.SAME, turn1: 0, ...C.shapes[k].settings })); + tiles($("side"), C.profiles.map((s) => ({ label: s.name, icon: sideIcon(s.widths), tip: s.full })), + (k) => change(() => { + const w = widest(); + return { profile: k, silhouette: C.profiles[k].points.map(([h, f]) => [h, Math.round(f * w * 100) / 100]) }; + })); + tiles($("pattern"), C.patterns.map((s) => ({ label: s.name, icon: patternIcon(s.name) })), + (k) => change({ ...C.no_pattern, ...C.patterns[k].settings })); + const colourItems = C.colours.map((c) => ({ label: c.name, icon: swatch([c.colour]) })) + .concat(C.many_colours.map((m) => ({ label: `${m.name} of colour (${m.palette})`, icon: swatch(m.colours.slice(0, 3)) }))); + tiles($("colour"), colourItems, (k) => { + if (k < C.colours.length) { + change({ colour_style: K.ONE_COLOUR, color1: C.colours[k].colour }); + } else { + const m = C.many_colours[k - C.colours.length]; + change({ ...m.settings, ...Object.fromEntries(m.colours.map((c, i) => [`color${i + 1}`, c])) }); + } + }, "swatch"); + $("tall").addEventListener("change", (e) => { + const cm = parseFloat(e.target.value); + if (cm > 0) change({ height: Math.round(cm * 100) / 10 }); + }); + $("wide").addEventListener("change", (e) => { + const cm = parseFloat(e.target.value); + if (cm > 0) commit("widest", cm * 10); + }); + $("head").addEventListener("change", (e) => { + const cm = parseFloat(e.target.value); + if (cm > 0) fitHead(cm); + }); + tiles($("tissue"), C.tissue_holes.map((t) => ({ label: t.name, icon: tissueIcon(t.value) })), + (k) => commit("tissue_hole", C.tissue_holes[k].value)); + for (const input of document.querySelectorAll("#boxrow input")) { + input.addEventListener("change", () => { + const cm = parseFloat(input.value); + if (cm > 0) commit(input.dataset.key, Math.round(cm * 100) / 10); + }); + } + $("twist").addEventListener("input", (e) => { + $("twistwords").textContent = twistWords(15 * e.target.value); + }); + $("twist").addEventListener("change", (e) => { + const twist = 15 * Number(e.target.value); + change((p) => ({twist, ...(p.twist_style === K.OWN ? {twist_style: K.EVENLY} : {})})); + }); + for (const input of document.querySelectorAll("#printer input")) { + input.addEventListener("change", () => { + const v = parseFloat(input.value); + if (!(v > 0)) return; + change({ [input.dataset.key]: input.dataset.key === "walls" ? Math.round(v) : v }); + // settled() persists the final queued settings. + }); + } + $("drawown").addEventListener("click", async () => { + if (params.shape1 !== K.DRAWN) await commit("shape1", K.DRAWN); + openOutline(1); + }); +} + +function rememberPrinter() { + store.set("printer", Object.fromEntries(C.printer_keys.map((k) => [k, params[k]]))); +} + +async function fitHead(cm) { return enqueue(async () => { + await withBusy("Fitting it to the head…", async () => + replaceNow(JSON.parse(await helper.call("fit", JSON.stringify(params), cm * 10)), false)); + status(`Sized for a head ${cm} cm round — Undo to go back`); +}); } + +// ================================================================== Advanced: every setting +const widgets = new Map(); // key -> {rows: [elements], set(p), input} +const editors = {}; // the outline editors in the panel, by k + +function widgetInput(key) { + const w = widgets.get(key); + return w ? w.input : null; +} + +function colourStyleOf(p) { + // a seamless vase prints one filament: no bands or stripes + return p.vase_mode === K.REAL_VASE && (p.colour_style === 1 || p.colour_style === 2) ? K.ONE_COLOUR : p.colour_style; +} + +function numberBox(row, { decimals = 2 } = {}) { + const input = document.createElement("input"); + input.type = "number"; + input.min = row.lo; + input.max = row.hi; + input.step = row.step; + input.dataset.decimals = decimals; + return input; +} + +function makeRow(row, rowsBox) { + const { key, label, kind } = row; + const [unit, none] = String(row.extra || "").split("|"); + const lab = document.createElement("label"); + lab.className = "label"; + lab.textContent = label; + lab.title = row.tip || ""; + const control = document.createElement("div"); + control.className = "control"; + control.title = row.tip || ""; + let set = () => {}; + let input = null; + const whole = ["curve", "expr", "outline"].includes(kind); + if (kind === "float" || kind === "int" || kind === "seed" || kind === "pct" || kind === "widest" || kind === "head") { + const scale = kind === "pct" ? 100 : 1; // a percent is shown ×100; its limits are in percent already + input = numberBox(row); + lab.htmlFor = input.id = `set-${key}`; + control.append(input); + if (unit) { + const u = document.createElement("span"); + u.className = "unit"; + u.textContent = unit.trim(); + control.append(u); + } + let noneLabel = null; + if (none) { + noneLabel = document.createElement("span"); + noneLabel.className = "none"; + noneLabel.textContent = `(${none.toLowerCase()})`; + control.append(noneLabel); + } + if (kind === "seed") { + const dice = document.createElement("button"); + dice.className = "dice"; + dice.textContent = "🎲"; + dice.title = "Another pattern"; + dice.addEventListener("click", () => commit(key, Math.floor(Math.random() * 99999))); + control.append(dice); + } + input.addEventListener("change", () => { + let v = parseFloat(input.value); + if (Number.isNaN(v)) return; + v = Math.min(Math.max(v, row.lo), row.hi) / scale; + if (kind === "int" || kind === "seed") v = Math.round(v); + if (kind === "head") return fitHead(v); + commit(key, v); + }); + set = (p) => { + let v; + if (kind === "widest") v = Math.round(widest() * 10) / 10; + else if (kind === "head") v = meta ? meta.head : 57; + else v = p[key] * scale; + setNumber(input, kind === "pct" ? Math.round(v * 10) / 10 : Math.round(v * 1000) / 1000); + if (noneLabel) noneLabel.hidden = !(p[key] === row.lo); + if (/^count[12]$/.test(key)) input.min = p[`shape${key.slice(-1)}`] === K.POLYGON ? 3 : 1; + }; + } else if (kind === "bool") { + input = document.createElement("input"); + input.type = "checkbox"; + lab.htmlFor = input.id = `set-${key}`; + control.append(input); + input.addEventListener("change", () => commit(key, input.checked)); + set = (p) => { input.checked = Boolean(p[key]); }; + } else if (kind === "choice") { + input = document.createElement("select"); + lab.htmlFor = input.id = `set-${key}`; + const fill = (choices) => { + const now = JSON.stringify(choices); + if (input.dataset.choices === now) return; + input.dataset.choices = now; + input.replaceChildren(...choices.map(([v, t]) => new Option(t, v))); + }; + fill(row.choices); + control.append(input); + input.addEventListener("change", () => commit(key, Number(input.value))); + set = (p) => { + if (key === "colour_style") { + fill(p.vase_mode === K.REAL_VASE ? C.real_colour_styles : C.colour_styles); + input.value = colourStyleOf(p); + } else { + input.value = p[key]; + } + }; + } else if (kind === "color") { + input = document.createElement("input"); + input.type = "color"; + lab.htmlFor = input.id = `set-${key}`; + const hex = document.createElement("span"); + hex.className = "unit"; + control.append(input, hex); + input.addEventListener("change", () => commit(key, input.value.toUpperCase())); + set = (p) => { + input.value = p[key].toLowerCase(); + hex.textContent = p[key].toUpperCase(); + }; + } else if (kind === "slider") { + input = document.createElement("input"); + input.type = "range"; + input.min = row.lo; + input.max = row.hi; + input.step = row.step; + lab.htmlFor = input.id = `set-${key}`; + const note = document.createElement("span"); + note.className = "unit"; + control.append(input, note); + const words = (v) => (Math.abs(v) < 0.05 ? "Even" : v > 0 ? "Thicker towards the top" : "Thicker towards the bottom"); + input.addEventListener("input", () => { + note.textContent = words(Number(input.value)); + commit(key, Number(input.value), true); + }); + input.addEventListener("change", () => commit(key, Number(input.value))); + set = (p) => { + if (document.activeElement !== input) input.value = p[key]; + note.textContent = words(p[key]); + }; + } else if (kind === "palette") { + input = document.createElement("select"); + lab.htmlFor = input.id = `set-${key}`; + input.append(new Option("Choose a palette…", ""), ...C.palettes.map((pal) => new Option(pal.name, pal.name))); + control.append(input); + input.addEventListener("change", () => { + const pal = C.palettes.find((x) => x.name === input.value); + input.value = ""; + if (pal) change(Object.fromEntries(pal.colours.map((c, i) => [`color${i + 1}`, c]))); + }); + } else if (kind === "curve") { + control.className = "whole"; + const canvas = document.createElement("canvas"); + control.append(canvas); + const o = row.curve; + const editor = new CurveEditor(canvas, { lo: o.lo, hi: o.hi, unit: o.unit || "", mirror: Boolean(o.mirror), + height: o.height || 110, floor: o.floor ?? null, grow: Boolean(o.grow), decimals: o.decimals || 0 }, + (pts, live) => commit(key, pts, live)); + set = (p) => editor.setPoints(p[key]); + } else if (kind === "expr") { + control.className = "whole"; + const text = document.createElement("textarea"); + text.spellcheck = false; + text.placeholder = "1.5 * sin(6*theta + 4*pi*h)"; + text.setAttribute("aria-label", "Your own function"); + input = text; + const tools = document.createElement("div"); + tools.className = "row"; + const examples = document.createElement("select"); + examples.append(new Option("Examples…", ""), ...C.examples.map(([n, f]) => new Option(`${n}: ${f}`, f))); + const help = document.createElement("button"); + help.textContent = "Help…"; + const clear = document.createElement("button"); + clear.textContent = "Clear"; + tools.append(examples, help, clear); + control.append(text, tools); + let timer = 0; + text.addEventListener("input", () => { + clearTimeout(timer); + const value = text.value; + pendingText = () => { + clearTimeout(timer); + pendingText = null; + commit(key, value); + }; + timer = setTimeout(flushText, 600); + }); + examples.addEventListener("change", () => { + if (examples.value) setFormula(examples.value); + examples.value = ""; + }); + help.addEventListener("click", () => openHelp("function")); + clear.addEventListener("click", () => setFormula("")); + set = (p) => { if (document.activeElement !== text && text.value !== p[key]) text.value = p[key]; }; + } else if (kind === "outline") { + control.className = "whole outlinebox"; + const k = Number(key.slice(-1)); + const canvas = document.createElement("canvas"); + const tools = document.createElement("div"); + tools.className = "row"; + const start = startFromMenu(k); + const close = document.createElement("button"); + close.textContent = "Close up"; + close.title = "Show one repeat close up, turned to point upwards - or the whole outline. Above 8 repeats it starts close up."; + const big = document.createElement("button"); + big.textContent = "Full window…"; + big.title = "Draw it in a window as big as the screen: the wheel zooms, a right-drag moves it."; + tools.append(start, close, big); + control.append(canvas, tools); + const editor = new OutlineEditor(canvas, {}, (pts, live) => commit(key, pts, live), + () => close.setAttribute("aria-pressed", String(editor.closeUp()))); + editors[k] = editor; + close.addEventListener("click", () => editor.setCloseUp(!editor.closeUp())); + big.addEventListener("click", () => openOutline(k)); + set = (p) => editor.setOutline(p[key], p[`repeat${k}`]); + } + if (whole) { + if (label) { + lab.className = "label wholelabel"; + rowsBox.append(lab); + } + rowsBox.append(control); + widgets.set(key, { rows: label ? [lab, control] : [control], set, input, lab }); + } else { + rowsBox.append(lab, control); + widgets.set(key, { rows: [lab, control], set, input, lab }); + } +} + +function startFromMenu(k) { + const start = document.createElement("select"); + start.setAttribute("aria-label", "Start from"); + start.title = "Start again from a ready-made outline, or trace one from a picture: dark lines drawn round a shape on a light or see-through ground."; + start.append(new Option("Start from…", ""), ...C.outlines.map((o, i) => new Option(o.name, String(i))), + new Option("Trace a picture…", "trace")); + start.addEventListener("change", () => { + const v = start.value; + start.value = ""; + if (v === "trace") tracePicture(k); + else if (v !== "") useOutline(k, C.outlines[Number(v)].points, C.outlines[Number(v)].repeat); + }); + return start; +} + +function useOutline(k, points, repeat) { return enqueue(() => useOutlineNow(k, points, repeat)); } +function useOutlineNow(k, points, repeat) { + // start drawn outline k again from given control points + liveFrom = JSON.stringify(params); + params[`repeat${k}`] = repeat; + return commitNow(`drawn${k}`, points); +} + +let traceFor = 1; +function tracePicture(k) { + traceFor = k; + $("picturefile").click(); +} + +async function traceFile(file) { + const target = traceFor; + return enqueue(() => withBusy("Tracing the picture…", async () => { + const bitmap = await createImageBitmap(file); + const scale = Math.min(1, 1080 / Math.max(bitmap.width, bitmap.height)); + const w = Math.max(Math.round(bitmap.width * scale), 1); + const h = Math.max(Math.round(bitmap.height * scale), 1); + const canvas = new OffscreenCanvas(w, h); + const g = canvas.getContext("2d"); + g.drawImage(bitmap, 0, 0, w, h); + const pixels = g.getImageData(0, 0, w, h).data; + let text = ""; + for (let i = 0; i < pixels.length; i += 0x8000) text += String.fromCharCode(...pixels.subarray(i, i + 0x8000)); + const pts = JSON.parse(await helper.call("trace", btoa(text), w, h)); + bitmap.close(); + await useOutlineNow(target, pts, 1); + status(`Traced ${file.name}: ${pts.length} points, ${pts.filter((q) => q[2]).length} sharp corners.`); + }).catch(() => {})); +} + +function setFormula(text) { + flushText(); + const w = widgets.get("expression"); + if (w && w.input) w.input.value = text; + commit("expression", text); +} + +function buildAdvanced() { + const box = $("groups"); + box.replaceChildren(); + for (const group of C.spec) { + const section = document.createElement("details"); + section.className = "group"; + section.open = !store.get(`closed.${group.title}`); + section.addEventListener("toggle", () => store.set(`closed.${group.title}`, !section.open)); + const summary = document.createElement("summary"); + summary.textContent = group.title; + const rows = document.createElement("div"); + rows.className = "rows"; + section.append(summary, rows); + box.append(section); + group.rows.forEach((row) => makeRow(row, rows)); + group.element = section; + } + $("designname").addEventListener("change", (e) => commit("name", e.target.value.trim() || "vase")); +} + +function refreshAdvanced() { + if (!C || !params) return; + const p = params; + setNumber($("designname"), p.name); + for (const [key, w] of widgets) { + const show = shown.size === 0 || shown.has(key); + w.rows.forEach((el) => { el.hidden = !show; }); + if (show) w.set(p); + } + for (const group of C.spec) group.element.hidden = !group.rows.some((r) => shown.size === 0 || shown.has(r.key)); + // the settings named for the shape: sides, points, petals, teeth + for (const k of [1, 2]) { + const names = C.shape_labels[String(p[`shape${k}`])] || [null, null, null]; + ["count", "depth", "sharp"].forEach((what, i) => { + const w = widgets.get(`${what}${k}`); + if (w && names[i]) w.lab.textContent = names[i]; + }); + } + const c1 = widgets.get("color1"); + if (c1) c1.lab.textContent = colourStyleOf(p) === K.ONE_COLOUR ? "Colour" : "Filament 1"; + if (outlineWindow.k) { + const k = outlineWindow.k; + outlineWindow.editor.setOutline(p[`drawn${k}`], p[`repeat${k}`]); + setNumber($("winrepeat"), p[`repeat${k}`]); + } +} + +function setMode(advanced) { + $("simple").hidden = advanced; + $("advanced").hidden = !advanced; + $("simplebtn").setAttribute("aria-selected", String(!advanced)); + $("advancedbtn").setAttribute("aria-selected", String(advanced)); + store.set("advanced", advanced); + if (advanced) refreshAdvanced(); +} + +// ================================================================== drawing in a window of its own +const outlineWindow = { k: 0, editor: null }; + +function openOutline(k) { + const win = $("outlinewin"); + outlineWindow.k = k; + $("outlinetitle").textContent = k === 1 ? "Drawing the cross-section" : "Drawing what it turns into"; + $("winrepeat").value = params[`repeat${k}`]; + win.showModal(); + outlineWindow.editor.view = null; + outlineWindow.editor.setOutline(params[`drawn${k}`], params[`repeat${k}`]); + $("bigoutline").focus(); +} + +function buildOutlineWindow() { + const close = $("winclose"); + outlineWindow.editor = new OutlineEditor($("bigoutline"), { big: true }, + (pts, live) => commit(`drawn${outlineWindow.k}`, pts, live), + () => close.setAttribute("aria-pressed", String(outlineWindow.editor.closeUp()))); + close.addEventListener("click", () => outlineWindow.editor.setCloseUp(!outlineWindow.editor.closeUp())); + $("winfit").addEventListener("click", () => outlineWindow.editor.fit()); + $("windone").addEventListener("click", () => $("outlinewin").close()); + $("outlinewin").addEventListener("close", () => { outlineWindow.k = 0; }); + $("winrepeat").addEventListener("change", (e) => { + const n = Math.min(Math.max(Math.round(Number(e.target.value)), 1), 100); + if (n) commit(`repeat${outlineWindow.k}`, n); + }); + const start = $("winstart"); + start.append(new Option("Start from…", ""), ...C.outlines.map((o, i) => new Option(o.name, String(i))), + new Option("Trace a picture…", "trace")); + start.addEventListener("change", () => { + const v = start.value; + start.value = ""; + if (v === "trace") tracePicture(outlineWindow.k); + else if (v !== "") useOutline(outlineWindow.k, C.outlines[Number(v)].points, C.outlines[Number(v)].repeat); + }); +} + +// ================================================================== the gallery, help and files +function buildGallery() { + const designs = $("designs"); + const entries = [{ title: "Plain cylinder", slug: "plain_cylinder", + about: "200 mm across and 200 mm tall, with everything else off: a blank start for your own shape or function" }] + .concat(C.gallery); + for (const g of entries) { + const b = document.createElement("button"); + b.className = "design"; + b.title = g.about; + b.innerHTML = ``; + b.querySelector("b").textContent = g.title; + b.addEventListener("click", () => { + $("gallery").close(); + chooseDesign(g.title); + }); + designs.append(b); + } + const menu = $("designsmenu"); + for (const g of C.gallery) { + const b = document.createElement("button"); + b.textContent = g.title; + b.title = g.about; + b.addEventListener("click", () => { + closeMenus(); + chooseDesign(g.title); + }); + menu.append(b); + } +} + +function chooseDesign(title) { + return enqueue(() => withBusy("Getting it ready…", async () => + replaceNow(JSON.parse(await helper.call("design_of", title, JSON.stringify(params)))))); +} + +function openHelp(which) { + const titles = { printing: "Printing it watertight", function: "Your own function", drawing: "Drawing a cross-section", + about: `About Vaseinator ${C.version}` }; + $("helptitle").textContent = titles[which]; + $("helptext").innerHTML = C.help[which]; + $("helpexamples").hidden = which !== "function"; + $("help").showModal(); +} + +async function withBusy(text, work) { + showBusy(text); + try { + await work(); + } catch (err) { + showProblem(err.message); + throw err; + } finally { + if (!busy) showBusy(null); + } +} + +function download(data, name, type) { + const url = URL.createObjectURL(data instanceof Blob ? data : new Blob([data], { type })); + const a = document.createElement("a"); + a.href = url; + a.download = name; + document.body.append(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 10000); +} + +function offer(data, name, type, title, steps = "") { + // a file that took a while to make: a browser only lets a click save a file straight away, so the file is + // offered with a button of its own to click + const link = $("stepsfile"); + if (link.href) URL.revokeObjectURL(link.href); + link.href = URL.createObjectURL(new Blob([data], { type })); + link.download = name; + link.textContent = `Save ${name}`; + $("stepstitle").textContent = title; + $("steplist").innerHTML = steps; + $("steps").showModal(); + link.focus(); +} + +function fileName() { + return (params.name || "vase").replace(/[^\w\- ]/g, "_").trim() || "vase"; +} + +// ================================================================== the menus and buttons +let currentView = 0; + +function setView(n) { + currentView = n; + view.setMode(n); + document.querySelectorAll(".views button").forEach((b) => b.setAttribute("aria-pressed", String(Number(b.dataset.view) === n))); +} + +function toggle(id, on) { + $(id).setAttribute("aria-pressed", String(on)); +} + +function effectiveLight() { + const t = document.documentElement.dataset.theme; + return t ? t === "light" : window.matchMedia("(prefers-color-scheme: light)").matches; +} + +async function libraryWork(op, payload = {}, text = JSON.stringify(params)) { + const result = JSON.parse(await helper.call("workspace_action", libraryState ? JSON.stringify(libraryState) : "", text, op, JSON.stringify(payload))); + libraryState = result.workspace; + // Unlike optional preferences, library failures must be visible to avoid silent data loss. + try { localStorage.setItem("vaseinator.library", JSON.stringify(libraryState)); } + catch { showProblem("The library could not be saved in this browser. Export a backup before closing the page."); } + if (result.params) replaceNow(result.params); + return result; +} + +function openLibrary() { + libraryDialog({ + current: () => params, + read: () => enqueue(() => libraryWork("read")), + command: (op, data) => { flushText(); return enqueue(() => libraryWork(op, data)); }, + exportFile: () => enqueue(() => download(JSON.stringify(libraryState, null, 2), "vaseinator-library.json", "application/json")), + importFile: (refresh) => { + const input = document.createElement("input"); input.type = "file"; input.accept = ".json"; + input.onchange = async () => { if (input.files[0]) { await enqueue(async () => libraryWork("import", JSON.parse(await input.files[0].text()))); refresh(); } }; + input.click(); + }, + }); +} + +const actions = { + library: openLibrary, + compare: () => enqueue(() => withBusy("Comparing the surfaces…", async () => { + const data = JSON.parse(await builder.call("compare_design", JSON.stringify(params))); + $("helptitle").textContent = "Original and softened"; + $("helptext").replaceChildren(); + const text = document.createElement("p"); text.textContent = data.text; + const image = document.createElement("img"); image.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(data.svg); image.alt = data.text; image.style.width = "100%"; + $("helptext").append(text, image); $("helpexamples").hidden = true; $("help").showModal(); + })), + calibration: () => enqueue(() => withBusy("Making clearance samples…", async () => { + const {data, steps} = await builder.call("export_calibration", JSON.stringify(params)); + offer(data, "clearance_samples.3mf", "model/3mf", "How to use the samples", steps); + })), + new: () => enqueue(() => replaceNow({ ...C.defaults, ...Object.fromEntries(C.printer_keys.map((k) => [k, params[k]])) })), + plain: () => chooseDesign("Plain cylinder"), + open: () => $("openfile").click(), + savevase: async () => download(await helper.call("save_vase", JSON.stringify(params)), fileName() + ".vase", "application/json"), + print: () => withBusy("Making the file for your slicer…", async () => { + const { data, steps } = await builder.call("export_3mf", JSON.stringify(params)); + offer(data, fileName() + ".3mf", "model/3mf", "How to print it", steps); + }).catch(() => {}), + lid: () => withBusy("Making the lid…", async () => { + const { data, steps } = await builder.call("export_lid", JSON.stringify(params)); + offer(data, fileName() + " lid.3mf", "model/3mf", "How to print the lid", steps); + }).catch(() => {}), + stl: () => withBusy("Making the STL…", async () => + offer(await builder.call("export_stl", JSON.stringify(params)), fileName() + ".stl", "model/stl", + "Your STL is ready")).catch(() => {}), + "3mf": () => withBusy("Making the 3MF…", async () => { + const { data } = await builder.call("export_3mf", JSON.stringify(params)); + offer(data, fileName() + ".3mf", "model/3mf", "Your 3MF is ready", + "

In your slicer, open it with File ▸ Import.

"); + }).catch(() => {}), + picture: async () => download(await view.picture(), fileName() + "_3d.png", "image/png"), + undo: () => step(undoStack, redoStack), + redo: () => step(redoStack, undoStack), + surprise: () => enqueue(() => withBusy("Thinking of something…", async () => + replaceNow(JSON.parse(await helper.call("surprise_me", JSON.stringify(params), Math.floor(Math.random() * 1e6), JSON.stringify(libraryState?.locks || []))))).catch(() => {})), + plan: async () => { + if (!meta) return; + try { + await navigator.clipboard.writeText(meta.plan); + status("Colour changes copied to the clipboard"); + } catch { + status("The browser didn't allow copying: the colour changes are in the panel"); + } + }, + gallery: () => $("gallery").showModal(), + view0: () => setView(0), + view1: () => setView(1), + view2: () => setView(2), + view3: () => setView(3), + spin: () => { + const on = $("spin").getAttribute("aria-pressed") !== "true"; + view.setSpin(on); + toggle("spin", on); + }, + printing: () => { + const on = $("printing").getAttribute("aria-pressed") !== "true"; + view.setPrinting(on); + toggle("printing", on); + }, + reset: () => view.reset(), + bigger: () => { + const on = $("bigger").getAttribute("aria-pressed") !== "true"; + document.documentElement.style.setProperty("--scale", on ? "1.25" : "1"); + toggle("bigger", on); + store.set("bigger", on); + }, + theme: () => { + const light = !effectiveLight(); + document.documentElement.dataset.theme = light ? "light" : "dark"; + store.set("theme", light ? "light" : "dark"); + drawPlots(); + Object.values(editors).forEach((e) => e.draw()); + for (const w of widgets.values()) if (w.set) w.set(params); + }, + "help-printing": () => openHelp("printing"), + "help-function": () => openHelp("function"), + "help-drawing": () => openHelp("drawing"), + "help-about": () => openHelp("about"), +}; + +for (const key of ["savevase", "print", "lid", "stl", "3mf", "picture", "plan"]) { + const action = actions[key]; + actions[key] = () => enqueue(action); +} +for (const key of Object.keys(actions)) { + const action = actions[key]; + actions[key] = () => { flushText(); return action(); }; +} + +function closeMenus(except) { + document.querySelectorAll(".menu[open]").forEach((m) => { if (m !== except) m.open = false; }); +} + +function wire() { + document.addEventListener("click", (e) => { + const b = e.target.closest("[data-do]"); + if (b) { + closeMenus(); + if (C && actions[b.dataset.do]) actions[b.dataset.do](); + return; + } + if (!e.target.closest(".menu")) closeMenus(); + }); + document.querySelectorAll(".menu").forEach((m) => m.addEventListener("toggle", () => { if (m.open) closeMenus(m); })); + document.querySelectorAll(".views button").forEach((b) => b.addEventListener("click", () => setView(Number(b.dataset.view)))); + $("simplebtn").addEventListener("click", () => setMode(false)); + $("advancedbtn").addEventListener("click", () => setMode(true)); + $("openfile").addEventListener("change", async (e) => { + const file = e.target.files[0]; + e.target.value = ""; + if (!file) return; + await enqueue(() => withBusy("Opening it…", async () => replaceNow(JSON.parse(await helper.call("open_vase", await file.text()))))); + }); + $("picturefile").addEventListener("change", (e) => { + const file = e.target.files[0]; + e.target.value = ""; + if (file) traceFile(file); + }); + $("useexample").addEventListener("click", () => { + setFormula($("examplepick").value); + $("help").close(); + }); + document.addEventListener("keydown", (e) => { + if (!C) return; + const typing = e.target.matches("input, textarea, select"); + if (e.ctrlKey || e.metaKey) { + const key = e.key.toLowerCase(); + const act = { z: e.shiftKey ? "redo" : "undo", y: "redo", s: "savevase", o: "open", g: "gallery" }[key]; + if (!act || (typing && (key === "z" || key === "y"))) return; + e.preventDefault(); + actions[act](); + } else if (!typing && !e.target.matches("canvas") && (e.key === "s" || e.key === "S")) { + actions.spin(); + } + }); + const theme = store.get("theme"); + if (theme) document.documentElement.dataset.theme = theme; + if (store.get("bigger")) actions.bigger(); +} + +// ================================================================== starting up +function failed(text) { + $("loading").hidden = false; + $("loading").classList.add("failed"); + $("loadingtext").textContent = `Vaseinator couldn't start: ${text}. Check the internet connection, then reload ` + + "the page."; +} + +async function boot() { + wire(); + try { + const [choices] = await Promise.all([helper.call("start"), builder.call("start")]); + C = JSON.parse(choices); + } catch (err) { + failed(err.message); + return; + } + K = C.constants; + $("examplepick").append(...C.examples.map(([n, f]) => new Option(`${n}: ${f}`, f))); + buildSimple(); + buildAdvanced(); + buildOutlineWindow(); + buildGallery(); + const printer = store.get("printer") || {}; + params = { ...C.defaults, ...printer, ...(store.get("design") || {}) }; + $("loading").hidden = true; + undoButtons(); + setMode(Boolean(store.get("advanced"))); + settled({ refit: true }); +} + +boot(); diff --git a/web/bridge.py b/web/bridge.py index 07a9c84..28bcd5d 100644 --- a/web/bridge.py +++ b/web/bridge.py @@ -1,294 +1,307 @@ -"""The web page's Python: the same vaseinator package as the desktop app, run in the browser by Pyodide. - -The page's workers call these functions with settings as JSON text and get JSON text back - and, for a -built vase, its mesh as raw bytes the page draws with three.js. The settings, the rules for which are shown -and what a change brings with it, the panel's report and the help are the desktop app's own. -""" - -from __future__ import annotations - -import base64 -import io -import json -import math -import os -import re -import tempfile - -import numpy as np - -from vaseinator import __version__, sections -from vaseinator.choices import (COLOURS, MANY_COLOURS, NO_PATTERN, PATTERNS, SHAPES_FROM_ABOVE, TISSUE_HOLES, pattern_of, - twist_words) -from vaseinator.expr import EXAMPLES -from vaseinator.geometry import TOLERANCE, VaseDesign, fit_head -from vaseinator.lid import Lid -from vaseinator.params import (CIRCLE, DRAWN, EVENLY, FAKE_VASE, FILE_FORMAT, FILE_VERSION, ONE_COLOUR, OWN, - OWN_PROFILE, POLYGON, REAL_VASE, SAME, TISSUE_OVAL, TISSUE_ROUND, VaseParams) -from vaseinator.presets import GALLERY, PRINTER_KEYS, gallery_design, plain_design, slug, surprise -from vaseinator.report import ABOUT, DRAWING_HELP, PRINTING_HELP, function_help, info_html, lid_steps, print_steps -from vaseinator.settings import COLOUR_STYLES, LIVE_KEYS, REAL_COLOUR_STYLES, SPEC, TEXT_KEYS, change, wanted -from vaseinator.shapes import PALETTES, PROFILES, SHAPE_LABELS, pchip, shape_table -from vaseinator.stl import write_stl -from vaseinator.threemf import write_3mf - -SLICES = 14 # layers drawn in the from-above picture, as the desktop app draws them - - -def _report(d, errors, warnings) -> str: - """The desktop app's report, without its own style sheet: the page styles it.""" - return re.sub(r"^", "", info_html(d, errors, warnings), flags=re.S) - - -def _params(text: str) -> VaseParams: - return VaseParams.from_dict(json.loads(text)) - - -def _dump(p: VaseParams) -> str: - return json.dumps(p.to_dict()) - - -def _shown(p: VaseParams) -> list: - return [row[0] for _, rows in SPEC for row in rows if wanted(p, row[0])] - - -# ------------------------------------------------------------------ the choices -def _spec(): - """Every setting, by group, as the Advanced panel lists them.""" - groups = [] - for title, items in SPEC: - rows = [] - for key, label, kind, lo, hi, step, extra, tip in items: - row = dict(key=key, label=label, kind=kind, tip=tip, extra=extra) - if kind == "choice": - row["choices"] = [[v, t] for v, t in lo] - elif kind == "curve": - row["curve"] = lo - else: - row.update(lo=lo, hi=hi, step=step) - rows.append(row) - groups.append(dict(title=title, rows=rows)) - return groups - - -def start() -> str: - """Everything the page needs: the gallery, the choices and pictures, every setting, the help.""" - def above(settings): - r = shape_table(settings.get("shape1", CIRCLE), settings.get("count1", 5), settings.get("depth1", 0.0), - settings.get("sharp1", 0.5)) - a = np.arange(len(r)) * (2 * math.pi / len(r)) + math.radians(settings.get("turn1", 0.0)) - step = max(len(r) // 160, 1) - return [[round(float(rr * math.cos(t)), 4), round(float(rr * math.sin(t)), 4)] - for rr, t in zip(r[::step], a[::step])] - - def side(points): - pts = np.asarray(points, dtype=float) - hs = np.linspace(0.0, 1.0, 40) - d = np.clip(pchip(pts[:, 0], pts[:, 1], hs), 0.05, None) - return [round(float(v), 4) for v in d / d.max()] - - palettes = dict(PALETTES) - return json.dumps(dict( - version=__version__, - gallery=[dict(title=t, about=about, slug=slug(t), worn=bool(ch.get("worn", False))) - for t, about, ch in GALLERY], - shapes=[dict(name=n, settings=s, outline=above(s)) for n, s in SHAPES_FROM_ABOVE], - profiles=[dict(name=full.split(" (")[0], full=full, widths=side(pts), points=[list(q) for q in pts]) - for full, pts in PROFILES], - patterns=[dict(name=n, settings=s) for n, s in PATTERNS], - tissue_holes=[dict(name=n, value=v) for n, v in TISSUE_HOLES], - no_pattern=NO_PATTERN, - colours=[dict(name=n, colour=c) for n, c in COLOURS], - many_colours=[dict(name=n, palette=pal, settings=s, colours=list(palettes[pal])) - for n, pal, s in MANY_COLOURS], - palettes=[dict(name=n, colours=list(c)) for n, c in PALETTES], - printer_keys=list(PRINTER_KEYS), - spec=_spec(), live_keys=sorted(LIVE_KEYS), text_keys=sorted(TEXT_KEYS), - colour_styles=[[v, t] for v, t in COLOUR_STYLES], - real_colour_styles=[[v, t] for v, t in REAL_COLOUR_STYLES], - outlines=[dict(name=n, points=[list(q) for q in pts], repeat=r) for n, pts, r in sections.OUTLINES], - examples=[[n, f] for n, f in EXAMPLES], - shape_labels={str(k): list(v) for k, v in SHAPE_LABELS.items()}, - help=dict(printing=PRINTING_HELP, drawing=DRAWING_HELP, about=ABOUT, function=function_help()), - constants=dict(SAME=SAME, CIRCLE=CIRCLE, POLYGON=POLYGON, DRAWN=DRAWN, OWN=OWN, OWN_PROFILE=OWN_PROFILE, - EVENLY=EVENLY, REAL_VASE=REAL_VASE, FAKE_VASE=FAKE_VASE, ONE_COLOUR=ONE_COLOUR, - TISSUE_OVAL=TISSUE_OVAL, TISSUE_ROUND=TISSUE_ROUND), - defaults=VaseParams().to_dict(), - plain=plain_design().to_dict(), - )) - - -def shown(text: str) -> str: - """Which settings the Advanced panel shows for this design.""" - return json.dumps(_shown(_params(text))) - - -def apply(text: str, key: str, value: str) -> str: - """One change, as the panel makes it, with what follows from it (the desktop app's own rules).""" - p = _params(text) - touched, message = change(p, key, json.loads(value)) - p = VaseParams.from_dict(p.to_dict()) - return json.dumps(dict(params=p.to_dict(), touched=touched, message=message, shown=_shown(p))) - - -def design_of(title: str, keep: str) -> str: - """A ready-made design (or the plain cylinder), with the printer settings of ``keep``.""" - k = _params(keep) - return _dump(plain_design(k) if title == "Plain cylinder" else gallery_design(title, keep=k)) - - -def surprise_me(keep: str, seed: int) -> str: - return _dump(surprise(_params(keep), int(seed))) - - -def fit(text: str, head_mm: float) -> str: - """The design scaled to fit a head this many mm round.""" - return _dump(fit_head(_params(text), float(head_mm))) - - -def trace(pixels: str, width: int, height: int) -> str: - """Control points tracing the outline drawn in a picture: its RGBA pixels, base64.""" - rgba = np.frombuffer(base64.b64decode(pixels), dtype=np.uint8).reshape(int(height), int(width), 4) - return json.dumps(sections.trace_rgba(rgba)) - - -# ------------------------------------------------------------------ building it -def _gradient(colours, phase): - """The blend the 3D view uses for a gradient spool, for each phase.""" - ring = np.asarray(colours, dtype=float) - n = len(ring) - ph = np.mod(phase, 1.0) * n - k = np.floor(ph).astype(int) % n - t = ph - np.floor(ph) - t = t * t * (3.0 - 2.0 * t) - return ring[k] * (1.0 - t)[:, None] + ring[(k + 1) % n] * t[:, None] - - -def _colours(d: VaseDesign, z: np.ndarray) -> np.ndarray: - """The colour of each height, as the desktop app's 3D view shows it: bands, or a gradient spool.""" - cols = d.p.colors() - if d.gradient: - return _gradient([cols[k] for k in d.gradient_fils], d.colour_phase(z)) - starts = np.array([b[0] for b in d.bands]) - band_cols = np.array([cols[f] for _, f in d.bands], dtype=float) - idx = np.clip(np.searchsorted(starts, z, side="right") - 1, 0, len(starts) - 1) - return band_cols[idx] - - -def _plots(d: VaseDesign) -> dict: - """What the side and from-above pictures under the 3D view draw, as the desktop app draws them.""" - z = np.asarray(d.profile_z, dtype=float) - step = max(1, len(z) // 400) - keep = np.unique(np.concatenate([np.arange(0, len(z), step), [len(z) - 1]])) - lim = d.p.max_overhang + TOLERANCE - bad = np.nonzero(d.band_lean > lim)[0] - bad_z = (z[np.minimum(bad, len(z) - 1)] if d.rows is None - else np.array([d.rows[min(b, len(d.rows) - 1)][0] for b in bad])) - moved = z[d.row_moved > 0.05] if d.softened > 0.05 and d.row_moved is not None else np.zeros(0) - heights = [min(max(d.H * k / SLICES, 0.02), d.H - 0.02) for k in range(SLICES + 1)] - slices = [np.round(d.contour(zc)[:, :, :2], 2).reshape(-1).tolist() for zc in heights] - return dict(z=np.round(z[keep], 3).tolist(), rmax=np.round(d.profile_max[keep], 3).tolist(), - rmin=np.round(d.profile_min[keep], 3).tolist(), colours=np.round(_colours(d, z[keep]), 4).tolist(), - zb=float(d.zb), bad=np.round(bad_z, 2).tolist(), moved=np.round(moved[::step], 2).tolist(), - slices=slices, slice_colours=np.round(_colours(d, np.array(heights)), 4).tolist(), - half=0.5 * float(max(d.size[0], d.size[1], 1.0)), height=float(d.H), widest=float(d.widest)) - - -def build(text: str, draft: bool): - """Build the vase. Returns (what the page shows about it, as JSON; positions; triangles; colours; each - corner's steepest lean) - the last four as raw float32 / uint32 bytes.""" - p = _params(text) - d = VaseDesign(p, draft=bool(draft)) - meta = dict(errors=d.errors, warnings=d.warnings) - if d.errors: - meta["report"] = _report(None, d.errors, d.warnings) - return json.dumps(meta), b"", b"", b"", b"" - verts = np.asarray(d.shell_verts, dtype=np.float64) - faces = np.asarray(d.shell_faces, dtype=np.int64) - lean = np.full(len(verts), -1.0) - np.maximum.at(lean, faces.ravel(), np.repeat(np.asarray(d.shell_lean, dtype=float), 3)) - fz, fa = np.asarray(d.filament_z, dtype=float), np.asarray(d.filament_at, dtype=float) - pick = np.unique(np.linspace(0, len(fz) - 1, min(len(fz), 300)).astype(int)) - meta.update( - info=d.info(), notes=d.notes, draft=bool(draft), height=float(d.H), size=[float(s) for s in d.size], - widest=float(max(v for _, v in p.silhouette)), head=round(float(d.head_fit) / 10.0, 1), - worn=bool(d.p.worn), pattern=pattern_of(d.p), - twist_words=twist_words(d.p.twist) if d.p.twist_style != OWN else "Your own curve", - ok=bool(d.worst <= d.p.max_overhang + TOLERANCE), look=int(d.p.look), normal=bool(d.normal), - triangles=int(len(faces)), bed=[float(d.p.bed_x), float(d.p.bed_y), float(d.p.printer_height)], - limit=float(d.p.max_overhang), report=_report(d, [], d.warnings), plan=d.colour_plan(), plots=_plots(d), - printing=dict(z=np.round(fz[pick], 3).tolist(), at=np.round(fa[pick], 2).tolist(), layers=int(d.layers), - seconds=float(d.seconds), grams=float(d.grams))) - colours = _colours(d, verts[:, 2]).astype(np.float32) - return (json.dumps(meta), verts.astype(np.float32).tobytes(), faces.astype(np.uint32).tobytes(), - colours.tobytes(), lean.astype(np.float32).tobytes()) - - -# ------------------------------------------------------------------ files -def _name(p: VaseParams) -> str: - name = "".join(ch if ch.isalnum() or ch in "-_ " else "_" for ch in p.name).strip() - return name or "vase" - - -def export_3mf(text: str): - """The vase for the slicer, as the desktop app's Save for printing makes it: a part per band of colour. - Returns (the file, the steps to print it, as HTML).""" - p = _params(text) - d = VaseDesign(p) - if d.errors: - raise ValueError("; ".join(d.errors)) - colors = d.p.colors() - parts = d.export_parts() - base = _name(p) - many = len(parts) > 1 - named = [(f"{base} band {k + 1}" if many else base, v, f, fil, colors[fil]) for k, (fil, v, f) in enumerate(parts)] - path = os.path.join(tempfile.gettempdir(), "vase.3mf") - write_3mf(path, base, named, (p.bed_x / 2.0, p.bed_y / 2.0, 0.0)) - with open(path, "rb") as fh: - data = fh.read() - steps = print_steps(d, f"{base}.3mf", len(parts)) - steps = re.sub(r"^

Saved .*?

", "", steps, flags=re.S) # saved by the button above the steps - return data, steps.replace(" (the button below, or File ▸ Import)", " (File ▸ Import)") - - -def export_lid(text: str): - """A tissue box cover's lid, made to fit it, as the desktop app's Save the lid for printing makes it. - Returns (the file, the steps to print it and put it on, as HTML).""" - p = _params(text) - d = VaseDesign(p) - if d.errors: - raise ValueError("; ".join(d.errors)) - lid = Lid(d) - if lid.errors: - raise ValueError(" ".join(lid.errors)) - base = _name(p) + " lid" - path = os.path.join(tempfile.gettempdir(), "lid.3mf") - write_3mf(path, base, [(base, lid.verts, lid.faces, 1, d.p.colors()[1])], (p.bed_x / 2.0, p.bed_y / 2.0, 0.0)) - with open(path, "rb") as fh: - data = fh.read() - steps = re.sub(r"^

Saved .*?

", "", lid_steps(lid, f"{base}.3mf"), flags=re.S) - return data, steps.replace(" (the button below, or File ▸ Import)", " (File ▸ Import)") - - -def export_stl(text: str) -> bytes: - p = _params(text) - d = VaseDesign(p) - if d.errors: - raise ValueError("; ".join(d.errors)) - path = os.path.join(tempfile.gettempdir(), "vase.stl") - write_stl(path, *d.export_mesh(), _name(p)) - with open(path, "rb") as fh: - return fh.read() - - -def save_vase(text: str) -> str: - """A .vase design file, the same as the desktop app saves.""" - return json.dumps({"format": FILE_FORMAT, "version": FILE_VERSION, "params": _params(text).to_dict()}, indent=2) - - -def open_vase(text: str) -> str: - """The settings from a .vase design file, made safe.""" - data = json.load(io.StringIO(text)) - if data.get("format") != FILE_FORMAT: - raise ValueError("That is not a Vaseinator design file.") - return _dump(VaseParams.from_dict(data.get("params", {}))) +"""The web page's Python: the same vaseinator package as the desktop app, run in the browser by Pyodide. + +The page's workers call these functions with settings as JSON text and get JSON text back - and, for a +built vase, its mesh as raw bytes the page draws with three.js. The settings, the rules for which are shown +and what a change brings with it, the panel's report and the help are the desktop app's own. +""" + +from __future__ import annotations + +import base64 +import io +import json +import math +import os +import re +import tempfile + +import numpy as np + +from vaseinator import __version__, sections +from vaseinator.choices import (COLOURS, MANY_COLOURS, NO_PATTERN, PATTERNS, SHAPES_FROM_ABOVE, TISSUE_HOLES, pattern_of, + twist_words) +from vaseinator.expr import EXAMPLES +from vaseinator.geometry import TOLERANCE, VaseDesign, fit_head, require_exportable +from vaseinator.lid import Lid +from vaseinator.params import (CIRCLE, DRAWN, EVENLY, FAKE_VASE, FILE_FORMAT, FILE_VERSION, ONE_COLOUR, OWN, + OWN_PROFILE, POLYGON, REAL_VASE, SAME, TISSUE_OVAL, TISSUE_ROUND, VaseParams) +from vaseinator.presets import GALLERY, PRINTER_KEYS, gallery_design, plain_design, slug, surprise +from vaseinator.report import ABOUT, DRAWING_HELP, PRINTING_HELP, function_help, info_html, lid_steps, print_steps +from vaseinator.settings import COLOUR_STYLES, LIVE_KEYS, REAL_COLOUR_STYLES, SPEC, TEXT_KEYS, change, wanted +from vaseinator.shapes import PALETTES, PROFILES, SHAPE_LABELS, pchip, shape_table +from vaseinator.workspace import Workspace, locked_surprise, comparison, calibration_parts, CALIBRATION_HELP +from vaseinator.stl import write_stl +from vaseinator.threemf import write_3mf + +SLICES = 14 # layers drawn in the from-above picture, as the desktop app draws them + + +def _report(d, errors, warnings) -> str: + """The desktop app's report, without its own style sheet: the page styles it.""" + return re.sub(r"^", "", info_html(d, errors, warnings), flags=re.S) + + +def _params(text: str) -> VaseParams: + return VaseParams.from_dict(json.loads(text)) + + +def _dump(p: VaseParams) -> str: + return json.dumps(p.to_dict()) + + +def _shown(p: VaseParams) -> list: + return [row[0] for _, rows in SPEC for row in rows if wanted(p, row[0])] + + +# ------------------------------------------------------------------ the choices +def _spec(): + """Every setting, by group, as the Advanced panel lists them.""" + groups = [] + for title, items in SPEC: + rows = [] + for key, label, kind, lo, hi, step, extra, tip in items: + row = dict(key=key, label=label, kind=kind, tip=tip, extra=extra) + if kind == "choice": + row["choices"] = [[v, t] for v, t in lo] + elif kind == "curve": + row["curve"] = lo + else: + row.update(lo=lo, hi=hi, step=step) + rows.append(row) + groups.append(dict(title=title, rows=rows)) + return groups + + +def start() -> str: + """Everything the page needs: the gallery, the choices and pictures, every setting, the help.""" + def above(settings): + r = shape_table(settings.get("shape1", CIRCLE), settings.get("count1", 5), settings.get("depth1", 0.0), + settings.get("sharp1", 0.5)) + a = np.arange(len(r)) * (2 * math.pi / len(r)) + math.radians(settings.get("turn1", 0.0)) + step = max(len(r) // 160, 1) + return [[round(float(rr * math.cos(t)), 4), round(float(rr * math.sin(t)), 4)] + for rr, t in zip(r[::step], a[::step])] + + def side(points): + pts = np.asarray(points, dtype=float) + hs = np.linspace(0.0, 1.0, 40) + d = np.clip(pchip(pts[:, 0], pts[:, 1], hs), 0.05, None) + return [round(float(v), 4) for v in d / d.max()] + + palettes = dict(PALETTES) + return json.dumps(dict( + version=__version__, + gallery=[dict(title=t, about=about, slug=slug(t), worn=bool(ch.get("worn", False))) + for t, about, ch in GALLERY], + shapes=[dict(name=n, settings=s, outline=above(s)) for n, s in SHAPES_FROM_ABOVE], + profiles=[dict(name=full.split(" (")[0], full=full, widths=side(pts), points=[list(q) for q in pts]) + for full, pts in PROFILES], + patterns=[dict(name=n, settings=s) for n, s in PATTERNS], + tissue_holes=[dict(name=n, value=v) for n, v in TISSUE_HOLES], + no_pattern=NO_PATTERN, + colours=[dict(name=n, colour=c) for n, c in COLOURS], + many_colours=[dict(name=n, palette=pal, settings=s, colours=list(palettes[pal])) + for n, pal, s in MANY_COLOURS], + palettes=[dict(name=n, colours=list(c)) for n, c in PALETTES], + printer_keys=list(PRINTER_KEYS), + spec=_spec(), live_keys=sorted(LIVE_KEYS), text_keys=sorted(TEXT_KEYS), + colour_styles=[[v, t] for v, t in COLOUR_STYLES], + real_colour_styles=[[v, t] for v, t in REAL_COLOUR_STYLES], + outlines=[dict(name=n, points=[list(q) for q in pts], repeat=r) for n, pts, r in sections.OUTLINES], + examples=[[n, f] for n, f in EXAMPLES], + shape_labels={str(k): list(v) for k, v in SHAPE_LABELS.items()}, + help=dict(printing=PRINTING_HELP, drawing=DRAWING_HELP, about=ABOUT, function=function_help()), + constants=dict(SAME=SAME, CIRCLE=CIRCLE, POLYGON=POLYGON, DRAWN=DRAWN, OWN=OWN, OWN_PROFILE=OWN_PROFILE, + EVENLY=EVENLY, REAL_VASE=REAL_VASE, FAKE_VASE=FAKE_VASE, ONE_COLOUR=ONE_COLOUR, + TISSUE_OVAL=TISSUE_OVAL, TISSUE_ROUND=TISSUE_ROUND), + defaults=VaseParams().to_dict(), + plain=plain_design().to_dict(), + )) + + +def shown(text: str) -> str: + """Which settings the Advanced panel shows for this design.""" + return json.dumps(_shown(_params(text))) + + +def apply(text: str, key: str, value: str) -> str: + """One change, as the panel makes it, with what follows from it (the desktop app's own rules).""" + p = _params(text) + touched, message = change(p, key, json.loads(value)) + p = VaseParams.from_dict(p.to_dict()) + return json.dumps(dict(params=p.to_dict(), touched=touched, message=message, shown=_shown(p))) + + +def design_of(title: str, keep: str) -> str: + """A ready-made design (or the plain cylinder), with the printer settings of ``keep``.""" + k = _params(keep) + return _dump(plain_design(k) if title == "Plain cylinder" else gallery_design(title, keep=k)) + + +def surprise_me(keep: str, seed: int, locks: str = "[]") -> str: + return _dump(locked_surprise(_params(keep), int(seed), json.loads(locks))) + + +def fit(text: str, head_mm: float) -> str: + """The design scaled to fit a head this many mm round.""" + return _dump(fit_head(_params(text), float(head_mm))) + + +def trace(pixels: str, width: int, height: int) -> str: + """Control points tracing the outline drawn in a picture: its RGBA pixels, base64.""" + rgba = np.frombuffer(base64.b64decode(pixels), dtype=np.uint8).reshape(int(height), int(width), 4) + return json.dumps(sections.trace_rgba(rgba)) + + +# ------------------------------------------------------------------ building it +def _gradient(colours, phase): + """The blend the 3D view uses for a gradient spool, for each phase.""" + ring = np.asarray(colours, dtype=float) + n = len(ring) + ph = np.mod(phase, 1.0) * n + k = np.floor(ph).astype(int) % n + t = ph - np.floor(ph) + t = t * t * (3.0 - 2.0 * t) + return ring[k] * (1.0 - t)[:, None] + ring[(k + 1) % n] * t[:, None] + + +def _colours(d: VaseDesign, z: np.ndarray) -> np.ndarray: + """The colour of each height, as the desktop app's 3D view shows it: bands, or a gradient spool.""" + cols = d.p.colors() + if d.gradient: + return _gradient([cols[k] for k in d.gradient_fils], d.colour_phase(z)) + starts = np.array([b[0] for b in d.bands]) + band_cols = np.array([cols[f] for _, f in d.bands], dtype=float) + idx = np.clip(np.searchsorted(starts, z, side="right") - 1, 0, len(starts) - 1) + return band_cols[idx] + + +def _plots(d: VaseDesign) -> dict: + """What the side and from-above pictures under the 3D view draw, as the desktop app draws them.""" + z = np.asarray(d.profile_z, dtype=float) + step = max(1, len(z) // 400) + keep = np.unique(np.concatenate([np.arange(0, len(z), step), [len(z) - 1]])) + lim = d.p.max_overhang + TOLERANCE + bad = np.nonzero(d.band_lean > lim)[0] + bad_z = (z[np.minimum(bad, len(z) - 1)] if d.rows is None + else np.array([d.rows[min(b, len(d.rows) - 1)][0] for b in bad])) + moved = z[d.row_moved > 0.05] if d.softened > 0.05 and d.row_moved is not None else np.zeros(0) + heights = [min(max(d.H * k / SLICES, 0.02), d.H - 0.02) for k in range(SLICES + 1)] + slices = [np.round(d.contour(zc)[:, :, :2], 2).reshape(-1).tolist() for zc in heights] + return dict(z=np.round(z[keep], 3).tolist(), rmax=np.round(d.profile_max[keep], 3).tolist(), + rmin=np.round(d.profile_min[keep], 3).tolist(), colours=np.round(_colours(d, z[keep]), 4).tolist(), + zb=float(d.zb), bad=np.round(bad_z, 2).tolist(), moved=np.round(moved[::step], 2).tolist(), + slices=slices, slice_colours=np.round(_colours(d, np.array(heights)), 4).tolist(), + half=0.5 * float(max(d.size[0], d.size[1], 1.0)), height=float(d.H), widest=float(d.widest)) + + +def build(text: str, draft: bool): + """Build the vase. Returns (what the page shows about it, as JSON; positions; triangles; colours; each + corner's steepest lean) - the last four as raw float32 / uint32 bytes.""" + p = _params(text) + d = VaseDesign(p, draft=bool(draft)) + meta = dict(errors=d.errors, warnings=d.warnings) + if d.errors: + meta["report"] = _report(None, d.errors, d.warnings) + return json.dumps(meta), b"", b"", b"", b"" + verts = np.asarray(d.shell_verts, dtype=np.float64) + faces = np.asarray(d.shell_faces, dtype=np.int64) + lean = np.full(len(verts), -1.0) + np.maximum.at(lean, faces.ravel(), np.repeat(np.asarray(d.shell_lean, dtype=float), 3)) + fz, fa = np.asarray(d.filament_z, dtype=float), np.asarray(d.filament_at, dtype=float) + pick = np.unique(np.linspace(0, len(fz) - 1, min(len(fz), 300)).astype(int)) + meta.update( + info=d.info(), notes=d.notes, draft=bool(draft), height=float(d.H), size=[float(s) for s in d.size], + widest=float(max(v for _, v in p.silhouette)), head=round(float(d.head_fit) / 10.0, 1), + worn=bool(d.p.worn), pattern=pattern_of(d.p), + twist_words=twist_words(d.p.twist) if d.p.twist_style != OWN else "Your own curve", + ok=bool(d.worst <= d.p.max_overhang + TOLERANCE), look=int(d.p.look), normal=bool(d.normal), + triangles=int(len(faces)), bed=[float(d.p.bed_x), float(d.p.bed_y), float(d.p.printer_height)], + limit=float(d.p.max_overhang), report=_report(d, [], d.warnings), plan=d.colour_plan(), plots=_plots(d), + printing=dict(z=np.round(fz[pick], 3).tolist(), at=np.round(fa[pick], 2).tolist(), layers=int(d.layers), + seconds=float(d.seconds), grams=float(d.grams))) + colours = _colours(d, verts[:, 2]).astype(np.float32) + return (json.dumps(meta), verts.astype(np.float32).tobytes(), faces.astype(np.uint32).tobytes(), + colours.tobytes(), lean.astype(np.float32).tobytes()) + + +# ------------------------------------------------------------------ files +def _name(p: VaseParams) -> str: + name = "".join(ch if ch.isalnum() or ch in "-_ " else "_" for ch in p.name).strip() + return name or "vase" + + +def export_3mf(text: str): + """The vase for the slicer, as the desktop app's Save for printing makes it: a part per band of colour. + Returns (the file, the steps to print it, as HTML).""" + p = _params(text) + d = VaseDesign(p) + require_exportable(d) + colors = d.p.colors() + parts = d.export_parts() + base = _name(p) + many = len(parts) > 1 + named = [(f"{base} band {k + 1}" if many else base, v, f, fil, colors[fil]) for k, (fil, v, f) in enumerate(parts)] + path = os.path.join(tempfile.gettempdir(), "vase.3mf") + write_3mf(path, base, named, (p.bed_x / 2.0, p.bed_y / 2.0, 0.0)) + with open(path, "rb") as fh: + data = fh.read() + steps = print_steps(d, f"{base}.3mf", len(parts)) + steps = re.sub(r"^

Saved .*?

", "", steps, flags=re.S) # saved by the button above the steps + return data, steps.replace(" (the button below, or File â–¸ Import)", " (File â–¸ Import)") + + +def export_lid(text: str): + """A tissue box cover's lid, made to fit it, as the desktop app's Save the lid for printing makes it. + Returns (the file, the steps to print it and put it on, as HTML).""" + p = _params(text) + d = VaseDesign(p) + require_exportable(d) + lid = Lid(d) + require_exportable(lid) + base = _name(p) + " lid" + path = os.path.join(tempfile.gettempdir(), "lid.3mf") + write_3mf(path, base, [(base, lid.verts, lid.faces, 1, d.p.colors()[1])], (p.bed_x / 2.0, p.bed_y / 2.0, 0.0)) + with open(path, "rb") as fh: + data = fh.read() + steps = re.sub(r"^

Saved .*?

", "", lid_steps(lid, f"{base}.3mf"), flags=re.S) + return data, steps.replace(" (the button below, or File â–¸ Import)", " (File â–¸ Import)") + + +def export_stl(text: str) -> bytes: + p = _params(text) + d = VaseDesign(p) + require_exportable(d) + path = os.path.join(tempfile.gettempdir(), "vase.stl") + write_stl(path, *d.export_mesh(), _name(p)) + with open(path, "rb") as fh: + return fh.read() + + +def save_vase(text: str) -> str: + """A .vase design file, the same as the desktop app saves.""" + return json.dumps({"format": FILE_FORMAT, "version": FILE_VERSION, "params": _params(text).to_dict()}, indent=2) + + +def open_vase(text: str) -> str: + """The settings from a .vase design file, made safe.""" + data = json.load(io.StringIO(text)) + if data.get("format") != FILE_FORMAT: + raise ValueError("That is not a Vaseinator design file.") + return _dump(VaseParams.from_dict(data.get("params", {}))) + + +def workspace_action(state: str, text: str, op: str, payload: str = "{}") -> str: + return json.dumps(Workspace(json.loads(state) if state else None).command(op, _params(text), json.loads(payload))) + + +def compare_design(text: str) -> str: + return json.dumps(comparison(VaseDesign(_params(text)))) + + +def export_calibration(text: str): + p = _params(text) + path = os.path.join(tempfile.gettempdir(), "calibration.3mf") + write_3mf(path, "Clearance samples", calibration_parts(), (p.bed_x/2, p.bed_y/2, 0)) + with open(path, "rb") as fh: + return fh.read(), "

" + CALIBRATION_HELP + "

" diff --git a/web/index.html b/web/index.html index ead9b42..b7f886e 100644 --- a/web/index.html +++ b/web/index.html @@ -1,251 +1,254 @@ - - - - - - Vaseinator - - - - - - -
- -
-

Vaseinator

-

Vases, lampshades, masks and helmets that print without supports

-
- - -
- -
- - -
- -
-
- - - - -
- - - -
- -

Drag to turn · wheel or pinch to zoom · right-drag to move

-
- -

Getting Vaseinator ready…

-

The first time takes a little while: it brings Python and numpy into your browser, - so the vase is built right here - nothing is sent anywhere.

-
-
- -
-
-
From the side
-
Layers from above
-
-
-
-

-
-
-
- - - - - -
-

Choose a design

- -
-

Click one to start from it. Your printer settings are kept, and Undo takes you back.

-
-
- - -
-

How to print it

- -
- Save -
-
-
- - -
-

Help

- -
-
- -
- - -
-

Drawing the cross-section

-
- - - - - -
-
- -
- - - - + + + + + + Vaseinator + + + + + + +
+ +
+

Vaseinator

+

Vases, lampshades, masks and helmets that print without supports

+
+ + +
+ +
+ + +
+ +
+
+ + + + +
+ + + +
+ +

Drag to turn · wheel or pinch to zoom · right-drag to move

+
+ +

Getting Vaseinator ready…

+

The first time takes a little while: it brings Python and numpy into your browser, + so the vase is built right here - nothing is sent anywhere.

+
+
+ +
+
+
From the side
+
Layers from above
+
+
+
+

+
+
+
+ + + + + +
+

Choose a design

+ +
+

Click one to start from it. Your printer settings are kept, and Undo takes you back.

+
+
+ + +
+

How to print it

+ +
+ Save +
+
+
+ + +
+

Help

+ +
+
+ +
+ + +
+

Drawing the cross-section

+
+ + + + + +
+
+ +
+ + + + diff --git a/web/library.js b/web/library.js new file mode 100644 index 0000000..ae62b40 --- /dev/null +++ b/web/library.js @@ -0,0 +1,48 @@ +// Shared Python owns library validation, profiles, locks and restoration; this file only presents them. +export function libraryDialog({read, command, current, exportFile, importFile}) { + const dlg = document.createElement('dialog'); + dlg.className = 'library'; + const title = document.createElement('h2'); title.textContent = 'Design library and profiles'; dlg.append(title); + const help = document.createElement('p'); + help.textContent = 'Saved in this browser. Export a library backup to transfer it to desktop or another browser. Recovery keeps the last 30 settled designs. Thumbnails are silhouette sketches.'; + dlg.append(help); + const locks = document.createElement('div'); dlg.append(locks); + const content = document.createElement('div'); dlg.append(content); + const footer = document.createElement('div'); dlg.append(footer); + function button(parent, label, work) { + const b=document.createElement('button'); b.textContent=label; parent.append(b); + b.addEventListener('click',async()=>{b.disabled=true;try{await work();}finally{b.disabled=false;}});return b; + } + button(footer,'Export library backup',exportFile); + button(footer,'Import library',()=>importFile(refresh)); + button(footer,'Close',()=>dlg.close()); + dlg.addEventListener('close',()=>dlg.remove()); + async function refresh() { + const data = await read(); + locks.replaceChildren(); content.replaceChildren(); + const label=document.createElement('p');label.textContent='Keep these when using Surprise me (printer settings are always kept):';locks.append(label); + for(const name of data.locks){ + const lab=document.createElement('label'), input=document.createElement('input');input.type='checkbox';input.checked=data.workspace.locks.includes(name); + lab.append(input,document.createTextNode(name)); locks.append(lab); + input.addEventListener('change',async()=>{const selected=[...locks.querySelectorAll('label')].filter(l=>l.firstChild.checked).map(l=>l.textContent);await command('locks',{locks:selected});}); + } + for(const [category,title] of [['designs','My designs'],['profiles','Printer and material profiles'],['recovery','Recovery']]){ + const section=document.createElement('section');content.append(section); + const heading=document.createElement('h3');heading.textContent=title;section.append(heading); + async function save(kind){const name=prompt('Name for this saved entry:',current().name);if(name?.trim()){await command('save',{category,name,kind});await refresh();}} + if(category==='designs')button(section,'Save current variant',()=>save('printer')); + if(category==='profiles'){button(section,'Save printer',()=>save('printer'));button(section,'Save material',()=>save('material'));} + const entries=[...data.workspace[category]].sort((a,b)=>Number(b.favorite)-Number(a.favorite)||b.created.localeCompare(a.created)); + if(!entries.length){const p=document.createElement('p');p.textContent='No saved entries yet.';section.append(p);} + for(const e of entries){ + const row=document.createElement('div');row.className='library-entry';section.append(row); + if(category==='designs'){const img=document.createElement('img');img.src='data:image/svg+xml;charset=utf-8,'+encodeURIComponent(data.thumbnails[e.id]);img.alt='Silhouette sketch';row.append(img);} + const label=document.createElement('span');label.textContent=(e.favorite?'★ ':'')+(category==='profiles'?e.kind+': ':'')+e.name+' — '+new Date(e.created).toLocaleString();row.append(label); + button(row,category==='profiles'?'Apply':'Restore',async()=>{await command('restore',{category,id:e.id});await refresh();}); + if(category==='designs')button(row,e.favorite?'Unfavourite':'Favourite',async()=>{await command('favorite',{id:e.id});await refresh();}); + button(row,'Remove',async()=>{if(confirm('Remove this saved entry?')){await command('delete',{category,id:e.id});await refresh();}}); + } + } + } + document.body.append(dlg);dlg.showModal();refresh(); +} diff --git a/web/style.css b/web/style.css index 647212a..485c184 100644 --- a/web/style.css +++ b/web/style.css @@ -1,406 +1,413 @@ -/* Vaseinator on the web: the desktop app, dark like it (light if the browser or the View menu asks). */ -:root { - --bg: #1b1e23; - --panel: #23272e; - --raised: #2b3038; - --well: #1a1c21; - --grid: #2a2e36; - --line: #3b4049; - --text: #e1e6ec; - --muted: #9aa3ad; - --accent: #f0a356; - --accent-ink: #1b1e23; - --ok: #6fdc8c; - --bad: #ff6b6b; - --warn: #f2c14e; - --view-top: #3a4150; - --view-bottom: #15171b; - --scale: 1; - color-scheme: dark; -} -@media (prefers-color-scheme: light) { - :root:not([data-theme="dark"]) { - --bg: #eef0f3; - --panel: #ffffff; - --raised: #f3f4f6; - --well: #f7f8fa; - --grid: #e3e6ea; - --line: #d0d5db; - --text: #1d2228; - --muted: #5c6670; - --accent: #c46a1e; - --accent-ink: #ffffff; - --ok: #1d8a45; - --bad: #c62828; - --warn: #9a6a00; - --view-top: #dfe5ee; - --view-bottom: #aab3c0; - color-scheme: light; - } -} -:root[data-theme="light"] { - --bg: #eef0f3; - --panel: #ffffff; - --raised: #f3f4f6; - --well: #f7f8fa; - --grid: #e3e6ea; - --line: #d0d5db; - --text: #1d2228; - --muted: #5c6670; - --accent: #c46a1e; - --accent-ink: #ffffff; - --ok: #1d8a45; - --bad: #c62828; - --warn: #9a6a00; - --view-top: #dfe5ee; - --view-bottom: #aab3c0; - color-scheme: light; -} - -* { box-sizing: border-box; } -[hidden] { display: none !important; } -html { font-size: calc(15px * var(--scale)); } -body { - margin: 0; - background: var(--bg); - color: var(--text); - font-family: "Segoe UI", system-ui, -apple-system, Roboto, sans-serif; - height: 100dvh; - display: flex; - flex-direction: column; -} -button, input, select, textarea { font: inherit; color: inherit; } -button { - background: var(--raised); - border: 1px solid var(--line); - border-radius: 6px; - padding: 0.4rem 0.8rem; - cursor: pointer; -} -button:hover:not(:disabled) { border-color: var(--accent); } -button:disabled { opacity: 0.45; cursor: default; } -button:focus-visible, input:focus-visible, a:focus-visible, summary:focus-visible, select:focus-visible, -textarea:focus-visible, canvas:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} -button[aria-pressed="true"] { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); } -input[type="number"], input[type="text"], select, textarea { - padding: 0.3rem 0.4rem; - border: 1px solid var(--line); - border-radius: 6px; - background: var(--raised); -} -kbd { margin-left: auto; padding-left: 1.5rem; color: var(--muted); font: inherit; font-size: 0.8rem; } - -/* ---------------------------------------------------------------- header and menus */ -header { - display: flex; - align-items: center; - gap: 0.8rem; - padding: 0.4rem 1rem; - border-bottom: 1px solid var(--line); - background: var(--panel); - position: relative; - z-index: 5; -} -.logo { width: 34px; height: 34px; } -.brand h1 { margin: 0; font-size: 1.2rem; color: var(--accent); } -.brand p { margin: 0; color: var(--muted); font-size: 0.8rem; } -.menubar { display: flex; gap: 0.1rem; margin-left: 0.6rem; } -.menu { position: relative; } -.menu summary { - list-style: none; - cursor: pointer; - padding: 0.35rem 0.6rem; - border-radius: 6px; -} -.menu summary::-webkit-details-marker { display: none; } -.menu[open] summary, .menu summary:hover { background: var(--raised); } -.menu .items { - position: absolute; - top: 100%; - left: 0; - min-width: 15rem; - max-height: 75vh; - overflow-y: auto; - padding: 0.3rem; - background: var(--panel); - border: 1px solid var(--line); - border-radius: 8px; - box-shadow: 0 8px 24px rgb(0 0 0 / 0.35); - display: flex; - flex-direction: column; -} -.menu .items button { - display: flex; - align-items: center; - text-align: left; - border: none; - background: none; - padding: 0.4rem 0.6rem; -} -.menu .items button:hover { background: var(--raised); } -.menu hr { border: none; border-top: 1px solid var(--line); margin: 0.25rem 0; width: 100%; } -nav { margin-left: auto; display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap; } -nav a { - color: var(--text); - text-decoration: none; - border: 1px solid var(--line); - border-radius: 6px; - padding: 0.4rem 0.8rem; -} -nav a:hover { border-color: var(--accent); } - -/* ---------------------------------------------------------------- layout */ -main { - flex: 1; - min-height: 0; - display: grid; - grid-template-columns: minmax(310px, 400px) 1fr; - grid-template-rows: 1fr auto; - grid-template-areas: "panel viewer" "panel below"; -} -#panel { - grid-area: panel; - overflow-y: auto; - overflow-x: hidden; - padding: 0.6rem 1rem 1.5rem; - background: var(--panel); - border-right: 1px solid var(--line); -} -#viewer { grid-area: viewer; position: relative; min-height: 0; } -#below { - grid-area: below; - display: grid; - grid-template-columns: minmax(260px, 2fr) 3fr; - height: 34vh; - min-height: 210px; - border-top: 1px solid var(--line); - background: var(--panel); -} -#plots { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; padding: 0.4rem 0.6rem; min-height: 0; } -#plots figure { margin: 0; display: flex; flex-direction: column; min-height: 0; } -#plots figcaption { color: var(--muted); font-size: 0.82rem; margin-bottom: 0.2rem; } -#plots canvas { flex: 1; width: 100%; min-height: 0; border-radius: 6px; } -#info { overflow-y: auto; padding: 0.3rem 1rem 0.8rem; border-left: 1px solid var(--line); font-size: 0.9rem; } - -/* ---------------------------------------------------------------- the panel */ -.modes { display: grid; grid-template-columns: 1fr 1fr; margin-bottom: 0.7rem; } -.modes button { border-radius: 0; padding: 0.5rem; } -.modes button:first-child { border-radius: 6px 0 0 6px; } -.modes button:last-child { border-radius: 0 6px 6px 0; } -.modes button[aria-selected="true"] { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); } -#panel h2 { margin: 0 0 0.6rem; font-size: 1.3rem; color: var(--accent); } -#panel h3 { margin: 1.1rem 0 0.45rem; font-size: 1.05rem; color: var(--accent); } -.row { display: flex; gap: 0.5rem; align-items: center; } -.row > .big { flex: 1; } -.big { padding: 0.6rem 0.8rem; font-weight: 600; } -.wide { width: 100%; margin-top: 0.4rem; } -.numbers label { display: flex; align-items: center; gap: 0.35rem; flex: 1; } -.numbers input { width: 4.6rem; padding: 0.4rem; } -#boxrow .numbers { flex-wrap: wrap; row-gap: 0.4rem; } -#boxrow .numbers label { flex: 0 1 auto; } -#boxrow .numbers input { width: 3.6rem; } -.tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.4rem; } -.tile { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.2rem; - padding: 0.35rem 0.2rem 0.3rem; - font-size: 0.82rem; - line-height: 1.15; -} -.tile svg { width: 46px; height: 46px; } -.tile[aria-pressed="true"] { - background: color-mix(in srgb, var(--accent) 18%, var(--raised)); - color: var(--text); - border: 2px solid var(--accent); -} -#twist { flex: 1; accent-color: var(--accent); } -.words { min-width: 10rem; color: var(--muted); } -.swatches { display: flex; flex-wrap: wrap; gap: 0.35rem; } -.swatch { width: 2.3rem; height: 2.3rem; padding: 3px; border-radius: 50%; } -.swatch span { display: block; width: 100%; height: 100%; border-radius: 50%; overflow: hidden; } -.swatch[aria-pressed="true"] { border: 3px solid var(--accent); background: var(--raised); } -a.save { display: block; box-sizing: border-box; text-align: center; text-decoration: none; margin-top: 0.8rem; } -.save { - width: 100%; - margin-top: 1.4rem; - padding: 0.8rem; - font-size: 1.15rem; - font-weight: 700; - background: var(--accent); - color: var(--accent-ink); - border-color: var(--accent); -} -button.lid { width: 100%; margin-top: 0.5rem; padding: 0.6rem; font-weight: 600; } -.files { margin-top: 0.5rem; flex-wrap: wrap; } -.files button { flex: 1; font-size: 0.88rem; } -details#printer { margin-top: 1rem; border-top: 1px solid var(--line); padding-top: 0.6rem; } -details#printer summary { cursor: pointer; font-weight: 600; } -.hint { color: var(--muted); font-size: 0.85rem; margin: 0.4rem 0; } -.grid { display: grid; grid-template-columns: 1fr; gap: 0.35rem; } -.grid label { display: flex; align-items: center; justify-content: space-between; gap: 0.4rem; font-size: 0.9rem; } -.grid input { width: 5rem; margin-left: auto; } - -/* ---------------------------------------------------------------- Advanced: every setting */ -.namerow { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } -.namerow input { flex: 1; } -.group { border: 1px solid var(--line); border-radius: 8px; margin-top: 0.7rem; background: var(--bg); } -.group > summary { - cursor: pointer; - padding: 0.45rem 0.7rem; - font-weight: 600; - color: var(--accent); -} -.group .rows { - display: grid; - grid-template-columns: max-content minmax(0, 1fr); - gap: 0.35rem 0.6rem; - align-items: center; - padding: 0.2rem 0.7rem 0.7rem; -} -.rows > .label { text-align: right; font-size: 0.9rem; color: var(--muted); } -.rows > .control { display: flex; align-items: center; gap: 0.35rem; min-width: 0; } -.rows > .whole { grid-column: 1 / -1; display: flex; flex-direction: column; gap: 0.35rem; } -.control input[type="number"] { width: 6.5rem; } -.control select { max-width: 100%; min-width: 0; flex: 1; } -.control input[type="range"] { flex: 1; accent-color: var(--accent); } -.control input[type="color"] { width: 3rem; height: 1.9rem; padding: 0.1rem; border: 1px solid var(--line); border-radius: 6px; background: var(--raised); } -.control .unit, .control .none { color: var(--muted); font-size: 0.85rem; } -.control .dice { padding: 0.2rem 0.5rem; } -.whole canvas { width: 100%; border-radius: 6px; display: block; } -.whole textarea { width: 100%; min-height: 5.5rem; font-family: Consolas, "Cascadia Mono", monospace; font-size: 0.88rem; } -.whole .row { flex-wrap: wrap; } -.whole .row button, .whole .row select { font-size: 0.85rem; padding: 0.25rem 0.5rem; } -.whole .row select { flex: 1; min-width: 0; max-width: 100%; } -.outlinebox canvas { height: 300px; } - -/* ---------------------------------------------------------------- the 3D view */ -#viewer { background: linear-gradient(var(--view-top), var(--view-bottom)); } -#canvas { display: block; width: 100%; height: 100%; touch-action: none; } -.viewbar { - position: absolute; - top: 0.5rem; - left: 0.5rem; - right: 0.5rem; - display: flex; - gap: 0.4rem; - flex-wrap: wrap; - justify-content: flex-end; - pointer-events: none; -} -.viewbar > * { pointer-events: auto; } -.viewbar button { background: color-mix(in srgb, var(--panel) 88%, transparent); } -.viewbar button[aria-pressed="true"] { background: var(--accent); color: var(--accent-ink); } -.views { display: flex; margin-right: auto; } -.views button { border-radius: 0; } -.views button:first-child { border-radius: 6px 0 0 6px; } -.views button:last-child { border-radius: 0 6px 6px 0; } -.views button[aria-pressed="true"] { background: var(--accent); color: var(--accent-ink); } -.help { position: absolute; left: 0.8rem; bottom: 0.4rem; margin: 0; font-size: 0.82rem; color: var(--muted); - text-shadow: 0 0 4px var(--bg); } -#busy { - position: absolute; - top: 3.1rem; - left: 0.6rem; - padding: 0.35rem 0.7rem; - border-radius: 6px; - background: color-mix(in srgb, var(--panel) 85%, transparent); - font-size: 0.85rem; -} -.spinner { - display: inline-block; - width: 0.8rem; - height: 0.8rem; - border: 2px solid var(--muted); - border-top-color: var(--accent); - border-radius: 50%; - vertical-align: -2px; - animation: turn 0.9s linear infinite; -} -@keyframes turn { to { transform: rotate(360deg); } } -@media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 3s; } } -#loading { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - text-align: center; - padding: 2rem; - background: color-mix(in srgb, var(--bg) 92%, transparent); - z-index: 2; -} -#loading img { width: 72px; height: 72px; margin-bottom: 0.5rem; } -#loading p { max-width: 30rem; margin: 0.3rem 0; } -#loadingtext { color: var(--muted); } -#loading.failed #loadingtext { color: var(--bad); } - -/* ---------------------------------------------------------------- the report (the desktop app's own HTML) */ -#report h3 { margin: 0.5rem 0 0.3rem; color: var(--accent); font-size: 1rem; } -#report td { padding: 0.12rem 0.6rem 0.12rem 0; vertical-align: top; } -#report .k { color: var(--muted); } -#report td.k { white-space: nowrap; } -#report .e { color: var(--bad); } -#report .w { color: var(--warn); } -#report .ok { color: var(--ok); } -#report p { margin: 0.35rem 0; } -#report ul { padding-left: 1.2rem; margin: 0.3rem 0; } -.status { color: var(--accent); font-size: 0.88rem; min-height: 1.2em; margin: 0.4rem 0 0; } - -/* ---------------------------------------------------------------- dialogs */ -dialog { - border: 1px solid var(--line); - border-radius: 10px; - background: var(--panel); - color: var(--text); - padding: 1rem 1.2rem; - max-width: min(1100px, 94vw); - max-height: 90dvh; -} -dialog::backdrop { background: rgb(0 0 0 / 0.55); } -.dialoghead { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } -.dialoghead h2 { margin: 0; color: var(--accent); font-size: 1.25rem; } -.designs { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 0.6rem; margin-top: 0.5rem; } -.design { display: flex; flex-direction: column; align-items: center; gap: 0.3rem; padding: 0.4rem; } -.design img { width: 150px; height: 190px; object-fit: contain; } -.design b { font-size: 0.9rem; } -#steplist ol { padding-left: 1.4rem; } -#steplist li { margin: 0.35rem 0; } -#help { width: min(760px, 94vw); } -.helptext { max-height: 62dvh; overflow-y: auto; line-height: 1.45; } -.helptext code { color: var(--accent); } -.helptext td { padding: 2px 8px; vertical-align: top; } -#helpexamples { margin-top: 0.8rem; } -#helpexamples select { max-width: 24rem; } -dialog.full { - width: 96vw; - height: 94dvh; - max-width: none; - max-height: none; - display: none; - flex-direction: column; - gap: 0.6rem; -} -dialog.full[open] { display: flex; } -dialog.full canvas { flex: 1; width: 100%; min-height: 0; border-radius: 8px; touch-action: none; } -.tools { flex-wrap: wrap; } -.tools input { width: 4.5rem; } - -/* ---------------------------------------------------------------- narrow screens: one column */ -@media (max-width: 900px) { - body { height: auto; } - main { display: flex; flex-direction: column; } - #viewer { order: 1; height: 60vh; } - #panel { order: 2; border-right: none; border-top: 1px solid var(--line); overflow: visible; } - #below { order: 3; grid-template-columns: 1fr; height: auto; } - #plots { height: 220px; } - #info { border-left: none; border-top: 1px solid var(--line); } - .brand p { display: none; } - header { flex-wrap: wrap; } - .menubar { order: 3; width: 100%; margin-left: 0; } -} +/* Vaseinator on the web: the desktop app, dark like it (light if the browser or the View menu asks). */ +:root { + --bg: #1b1e23; + --panel: #23272e; + --raised: #2b3038; + --well: #1a1c21; + --grid: #2a2e36; + --line: #3b4049; + --text: #e1e6ec; + --muted: #9aa3ad; + --accent: #f0a356; + --accent-ink: #1b1e23; + --ok: #6fdc8c; + --bad: #ff6b6b; + --warn: #f2c14e; + --view-top: #3a4150; + --view-bottom: #15171b; + --scale: 1; + color-scheme: dark; +} +@media (prefers-color-scheme: light) { + :root:not([data-theme="dark"]) { + --bg: #eef0f3; + --panel: #ffffff; + --raised: #f3f4f6; + --well: #f7f8fa; + --grid: #e3e6ea; + --line: #d0d5db; + --text: #1d2228; + --muted: #5c6670; + --accent: #c46a1e; + --accent-ink: #ffffff; + --ok: #1d8a45; + --bad: #c62828; + --warn: #9a6a00; + --view-top: #dfe5ee; + --view-bottom: #aab3c0; + color-scheme: light; + } +} +:root[data-theme="light"] { + --bg: #eef0f3; + --panel: #ffffff; + --raised: #f3f4f6; + --well: #f7f8fa; + --grid: #e3e6ea; + --line: #d0d5db; + --text: #1d2228; + --muted: #5c6670; + --accent: #c46a1e; + --accent-ink: #ffffff; + --ok: #1d8a45; + --bad: #c62828; + --warn: #9a6a00; + --view-top: #dfe5ee; + --view-bottom: #aab3c0; + color-scheme: light; +} + +* { box-sizing: border-box; } +[hidden] { display: none !important; } +html { font-size: calc(15px * var(--scale)); } +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: "Segoe UI", system-ui, -apple-system, Roboto, sans-serif; + height: 100dvh; + display: flex; + flex-direction: column; +} +button, input, select, textarea { font: inherit; color: inherit; } +button { + background: var(--raised); + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.4rem 0.8rem; + cursor: pointer; +} +button:hover:not(:disabled) { border-color: var(--accent); } +button:disabled { opacity: 0.45; cursor: default; } +button:focus-visible, input:focus-visible, a:focus-visible, summary:focus-visible, select:focus-visible, +textarea:focus-visible, canvas:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +button[aria-pressed="true"] { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); } +input[type="number"], input[type="text"], select, textarea { + padding: 0.3rem 0.4rem; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--raised); +} +kbd { margin-left: auto; padding-left: 1.5rem; color: var(--muted); font: inherit; font-size: 0.8rem; } + +/* ---------------------------------------------------------------- header and menus */ +header { + display: flex; + align-items: center; + gap: 0.8rem; + padding: 0.4rem 1rem; + border-bottom: 1px solid var(--line); + background: var(--panel); + position: relative; + z-index: 5; +} +.logo { width: 34px; height: 34px; } +.brand h1 { margin: 0; font-size: 1.2rem; color: var(--accent); } +.brand p { margin: 0; color: var(--muted); font-size: 0.8rem; } +.menubar { display: flex; gap: 0.1rem; margin-left: 0.6rem; } +.menu { position: relative; } +.menu summary { + list-style: none; + cursor: pointer; + padding: 0.35rem 0.6rem; + border-radius: 6px; +} +.menu summary::-webkit-details-marker { display: none; } +.menu[open] summary, .menu summary:hover { background: var(--raised); } +.menu .items { + position: absolute; + top: 100%; + left: 0; + min-width: 15rem; + max-height: 75vh; + overflow-y: auto; + padding: 0.3rem; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: 0 8px 24px rgb(0 0 0 / 0.35); + display: flex; + flex-direction: column; +} +.menu .items button { + display: flex; + align-items: center; + text-align: left; + border: none; + background: none; + padding: 0.4rem 0.6rem; +} +.menu .items button:hover { background: var(--raised); } +.menu hr { border: none; border-top: 1px solid var(--line); margin: 0.25rem 0; width: 100%; } +nav { margin-left: auto; display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap; } +nav a { + color: var(--text); + text-decoration: none; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.4rem 0.8rem; +} +nav a:hover { border-color: var(--accent); } + +/* ---------------------------------------------------------------- layout */ +main { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: minmax(310px, 400px) 1fr; + grid-template-rows: 1fr auto; + grid-template-areas: "panel viewer" "panel below"; +} +#panel { + grid-area: panel; + overflow-y: auto; + overflow-x: hidden; + padding: 0.6rem 1rem 1.5rem; + background: var(--panel); + border-right: 1px solid var(--line); +} +#viewer { grid-area: viewer; position: relative; min-height: 0; } +#below { + grid-area: below; + display: grid; + grid-template-columns: minmax(260px, 2fr) 3fr; + height: 34vh; + min-height: 210px; + border-top: 1px solid var(--line); + background: var(--panel); +} +#plots { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; padding: 0.4rem 0.6rem; min-height: 0; } +#plots figure { margin: 0; display: flex; flex-direction: column; min-height: 0; } +#plots figcaption { color: var(--muted); font-size: 0.82rem; margin-bottom: 0.2rem; } +#plots canvas { flex: 1; width: 100%; min-height: 0; border-radius: 6px; } +#info { overflow-y: auto; padding: 0.3rem 1rem 0.8rem; border-left: 1px solid var(--line); font-size: 0.9rem; } + +/* ---------------------------------------------------------------- the panel */ +.modes { display: grid; grid-template-columns: 1fr 1fr; margin-bottom: 0.7rem; } +.modes button { border-radius: 0; padding: 0.5rem; } +.modes button:first-child { border-radius: 6px 0 0 6px; } +.modes button:last-child { border-radius: 0 6px 6px 0; } +.modes button[aria-selected="true"] { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); } +#panel h2 { margin: 0 0 0.6rem; font-size: 1.3rem; color: var(--accent); } +#panel h3 { margin: 1.1rem 0 0.45rem; font-size: 1.05rem; color: var(--accent); } +.row { display: flex; gap: 0.5rem; align-items: center; } +.row > .big { flex: 1; } +.big { padding: 0.6rem 0.8rem; font-weight: 600; } +.wide { width: 100%; margin-top: 0.4rem; } +.numbers label { display: flex; align-items: center; gap: 0.35rem; flex: 1; } +.numbers input { width: 4.6rem; padding: 0.4rem; } +#boxrow .numbers { flex-wrap: wrap; row-gap: 0.4rem; } +#boxrow .numbers label { flex: 0 1 auto; } +#boxrow .numbers input { width: 3.6rem; } +.tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.4rem; } +.tile { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.2rem; + padding: 0.35rem 0.2rem 0.3rem; + font-size: 0.82rem; + line-height: 1.15; +} +.tile svg { width: 46px; height: 46px; } +.tile[aria-pressed="true"] { + background: color-mix(in srgb, var(--accent) 18%, var(--raised)); + color: var(--text); + border: 2px solid var(--accent); +} +#twist { flex: 1; accent-color: var(--accent); } +.words { min-width: 10rem; color: var(--muted); } +.swatches { display: flex; flex-wrap: wrap; gap: 0.35rem; } +.swatch { width: 2.3rem; height: 2.3rem; padding: 3px; border-radius: 50%; } +.swatch span { display: block; width: 100%; height: 100%; border-radius: 50%; overflow: hidden; } +.swatch[aria-pressed="true"] { border: 3px solid var(--accent); background: var(--raised); } +a.save { display: block; box-sizing: border-box; text-align: center; text-decoration: none; margin-top: 0.8rem; } +.save { + width: 100%; + margin-top: 1.4rem; + padding: 0.8rem; + font-size: 1.15rem; + font-weight: 700; + background: var(--accent); + color: var(--accent-ink); + border-color: var(--accent); +} +button.lid { width: 100%; margin-top: 0.5rem; padding: 0.6rem; font-weight: 600; } +.files { margin-top: 0.5rem; flex-wrap: wrap; } +.files button { flex: 1; font-size: 0.88rem; } +details#printer { margin-top: 1rem; border-top: 1px solid var(--line); padding-top: 0.6rem; } +details#printer summary { cursor: pointer; font-weight: 600; } +.hint { color: var(--muted); font-size: 0.85rem; margin: 0.4rem 0; } +.grid { display: grid; grid-template-columns: 1fr; gap: 0.35rem; } +.grid label { display: flex; align-items: center; justify-content: space-between; gap: 0.4rem; font-size: 0.9rem; } +.grid input { width: 5rem; margin-left: auto; } + +/* ---------------------------------------------------------------- Advanced: every setting */ +.namerow { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } +.namerow input { flex: 1; } +.group { border: 1px solid var(--line); border-radius: 8px; margin-top: 0.7rem; background: var(--bg); } +.group > summary { + cursor: pointer; + padding: 0.45rem 0.7rem; + font-weight: 600; + color: var(--accent); +} +.group .rows { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 0.35rem 0.6rem; + align-items: center; + padding: 0.2rem 0.7rem 0.7rem; +} +.rows > .label { text-align: right; font-size: 0.9rem; color: var(--muted); } +.rows > .control { display: flex; align-items: center; gap: 0.35rem; min-width: 0; } +.rows > .whole { grid-column: 1 / -1; display: flex; flex-direction: column; gap: 0.35rem; } +.control input[type="number"] { width: 6.5rem; } +.control select { max-width: 100%; min-width: 0; flex: 1; } +.control input[type="range"] { flex: 1; accent-color: var(--accent); } +.control input[type="color"] { width: 3rem; height: 1.9rem; padding: 0.1rem; border: 1px solid var(--line); border-radius: 6px; background: var(--raised); } +.control .unit, .control .none { color: var(--muted); font-size: 0.85rem; } +.control .dice { padding: 0.2rem 0.5rem; } +.whole canvas { width: 100%; border-radius: 6px; display: block; } +.whole textarea { width: 100%; min-height: 5.5rem; font-family: Consolas, "Cascadia Mono", monospace; font-size: 0.88rem; } +.whole .row { flex-wrap: wrap; } +.whole .row button, .whole .row select { font-size: 0.85rem; padding: 0.25rem 0.5rem; } +.whole .row select { flex: 1; min-width: 0; max-width: 100%; } +.outlinebox canvas { height: 300px; } + +/* ---------------------------------------------------------------- the 3D view */ +#viewer { background: linear-gradient(var(--view-top), var(--view-bottom)); } +#canvas { display: block; width: 100%; height: 100%; touch-action: none; } +.viewbar { + position: absolute; + top: 0.5rem; + left: 0.5rem; + right: 0.5rem; + display: flex; + gap: 0.4rem; + flex-wrap: wrap; + justify-content: flex-end; + pointer-events: none; +} +.viewbar > * { pointer-events: auto; } +.viewbar button { background: color-mix(in srgb, var(--panel) 88%, transparent); } +.viewbar button[aria-pressed="true"] { background: var(--accent); color: var(--accent-ink); } +.views { display: flex; margin-right: auto; } +.views button { border-radius: 0; } +.views button:first-child { border-radius: 6px 0 0 6px; } +.views button:last-child { border-radius: 0 6px 6px 0; } +.views button[aria-pressed="true"] { background: var(--accent); color: var(--accent-ink); } +.help { position: absolute; left: 0.8rem; bottom: 0.4rem; margin: 0; font-size: 0.82rem; color: var(--muted); + text-shadow: 0 0 4px var(--bg); } +#busy { + position: absolute; + top: 3.1rem; + left: 0.6rem; + padding: 0.35rem 0.7rem; + border-radius: 6px; + background: color-mix(in srgb, var(--panel) 85%, transparent); + font-size: 0.85rem; +} +.spinner { + display: inline-block; + width: 0.8rem; + height: 0.8rem; + border: 2px solid var(--muted); + border-top-color: var(--accent); + border-radius: 50%; + vertical-align: -2px; + animation: turn 0.9s linear infinite; +} +@keyframes turn { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 3s; } } +#loading { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 2rem; + background: color-mix(in srgb, var(--bg) 92%, transparent); + z-index: 2; +} +#loading img { width: 72px; height: 72px; margin-bottom: 0.5rem; } +#loading p { max-width: 30rem; margin: 0.3rem 0; } +#loadingtext { color: var(--muted); } +#loading.failed #loadingtext { color: var(--bad); } + +/* ---------------------------------------------------------------- the report (the desktop app's own HTML) */ +#report h3 { margin: 0.5rem 0 0.3rem; color: var(--accent); font-size: 1rem; } +#report td { padding: 0.12rem 0.6rem 0.12rem 0; vertical-align: top; } +#report .k { color: var(--muted); } +#report td.k { white-space: nowrap; } +#report .e { color: var(--bad); } +#report .w { color: var(--warn); } +#report .ok { color: var(--ok); } +#report p { margin: 0.35rem 0; } +#report ul { padding-left: 1.2rem; margin: 0.3rem 0; } +.status { color: var(--accent); font-size: 0.88rem; min-height: 1.2em; margin: 0.4rem 0 0; } + +/* ---------------------------------------------------------------- dialogs */ +dialog { + border: 1px solid var(--line); + border-radius: 10px; + background: var(--panel); + color: var(--text); + padding: 1rem 1.2rem; + max-width: min(1100px, 94vw); + max-height: 90dvh; +} +dialog::backdrop { background: rgb(0 0 0 / 0.55); } +.dialoghead { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } +.dialoghead h2 { margin: 0; color: var(--accent); font-size: 1.25rem; } +.designs { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 0.6rem; margin-top: 0.5rem; } +.design { display: flex; flex-direction: column; align-items: center; gap: 0.3rem; padding: 0.4rem; } +.design img { width: 150px; height: 190px; object-fit: contain; } +.design b { font-size: 0.9rem; } +#steplist ol { padding-left: 1.4rem; } +#steplist li { margin: 0.35rem 0; } +#help { width: min(760px, 94vw); } +.helptext { max-height: 62dvh; overflow-y: auto; line-height: 1.45; } +.helptext code { color: var(--accent); } +.helptext td { padding: 2px 8px; vertical-align: top; } +#helpexamples { margin-top: 0.8rem; } +#helpexamples select { max-width: 24rem; } +dialog.full { + width: 96vw; + height: 94dvh; + max-width: none; + max-height: none; + display: none; + flex-direction: column; + gap: 0.6rem; +} +dialog.full[open] { display: flex; } +dialog.full canvas { flex: 1; width: 100%; min-height: 0; border-radius: 8px; touch-action: none; } +.tools { flex-wrap: wrap; } +.tools input { width: 4.5rem; } + +/* ---------------------------------------------------------------- narrow screens: one column */ +@media (max-width: 900px) { + body { height: auto; } + main { display: flex; flex-direction: column; } + #viewer { order: 1; height: 60vh; } + #panel { order: 2; border-right: none; border-top: 1px solid var(--line); overflow: visible; } + #below { order: 3; grid-template-columns: 1fr; height: auto; } + #plots { height: 220px; } + #info { border-left: none; border-top: 1px solid var(--line); } + .brand p { display: none; } + header { flex-wrap: wrap; } + .menubar { order: 3; width: 100%; margin-left: 0; } +} + +.library { width: min(960px, 92vw); max-height: 85vh; overflow: auto; } +.library label { display: inline-flex; gap: .35em; margin: .4em; } +.library button { margin: .25em; } +.library-entry { display: flex; flex-wrap: wrap; align-items: center; gap: .5em; padding: .5em; border-bottom: 1px solid #8885; } +.library-entry img { width: 65px; height: 65px; } +.library-entry span { flex: 1; min-width: 150px; } diff --git a/web/worker.js b/web/worker.js index 44ab9b0..040b0a7 100644 --- a/web/worker.js +++ b/web/worker.js @@ -1,78 +1,78 @@ -// Vaseinator in the browser: the desktop app's own Python, run by Pyodide in a worker so the page stays -// responsive. The page runs two: one builds the vase, the other answers everything else at once. -// -// Messages in: {id, call, args} - call is a function of bridge.py -// Messages out: {id, ok, result} or {id, ok: false, error}; a mesh or a file comes as transferable buffers. - -importScripts("https://cdn.jsdelivr.net/pyodide/v0.26.4/full/pyodide.js"); -// meshoptimizer: the web version's Simplify model, before saving (the desktop app uses pyfqmr) -importScripts("https://cdn.jsdelivr.net/npm/meshoptimizer@0.22.0/meshopt_simplifier.js"); - -let bridge = null; -const VERSION = self.location.search; // the site's version, so its Python comes fresh with it - -async function start() { - const py = await loadPyodide(); - await py.loadPackage("numpy"); - await MeshoptSimplifier.ready; - py.registerJsModule("meshopt", { - // positions (float32) and triangles (uint32) as bytes; the triangles kept, as bytes, merged wherever that moves - // the surface by no more than ``error`` mm - simplify(positions, triangles, error) { - const pos = positions.toJs(); - const tri = triangles.toJs(); - const [kept] = MeshoptSimplifier.simplify(new Uint32Array(tri.buffer, tri.byteOffset, tri.byteLength / 4), - new Float32Array(pos.buffer, pos.byteOffset, pos.byteLength / 4), 3, 0, error, ["ErrorAbsolute"]); - return new Uint8Array(kept.buffer, kept.byteOffset, kept.byteLength); - }, - }); - const files = await (await fetch("py/files.json" + VERSION)).json(); - py.FS.mkdirTree("/home/pyodide/vaseinator"); - for (const name of files) { - const text = await (await fetch("py/" + name + VERSION)).text(); - py.FS.writeFile("/home/pyodide/" + name, text); - } - py.runPython("import sys; sys.path.insert(0, '/home/pyodide')"); - bridge = py.pyimport("bridge"); - return bridge.start(); -} - -function bytesOf(proxy) { - // a Python bytes object, as a fresh ArrayBuffer of its own - const u8 = proxy.toJs(); - proxy.destroy?.(); - return u8.buffer.byteLength === u8.byteLength ? u8.buffer : u8.slice().buffer; -} - -self.onmessage = async (event) => { - const { id, call, args } = event.data; - try { - if (call === "start") { - self.postMessage({ id, ok: true, result: await start() }); - return; - } - if (call === "build") { - const out = bridge.build(...args); - const result = { meta: out.get(0), positions: bytesOf(out.get(1)), triangles: bytesOf(out.get(2)), - colours: bytesOf(out.get(3)), lean: bytesOf(out.get(4)) }; - out.destroy(); - self.postMessage({ id, ok: true, result }, [result.positions, result.triangles, result.colours, result.lean]); - return; - } - if (call === "export_3mf" || call === "export_lid") { - const out = bridge[call](...args); - const result = { data: bytesOf(out.get(0)), steps: out.get(1) }; - out.destroy(); - self.postMessage({ id, ok: true, result }, [result.data]); - return; - } - if (call === "export_stl") { - const buffer = bytesOf(bridge.export_stl(...args)); - self.postMessage({ id, ok: true, result: buffer }, [buffer]); - return; - } - self.postMessage({ id, ok: true, result: bridge[call](...args) }); - } catch (err) { - self.postMessage({ id, ok: false, error: String(err && err.message ? err.message : err) }); - } -}; +// Vaseinator in the browser: the desktop app's own Python, run by Pyodide in a worker so the page stays +// responsive. The page runs two: one builds the vase, the other answers everything else at once. +// +// Messages in: {id, call, args} - call is a function of bridge.py +// Messages out: {id, ok, result} or {id, ok: false, error}; a mesh or a file comes as transferable buffers. + +importScripts("https://cdn.jsdelivr.net/pyodide/v0.26.4/full/pyodide.js"); +// meshoptimizer: the web version's Simplify model, before saving (the desktop app uses pyfqmr) +importScripts("https://cdn.jsdelivr.net/npm/meshoptimizer@0.22.0/meshopt_simplifier.js"); + +let bridge = null; +const VERSION = self.location.search; // the site's version, so its Python comes fresh with it + +async function start() { + const py = await loadPyodide(); + await py.loadPackage("numpy"); + await MeshoptSimplifier.ready; + py.registerJsModule("meshopt", { + // positions (float32) and triangles (uint32) as bytes; the triangles kept, as bytes, merged wherever that moves + // the surface by no more than ``error`` mm + simplify(positions, triangles, error) { + const pos = positions.toJs(); + const tri = triangles.toJs(); + const [kept] = MeshoptSimplifier.simplify(new Uint32Array(tri.buffer, tri.byteOffset, tri.byteLength / 4), + new Float32Array(pos.buffer, pos.byteOffset, pos.byteLength / 4), 3, 0, error, ["ErrorAbsolute"]); + return new Uint8Array(kept.buffer, kept.byteOffset, kept.byteLength); + }, + }); + const files = await (await fetch("py/files.json" + VERSION)).json(); + py.FS.mkdirTree("/home/pyodide/vaseinator"); + for (const name of files) { + const text = await (await fetch("py/" + name + VERSION)).text(); + py.FS.writeFile("/home/pyodide/" + name, text); + } + py.runPython("import sys; sys.path.insert(0, '/home/pyodide')"); + bridge = py.pyimport("bridge"); + return bridge.start(); +} + +function bytesOf(proxy) { + // a Python bytes object, as a fresh ArrayBuffer of its own + const u8 = proxy.toJs(); + proxy.destroy?.(); + return u8.buffer.byteLength === u8.byteLength ? u8.buffer : u8.slice().buffer; +} + +self.onmessage = async (event) => { + const { id, call, args } = event.data; + try { + if (call === "start") { + self.postMessage({ id, ok: true, result: await start() }); + return; + } + if (call === "build") { + const out = bridge.build(...args); + const result = { meta: out.get(0), positions: bytesOf(out.get(1)), triangles: bytesOf(out.get(2)), + colours: bytesOf(out.get(3)), lean: bytesOf(out.get(4)) }; + out.destroy(); + self.postMessage({ id, ok: true, result }, [result.positions, result.triangles, result.colours, result.lean]); + return; + } + if (call === "export_3mf" || call === "export_lid" || call === "export_calibration") { + const out = bridge[call](...args); + const result = { data: bytesOf(out.get(0)), steps: out.get(1) }; + out.destroy(); + self.postMessage({ id, ok: true, result }, [result.data]); + return; + } + if (call === "export_stl") { + const buffer = bytesOf(bridge.export_stl(...args)); + self.postMessage({ id, ok: true, result: buffer }, [buffer]); + return; + } + self.postMessage({ id, ok: true, result: bridge[call](...args) }); + } catch (err) { + self.postMessage({ id, ok: false, error: String(err && err.message ? err.message : err) }); + } +}; From b8f7e748947483147a6d8788ddfda4ba4c1320f9 Mon Sep 17 00:00:00 2001 From: Rick <64953373+RickUsingGitHub@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:35:46 +1100 Subject: [PATCH 2/2] Normalize source line endings for a focused review diff --- .github/workflows/pages.yml | 80 +- README.md | 990 ++++----- tools/build_web.py | 160 +- vaseinator/app.py | 3750 +++++++++++++++++------------------ vaseinator/design_tools.py | 474 ++--- vaseinator/geometry.py | 3240 +++++++++++++++--------------- vaseinator/params.py | 692 +++---- web/app.js | 2372 +++++++++++----------- web/bridge.py | 614 +++--- web/index.html | 508 ++--- web/style.css | 826 ++++---- web/worker.js | 156 +- 12 files changed, 6931 insertions(+), 6931 deletions(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index c6e4d5c..126abbf 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,40 +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: - 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 +# 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/README.md b/README.md index e94fe8a..b82f946 100644 --- a/README.md +++ b/README.md @@ -1,495 +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. - -![Vaseinator main window](docs/images/main_window.png) - -![The ready-made vases](docs/images/gallery.png) - -| Advanced: draw how things change up the vase | Section: the printed wall | -|---|---| -| ![Advanced mode with curves](docs/images/detailed.png) | ![Section view](docs/images/section.png) | -| **Overhangs: green is easy, red needs support** | **Watch it print, in real print time** | -| ![Overhang check](docs/images/overhangs.png) | ![Print layout and animation](docs/images/print_layout.png) | -| **Draw any cross-section, sharp corners and all** | **Draw one lobe, repeat it: three bulbs become six** | -| ![The cross-section editor](docs/images/drawn_claw_shell.png) | ![Repeat around](docs/images/drawn_bulb_swirl.png) | - -## 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). +# 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. + +![Vaseinator main window](docs/images/main_window.png) + +![The ready-made vases](docs/images/gallery.png) + +| Advanced: draw how things change up the vase | Section: the printed wall | +|---|---| +| ![Advanced mode with curves](docs/images/detailed.png) | ![Section view](docs/images/section.png) | +| **Overhangs: green is easy, red needs support** | **Watch it print, in real print time** | +| ![Overhang check](docs/images/overhangs.png) | ![Print layout and animation](docs/images/print_layout.png) | +| **Draw any cross-section, sharp corners and all** | **Draw one lobe, repeat it: three bulbs become six** | +| ![The cross-section editor](docs/images/drawn_claw_shell.png) | ![Repeat around](docs/images/drawn_bulb_swirl.png) | + +## 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/tools/build_web.py b/tools/build_web.py index 9037b69..086be61 100644 --- a/tools/build_web.py +++ b/tools/build_web.py @@ -1,80 +1,80 @@ -"""Put the web version together in site/: the page, the Python it runs, and the gallery pictures. - - .venv\\Scripts\\python tools\\build_web.py then, to try it: - .venv\\Scripts\\python -m http.server 8000 -d site and open http://localhost:8000 - -The page runs the same vaseinator package as the desktop app - the parts of it that need no Qt - in the -browser with Pyodide. GitHub Pages publishes site/ (see .github/workflows/pages.yml). -""" - -import hashlib -import json -import os -import re -import shutil -import sys - -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -# the package's modules that need no Qt: the geometry, the designs, the settings, the report and the exports -MODULES = ("__init__", "choices", "expr", "geometry", "hollow", "lid", "params", "presets", "report", "sections", - "settings", "shapes", "stl", "threemf", "workspace") - - -def main(out=None): - out = out or os.path.join(ROOT, "site") - if os.path.isdir(out): - shutil.rmtree(out) - os.makedirs(os.path.join(out, "py", "vaseinator")) - os.makedirs(os.path.join(out, "gallery")) - for name in sorted(os.listdir(os.path.join(ROOT, "web"))): # the page: everything but its Python - if name.endswith((".html", ".css", ".js")): - shutil.copy2(os.path.join(ROOT, "web", name), os.path.join(out, name)) - shutil.copy2(os.path.join(ROOT, "assets", "icon.png"), os.path.join(out, "icon.png")) - files = ["bridge.py"] - shutil.copy2(os.path.join(ROOT, "web", "bridge.py"), os.path.join(out, "py", "bridge.py")) - for m in MODULES: - src = os.path.join(ROOT, "vaseinator", m + ".py") - with open(src, encoding="utf-8") as fh: - text = fh.read() - if "PySide6" in text.split("def ", 1)[0]: # imported at the top: it would not load - sys.exit(f"vaseinator/{m}.py needs Qt, so it can't run in the browser") - shutil.copy2(src, os.path.join(out, "py", "vaseinator", m + ".py")) - files.append(f"vaseinator/{m}.py") - with open(os.path.join(out, "py", "files.json"), "w", encoding="utf-8") as fh: - json.dump(files, fh) - pictures = os.path.join(ROOT, "assets", "gallery") - for name in sorted(os.listdir(pictures)): - if name.endswith(".png"): - shutil.copy2(os.path.join(pictures, name), os.path.join(out, "gallery", name)) - open(os.path.join(out, ".nojekyll"), "w").close() # serve every file as it is - stamp(out) - print("built", out) - - -def stamp(out): - """Give the page's own files a version in their links, from what they hold, so that after an update a - browser never mixes files it kept from before with new ones.""" - digest = hashlib.sha1() - for folder, _, names in sorted(os.walk(out)): - for name in sorted(names): - if name.endswith((".html", ".css", ".js", ".py", ".json")): - with open(os.path.join(folder, name), "rb") as fh: - digest.update(fh.read()) - v = digest.hexdigest()[:10] - for name in os.listdir(out): - if not name.endswith((".html", ".js")): - continue - path = os.path.join(out, name) - with open(path, encoding="utf-8") as fh: - text = fh.read() - def versioned(m): - ours = os.path.exists(os.path.join(out, f"{m[2]}.{m[3]}")) - return f"{m[1]}{m[2]}.{m[3]}?v={v}{m[4]}" if ours else m[0] - - text = re.sub(r"""(["'](?:\./)?)(\w+)\.(js|css)(["'])""", versioned, text) - with open(path, "w", encoding="utf-8") as fh: - fh.write(text) - - -if __name__ == "__main__": - main(sys.argv[1] if len(sys.argv) > 1 else None) +"""Put the web version together in site/: the page, the Python it runs, and the gallery pictures. + + .venv\\Scripts\\python tools\\build_web.py then, to try it: + .venv\\Scripts\\python -m http.server 8000 -d site and open http://localhost:8000 + +The page runs the same vaseinator package as the desktop app - the parts of it that need no Qt - in the +browser with Pyodide. GitHub Pages publishes site/ (see .github/workflows/pages.yml). +""" + +import hashlib +import json +import os +import re +import shutil +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +# the package's modules that need no Qt: the geometry, the designs, the settings, the report and the exports +MODULES = ("__init__", "choices", "expr", "geometry", "hollow", "lid", "params", "presets", "report", "sections", + "settings", "shapes", "stl", "threemf", "workspace") + + +def main(out=None): + out = out or os.path.join(ROOT, "site") + if os.path.isdir(out): + shutil.rmtree(out) + os.makedirs(os.path.join(out, "py", "vaseinator")) + os.makedirs(os.path.join(out, "gallery")) + for name in sorted(os.listdir(os.path.join(ROOT, "web"))): # the page: everything but its Python + if name.endswith((".html", ".css", ".js")): + shutil.copy2(os.path.join(ROOT, "web", name), os.path.join(out, name)) + shutil.copy2(os.path.join(ROOT, "assets", "icon.png"), os.path.join(out, "icon.png")) + files = ["bridge.py"] + shutil.copy2(os.path.join(ROOT, "web", "bridge.py"), os.path.join(out, "py", "bridge.py")) + for m in MODULES: + src = os.path.join(ROOT, "vaseinator", m + ".py") + with open(src, encoding="utf-8") as fh: + text = fh.read() + if "PySide6" in text.split("def ", 1)[0]: # imported at the top: it would not load + sys.exit(f"vaseinator/{m}.py needs Qt, so it can't run in the browser") + shutil.copy2(src, os.path.join(out, "py", "vaseinator", m + ".py")) + files.append(f"vaseinator/{m}.py") + with open(os.path.join(out, "py", "files.json"), "w", encoding="utf-8") as fh: + json.dump(files, fh) + pictures = os.path.join(ROOT, "assets", "gallery") + for name in sorted(os.listdir(pictures)): + if name.endswith(".png"): + shutil.copy2(os.path.join(pictures, name), os.path.join(out, "gallery", name)) + open(os.path.join(out, ".nojekyll"), "w").close() # serve every file as it is + stamp(out) + print("built", out) + + +def stamp(out): + """Give the page's own files a version in their links, from what they hold, so that after an update a + browser never mixes files it kept from before with new ones.""" + digest = hashlib.sha1() + for folder, _, names in sorted(os.walk(out)): + for name in sorted(names): + if name.endswith((".html", ".css", ".js", ".py", ".json")): + with open(os.path.join(folder, name), "rb") as fh: + digest.update(fh.read()) + v = digest.hexdigest()[:10] + for name in os.listdir(out): + if not name.endswith((".html", ".js")): + continue + path = os.path.join(out, name) + with open(path, encoding="utf-8") as fh: + text = fh.read() + def versioned(m): + ours = os.path.exists(os.path.join(out, f"{m[2]}.{m[3]}")) + return f"{m[1]}{m[2]}.{m[3]}?v={v}{m[4]}" if ours else m[0] + + text = re.sub(r"""(["'](?:\./)?)(\w+)\.(js|css)(["'])""", versioned, text) + with open(path, "w", encoding="utf-8") as fh: + fh.write(text) + + +if __name__ == "__main__": + main(sys.argv[1] if len(sys.argv) > 1 else None) diff --git a/vaseinator/app.py b/vaseinator/app.py index 87c16a0..66ac782 100644 --- a/vaseinator/app.py +++ b/vaseinator/app.py @@ -1,1875 +1,1875 @@ -"""Vaseinator - main window.""" - -from __future__ import annotations - -import math -import json -import os -import random -import sys -import time - -import numpy as np -from PySide6.QtCore import QSettings, QSize, Qt, QTimer, QUrl, Signal -from PySide6.QtGui import (QAction, QColor, QDesktopServices, QFontDatabase, QIcon, QKeySequence, QPainter, - QPalette, QPen, QPixmap, QSurfaceFormat) -from PySide6.QtWidgets import (QApplication, QCheckBox, QColorDialog, QComboBox, QDialog, QDialogButtonBox, - QDoubleSpinBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel, - QLineEdit, QMainWindow, QMenu, QMessageBox, QPlainTextEdit, QPushButton, - QScrollArea, QSlider, QSpinBox, QSplitter, QStackedWidget, QStyle, - QTextBrowser, QToolButton, QVBoxLayout, QWidget) - -from . import sections, slicers -from .workspace import Workspace, locked_surprise -from .design_tools import DesignTools, show_comparison, save_calibration -from .curves import CurveEditor -from .expr import EXAMPLES -from .geometry import HEAD_SCALED, VaseDesign, duration, fit_head, require_exportable -from .outline_editor import CLOSE_UP_FROM, OutlineEditor, OutlineWindow -from .params import (DRAWN, FILAMENTS, ONE_COLOUR, OWN_PROFILE, POLYGON, REAL_VASE, VaseParams, colour_style_of, - load_design, save_design) -from .plots import ShapePlots -from .lid import Lid -from .report import ABOUT, APP_NAME, DRAWING_HELP, PRINTING_HELP, function_help, info_html, lid_steps, print_steps -from .settings import (COLOUR_STYLES, REAL_COLOUR_STYLES, REMEMBERED, SPEC, TEXT_KEYS, change as apply_change, - outline_from_shape, wanted) -from .presets import GALLERY, gallery_design, outline, palette, plain_design, slug, surprise -from .simple_panel import GalleryDialog, SimplePanel, asset, profile_icon -from .spinbox import ComboBox, DoubleSpinBox, Slider, SpinBox -from .shapes import PALETTES, SHAPE_LABELS -from .stl import write_stl -from .threemf import write_3mf -from .viewer import Viewer3D, spin_transform - -FILE_EXT = ".vase" -KOFI_URL = "https://ko-fi.com/rickapps" -VASE, SECTION, OVERHANGS, PRINT_LAYOUT = 0, 1, 2, 3 -VIEW_NAMES = ("Vase", "Section", "Overhangs", "Print layout") -SPIN_SPEED = 30.0 # degrees a second, on the turntable - - - -def swatch(color: str, size: int = 14) -> QIcon: - pm = QPixmap(size, size) - pm.fill(Qt.transparent) - p = QPainter(pm) - p.setPen(QPen(QColor(20, 22, 26), 1)) - p.setBrush(QColor(color)) - p.drawRect(0, 0, size - 1, size - 1) - p.end() - return QIcon(pm) - - -def palette_icon(colours) -> QIcon: - pm = QPixmap(56, 14) - pm.fill(Qt.transparent) - p = QPainter(pm) - for k, c in enumerate(colours): - p.setPen(QPen(QColor(20, 22, 26), 1)) - p.setBrush(QColor(c)) - p.drawRect(k * 14, 0, 13, 13) - p.end() - return QIcon(pm) - - -def glyph_icon(kind: str) -> QIcon: - """Small light line icons that read well on the dark theme.""" - from PySide6.QtCore import QPointF, QRectF - - pm = QPixmap(64, 64) - pm.fill(Qt.transparent) - p = QPainter(pm) - p.setRenderHint(QPainter.Antialiasing) - app = QApplication.instance() - ink = app.palette().color(QPalette.WindowText) if app is not None else QColor(225, 230, 236) - pen = QPen(ink, 5, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin) - p.setPen(pen) - if kind == "export": - p.drawLine(QPointF(32, 8), QPointF(32, 40)) - p.drawPolyline([QPointF(19, 28), QPointF(32, 41), QPointF(45, 28)]) - p.drawPolyline([QPointF(10, 40), QPointF(10, 54), QPointF(54, 54), QPointF(54, 40)]) - elif kind == "picture": - p.drawRoundedRect(QRectF(8, 14, 48, 38), 4, 4) - pen.setColor(QColor(240, 163, 86)) - p.setPen(pen) - p.drawPolyline([QPointF(14, 44), QPointF(26, 30), QPointF(34, 38), QPointF(42, 26), QPointF(50, 38)]) - elif kind == "dice": - p.drawRoundedRect(QRectF(10, 10, 44, 44), 9, 9) - p.setBrush(QColor(240, 163, 86)) - p.setPen(Qt.NoPen) - for x, y in ((22, 22), (42, 22), (32, 32), (22, 42), (42, 42)): - p.drawEllipse(QPointF(x, y), 4.5, 4.5) - elif kind == "gallery": - pen.setColor(QColor(240, 163, 86)) - p.setPen(pen) - p.drawPolyline([QPointF(20, 8), QPointF(20, 16), QPointF(12, 34), QPointF(16, 56), QPointF(48, 56), - QPointF(52, 34), QPointF(44, 16), QPointF(44, 8)]) - p.drawLine(QPointF(16, 8), QPointF(48, 8)) - elif kind in ("undo", "redo"): - sx = 1 if kind == "undo" else -1 - cx = 32 - p.drawArc(QRectF(14, 18, 36, 32), (0 if sx > 0 else 180) * 16, (180 if sx > 0 else -180) * 16) - tip = QPointF(cx - sx * 18, 34) - p.drawPolyline([QPointF(tip.x() - sx * 7, 25), tip, QPointF(tip.x() + sx * 9, 28)]) - elif kind == "coffee": - p.drawRoundedRect(QRectF(14, 26, 30, 26), 5, 5) # cup - p.drawArc(QRectF(36, 30, 16, 16), -90 * 16, 180 * 16) # handle, ends on the cup - pen.setColor(QColor(240, 163, 86)) - p.setPen(pen) - for x in (21, 29, 37): # steam - p.drawLine(QPointF(x, 10), QPointF(x, 20)) - p.end() - return QIcon(pm) - - -def make_app_icon() -> QIcon: - here = os.path.dirname(os.path.abspath(__file__)) - for cand in (os.path.join(here, "..", "assets", "icon.png"), - os.path.join(getattr(sys, "_MEIPASS", here), "assets", "icon.png")): - if os.path.exists(cand): - return QIcon(cand) - return QIcon() - - -class FunctionHelp(QDialog): - """What a formula can use, with examples to try.""" - - def __init__(self, parent, insert): - super().__init__(parent) - self.setWindowTitle("Your own function") - self.resize(640, 560) - text = QTextBrowser() - text.setHtml(function_help()) - box = QVBoxLayout(self) - box.addWidget(text) - row = QHBoxLayout() - row.addWidget(QLabel("Try an example:")) - pick = ComboBox() - for name, formula in EXAMPLES: - pick.addItem(name, formula) - row.addWidget(pick, 1) - use = QPushButton("Use it") - use.clicked.connect(lambda: (insert(pick.currentData()), self.accept())) - row.addWidget(use) - box.addLayout(row) - buttons = QDialogButtonBox(QDialogButtonBox.Close) - buttons.rejected.connect(self.reject) - box.addWidget(buttons) - - -class PrintSteps(QDialog): - """After Save for printing: where the file is, and plain steps to print it - the vase's, or the steps - given (a lid's, say, as steps(where)).""" - open_slicer = Signal() - - def __init__(self, parent, d, path, parts: int, slicer, steps=None): - super().__init__(parent) - self.setWindowTitle("How to print it") - where = slicers.slicer_name(slicer) if slicer else "your slicer" - text = QLabel(steps(where) if steps else print_steps(d, path, parts, where)) - text.setWordWrap(True) - text.setTextInteractionFlags(Qt.TextSelectableByMouse) - box = QVBoxLayout(self) - box.addWidget(text) - row = QHBoxLayout() - if slicer: - go = QPushButton(f"Open in {where}") - go.setObjectName("saveButton") - go.setMinimumHeight(36) - go.clicked.connect(lambda: (self.open_slicer.emit(), self.accept())) - row.addWidget(go) - show = QPushButton("Show the file") - show.clicked.connect(lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(os.path.dirname(path)))) - row.addWidget(show) - done = QPushButton("Close") - done.clicked.connect(self.accept) - row.addWidget(done) - box.addLayout(row) - self.resize(560, 320) - - -class MainWindow(QMainWindow): - def __init__(self, settings: QSettings | None = None): - super().__init__() - self.settings = settings or QSettings("Vaseinator", "Vaseinator") - if self.settings.value("big_text", False, type=bool) or self.settings.value("light_theme", False, type=bool): - apply_theme(QApplication.instance(), light=self.settings.value("light_theme", False, type=bool), - scale=1.3 if self.settings.value("big_text", False, type=bool) else 1.0) - self.simple_mode = self.settings.value("simple_mode", True, type=bool) - self.params = self._fresh_params() - try: - saved = self.settings.value("workspace", "") - self.workspace = Workspace(json.loads(saved) if saved else None) - except (ValueError, TypeError): - self.settings.setValue("workspace_unreadable_backup", saved) - self.workspace = Workspace() - self.design: VaseDesign | None = None - self.current_file = None - self.view = VASE - self._free_camera = False - self._fitted = None - self._dirty = False - self.widgets = {} - self.labels = {} - self.outline_menus = {} # Start from, for each drawn cross-section - self.close_up_buttons = {} - self.outline_windows = {} # the cross-section editors opened full window - self.slider_notes = {} - self.history: list[dict] = [] - self.future: list[dict] = [] - self.spin_angle = 0.0 - self.spinning = self.settings.value("spin", True, type=bool) - self.printing = None # the print animation: its start time - self._last_full_ms = 0.0 - self._note = "" # said with the next "Built in ..." message - self._last_tick = time.perf_counter() - - self.regen_timer = QTimer(self) - self.regen_timer.setSingleShot(True) - self.regen_timer.timeout.connect(self.regenerate) - self.draft_timer = QTimer(self) - self.draft_timer.setSingleShot(True) - self.draft_timer.timeout.connect(lambda: self.regenerate(draft=True)) - self.anim_timer = QTimer(self) - self.anim_timer.setInterval(16) - self.anim_timer.timeout.connect(self._anim_tick) - - self.setWindowIcon(make_app_icon()) - self._build_ui() - self._build_actions() - self.resize(1480, 940) - geo = self.settings.value("geometry") - if geo is not None: - self.restoreGeometry(geo) - self.regenerate() - self._update_anim() - - # ================================================================== UI - def _build_ui(self): - panel = QWidget() - pl = QVBoxLayout(panel) - pl.setContentsMargins(8, 8, 8, 8) - pl.setSpacing(8) - - mode_row = QHBoxLayout() - mode_row.setSpacing(0) - self.simple_btn = QPushButton("Simple") - self.detail_btn = QPushButton("Advanced") - self.simple_btn.setToolTip("A few choices made from pictures: size, shape, twist, pattern and colour.") - self.detail_btn.setToolTip("Every setting, with curves to draw how things change up the vase.") - for b in (self.simple_btn, self.detail_btn): - b.setCheckable(True) - b.setAutoExclusive(True) - b.setMinimumHeight(28) - b.setObjectName("modeButton") - mode_row.addWidget(b) - (self.simple_btn if self.simple_mode else self.detail_btn).setChecked(True) - self.simple_btn.clicked.connect(lambda: self.set_simple_mode(True)) - self.detail_btn.clicked.connect(lambda: self.set_simple_mode(False)) - - name_row = QHBoxLayout() - name_row.addWidget(QLabel("Name")) - self.name_edit = QLineEdit(self.params.name) - self.name_edit.setToolTip("Design name - used for exported file names.") - self.name_edit.textChanged.connect(self._on_name) - name_row.addWidget(self.name_edit) - dice = self.dice_btn = QToolButton() - dice.setIcon(glyph_icon("dice")) - dice.setToolTip("Surprise me: a new random vase that still prints (Ctrl+R)") - dice.clicked.connect(self.surprise_me) - name_row.addWidget(dice) - pl.addLayout(name_row) - - self._rows = {} - self._groups = [] - for title, items in SPEC: - box = QGroupBox(title) - form = QFormLayout(box) - form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) - form.setContentsMargins(8, 12, 8, 8) - form.setHorizontalSpacing(10) - form.setVerticalSpacing(5) - for key, label, kind, lo, hi, step, extra, tip in items: - w = self._make_widget(key, kind, lo, hi, step, extra) - if tip: - w.setToolTip(tip) - if label: - lab = QLabel(label) - if tip: - lab.setToolTip(tip) - self.labels[key] = lab - form.addRow(lab, w) - else: - form.addRow(w) - self._rows[key] = (form, w) - self._groups.append((box, [it[0] for it in items])) - pl.addWidget(box) - pl.addStretch(1) - - scroll = QScrollArea() - scroll.setWidget(panel) - scroll.setWidgetResizable(True) - scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - # Simple: a few choices made from pictures; Advanced: every setting - self.simple = SimplePanel() - self.simple.param.connect(lambda k, v: self._on_param(k, v)) - self.simple.live.connect(lambda k, v: self._on_param(k, v, live=True)) - self.simple.changes.connect(self.apply_changes) - self.simple.choose_design.connect(self.choose_design) - self.simple.surprise.connect(lambda: self.surprise_me()) - self.simple.draw_own.connect(self.draw_own_shape) - self.simple.save.connect(self.save_for_printing) - self.simple.save_lid.connect(self.save_lid) - self.simple.advanced.connect(lambda: self.set_simple_mode(False)) - simple_scroll = QScrollArea() - simple_scroll.setWidget(self.simple) - simple_scroll.setWidgetResizable(True) - simple_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.panels = QStackedWidget() - self.panels.addWidget(simple_scroll) - self.panels.addWidget(scroll) - self.panels.setCurrentIndex(0 if self.simple_mode else 1) - left = QWidget() - lv = QVBoxLayout(left) - lv.setContentsMargins(8, 8, 0, 0) - lv.setSpacing(6) - lv.addLayout(mode_row) - lv.addWidget(self.panels, 1) - left.setMinimumWidth(370) - left.setMaximumWidth(480) - - self.viewer = Viewer3D() - self.viewer.dragged.connect(self._on_dragged) - self.viewer.reset_requested.connect(lambda: self.show_view(self.view)) - self.viewer.resized.connect(self._on_resized) - self.plots = ShapePlots() - self.info = QTextBrowser() - self.info.setOpenExternalLinks(False) - self.info.setMinimumWidth(280) - - bottom = QSplitter(Qt.Horizontal) - bottom.addWidget(self.plots) - bottom.addWidget(self.info) - bottom.setStretchFactor(0, 3) - bottom.setStretchFactor(1, 2) - bottom.setSizes([560, 500]) - - right = QSplitter(Qt.Vertical) - right.addWidget(self._make_view_bar()) - right.addWidget(self.viewer) - right.addWidget(bottom) - right.setStretchFactor(1, 3) - right.setStretchFactor(2, 2) - right.setSizes([36, 560, 300]) - right.setCollapsible(0, False) - right.handle(1).setEnabled(False) - - main = QSplitter(Qt.Horizontal) - main.addWidget(left) - main.addWidget(right) - main.setStretchFactor(1, 1) - main.setSizes([380, 1100]) - self.setCentralWidget(main) - self.statusBar().showMessage("Ready") - self._refresh_widgets() - - def _make_view_bar(self): - bar = QWidget() - bar.setMaximumHeight(40) - h = QHBoxLayout(bar) - h.setContentsMargins(6, 4, 6, 4) - h.addWidget(QLabel("Show")) - row = QHBoxLayout() - row.setSpacing(0) - self.view_btns = [] - tips = ("The vase as it will look (Ctrl+1)", - "Cut in half through the middle: the single printed wall and the solid bottom (Ctrl+2)", - "How far each part of the wall leans: green is easy, orange near the limit, red needs " - "support (Ctrl+3)", - "On the printer bed, inside the space the printer can reach (Ctrl+4)") - for idx, text in enumerate(VIEW_NAMES): - b = QPushButton(text) - b.setCheckable(True) - b.setAutoExclusive(True) - b.setMinimumHeight(28) - b.setObjectName("modeButton") - b.setToolTip(tips[idx]) - b.clicked.connect(lambda _=False, i=idx: self.show_view(i)) - row.addWidget(b) - self.view_btns.append(b) - self.view_btns[self.view].setChecked(True) - h.addLayout(row) - h.addSpacing(10) - self.spin_btn = QPushButton("⟳ Spin") - self.spin_btn.setCheckable(True) - self.spin_btn.setChecked(self.spinning) - self.spin_btn.setObjectName("modeButton") - self.spin_btn.setMinimumHeight(28) - self.spin_btn.setToolTip("Turn the vase slowly on a turntable (S)") - self.spin_btn.toggled.connect(self.set_spin) - h.addWidget(self.spin_btn) - self.print_btn = QPushButton("▶ Watch it print") - self.print_btn.setCheckable(True) - self.print_btn.setObjectName("modeButton") - self.print_btn.setMinimumHeight(28) - self.print_btn.setToolTip("Build the vase up as the printer would, keeping to the real print time " - "(wide layers take longer) (F5)") - self.print_btn.toggled.connect(self.set_printing) - h.addWidget(self.print_btn) - h.addSpacing(10) - self.hint = QLabel("Drag to turn · wheel to zoom") - self.hint.setStyleSheet("color: #9aa3ad;") - h.addWidget(self.hint) - h.addStretch(1) - fit = QToolButton() - fit.setText("Reset view") - fit.setToolTip("Back to the chosen view (double-click the 3D view, or press F)") - fit.clicked.connect(lambda: self.show_view(self.view)) - h.addWidget(fit) - return bar - - def _make_widget(self, key, kind, lo, hi, step, extra): - def row(*parts): - box = QWidget() - hl = QHBoxLayout(box) - hl.setContentsMargins(0, 0, 0, 0) - hl.setSpacing(6) - for part in parts: - hl.addWidget(part, 0 if isinstance(part, QToolButton) else 1) - return box - - if kind == "widest": - s = DoubleSpinBox() - s.setDecimals(1) - s.setRange(lo, hi) - s.setSingleStep(step) - s.setSuffix(extra) - s.setKeyboardTracking(False) - s.setValue(self._widest()) - s.valueChanged.connect(lambda v: self._on_param("widest", float(v))) - self.widgets[key] = s - return s - if kind == "head": # not a setting of its own: it scales the design to fit - s = DoubleSpinBox() - s.setDecimals(1) - s.setRange(lo, hi) - s.setSingleStep(step) - s.setSuffix(extra) - s.setKeyboardTracking(False) - s.setValue(57.0) - s.valueChanged.connect(lambda v: self._on_param("head", float(v))) - self.widgets[key] = s - return s - if kind == "palette": - b = QToolButton() - b.setText("Choose a palette") - b.setPopupMode(QToolButton.InstantPopup) - b.setToolButtonStyle(Qt.ToolButtonTextOnly) - m = QMenu(b) - for name, cols in PALETTES: - a = m.addAction(palette_icon(cols), name) - a.triggered.connect(lambda _=False, n=name: self._apply_palette(n)) - b.setMenu(m) - b.setMinimumHeight(24) - self.widgets[key] = b - return b - if kind == "outline": # a drawn cross-section, and where to start one from - k = key[-1] - box = QWidget() - vl = QVBoxLayout(box) - vl.setContentsMargins(0, 0, 0, 0) - vl.setSpacing(4) - ed = OutlineEditor() - ed.set_outline(getattr(self.params, key), getattr(self.params, f"repeat{k}")) - ed.changed.connect(lambda pts, kk=int(k), e=ed: self._outline_edited(kk, pts, e, live=True)) - ed.finished.connect(lambda pts, kk=int(k), e=ed: self._outline_edited(kk, pts, e)) - start = QToolButton() - start.setText("Start from") - start.setPopupMode(QToolButton.InstantPopup) - m = QMenu(start) - for name, pts, repeat in sections.OUTLINES: - m.addAction(name).triggered.connect( - lambda _=False, kk=k, pp=pts, rr=repeat: self._use_outline(kk, pp, rr)) - m.addSeparator() - m.addAction("Trace a picture…").triggered.connect(lambda _=False, kk=k: self._trace_outline(kk)) - start.setMenu(m) - start.setToolTip("Start again from a ready-made outline, or trace one from a picture: dark lines " - "drawn round a shape on a light or see-through ground.") - close = QToolButton() - close.setText("Close up") - close.setCheckable(True) - close.setToolTip("Show one repeat close up, turned to point upwards - or the whole outline.\n" - f"Above {CLOSE_UP_FROM} repeats it starts close up.") - close.toggled.connect(ed.set_close_up) - ed.view_changed.connect(lambda kk=int(k): self._show_close_up(kk)) - big = QToolButton() - big.setText("Full window…") - big.setToolTip("Draw it in a window of its own, as big as the screen: the wheel zooms, a right-drag " - "moves it.") - big.clicked.connect(lambda _=False, kk=int(k): self._open_outline_window(kk)) - self.outline_menus[int(k)] = m - self.close_up_buttons[int(k)] = close - buttons = QHBoxLayout() - buttons.setContentsMargins(0, 0, 0, 0) - buttons.addWidget(start) - buttons.addWidget(close) - buttons.addWidget(big) - buttons.addStretch(1) - vl.addWidget(ed) - vl.addLayout(buttons) - self.widgets[key] = ed - return box - val = getattr(self.params, key) - if kind == "float": - suffix, _, special = extra.partition("|") - s = DoubleSpinBox() - s.setDecimals(2) - s.setRange(lo, hi) - s.setSingleStep(step) - s.setSuffix(suffix) - if special: - s.setSpecialValueText(special) - s.setValue(val) - s.setKeyboardTracking(False) - s.valueChanged.connect(lambda v, k=key: self._on_param(k, float(v))) - s.setMinimumWidth(110) - w = s - elif kind == "int" or kind == "seed": - suffix, _, special = extra.partition("|") - s = SpinBox() - s.setRange(int(lo), int(hi)) - s.setSingleStep(int(step)) - s.setSuffix(suffix) - if special: - s.setSpecialValueText(special) - s.setValue(int(val)) - s.setKeyboardTracking(False) - s.valueChanged.connect(lambda v, k=key: self._on_param(k, int(v))) - self.widgets[key] = s - if kind == "seed": - roll = QToolButton() - roll.setIcon(glyph_icon("dice")) - roll.setToolTip("Another one") - roll.clicked.connect(lambda _=False, ss=s: ss.setValue(random.randint(0, 99999))) - return row(s, roll) - return s - elif kind == "pct": - s = DoubleSpinBox() - s.setDecimals(0) - s.setRange(lo, hi) - s.setSingleStep(step) - s.setSuffix(extra) - s.setValue(100.0 * val) - s.setKeyboardTracking(False) - s.valueChanged.connect(lambda v, k=key: self._on_param(k, float(v) / 100.0)) - w = s - elif kind == "bool": - w = QCheckBox() - w.setChecked(val) - w.toggled.connect(lambda v, k=key: self._on_param(k, bool(v))) - elif kind == "choice": - w = ComboBox() - for value, label in lo: - w.addItem(label, value) - w.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon) - w.setMinimumContentsLength(12) - w.setCurrentIndex(max(0, w.findData(val))) - w.currentIndexChanged.connect(lambda i, k=key, ww=w: self._on_param(k, ww.itemData(i))) - elif kind == "color": - w = QPushButton() - w.setMinimumHeight(24) - w.clicked.connect(lambda _=False, k=key: self._pick_color(k)) - elif kind == "slider": - box = QWidget() - vl = QVBoxLayout(box) - vl.setContentsMargins(0, 0, 0, 0) - vl.setSpacing(1) - s = Slider(Qt.Horizontal) - s.setRange(int(round(lo * 100)), int(round(hi * 100))) - s.setPageStep(10) - s.setTickInterval(100) - s.setTickPosition(QSlider.TicksBelow) - s.setValue(int(round(val * 100))) - s.valueChanged.connect(lambda v, k=key: self._on_param(k, v / 100.0, live=True)) - s.sliderReleased.connect(lambda: self._on_param(key, s.value() / 100.0)) - self.slider_notes[key] = QLabel() - self.slider_notes[key].setStyleSheet("color: #9aa3ad;") - vl.addWidget(s) - vl.addWidget(self.slider_notes[key]) - self.widgets[key] = s - return box - elif kind == "curve": - w = CurveEditor(**lo) - w.set_points(val) - w.changed.connect(lambda pts, k=key: self._on_param(k, pts, live=True)) - w.finished.connect(lambda pts, k=key: self._on_param(k, pts)) - elif kind == "expr": - box = QWidget() - vl = QVBoxLayout(box) - vl.setContentsMargins(0, 0, 0, 0) - vl.setSpacing(4) - t = QPlainTextEdit(val) - mono = QFontDatabase.systemFont(QFontDatabase.FixedFont) - t.setFont(mono) - t.setPlaceholderText("e.g. 1.5 * sin(6*theta + 4*pi*h)") - t.setTabChangesFocus(True) - t.setFixedHeight(54) - t.textChanged.connect(lambda k=key, tt=t: self._on_param(k, tt.toPlainText())) - self.widgets[key] = t - ex = QToolButton() - ex.setText("Examples") - ex.setPopupMode(QToolButton.InstantPopup) - m = QMenu(ex) - for name, formula in EXAMPLES: - a = m.addAction(f"{name}: {formula}") - a.triggered.connect(lambda _=False, f=formula: self._set_expression(f)) - m.addSeparator() - m.addAction("Clear").triggered.connect(lambda: self._set_expression("")) - ex.setMenu(m) - helpb = QToolButton() - helpb.setText("Help…") - helpb.clicked.connect(self.function_help) - buttons = QHBoxLayout() - buttons.setContentsMargins(0, 0, 0, 0) - buttons.addWidget(ex) - buttons.addWidget(helpb) - buttons.addStretch(1) - vl.addWidget(t) - vl.addLayout(buttons) - return box - else: - raise ValueError(kind) - self.widgets[key] = w - return w - - def _widest(self) -> float: - return max(d for _, d in self.params.silhouette) - - def _refresh_widgets(self): - """Push self.params into the widgets without triggering updates.""" - for key, w in self.widgets.items(): - w.blockSignals(True) - if key == "widest": - w.setValue(self._widest()) - elif key in ("palette", "head"): # the head size follows the design once it is built - pass - elif isinstance(w, QComboBox): - w.setCurrentIndex(max(0, w.findData(getattr(self.params, key)))) - elif isinstance(w, QCheckBox): - w.setChecked(bool(getattr(self.params, key))) - elif isinstance(w, QPlainTextEdit): - if w.toPlainText() != getattr(self.params, key): - w.setPlainText(getattr(self.params, key)) - elif isinstance(w, QSpinBox): - w.setValue(int(getattr(self.params, key))) - elif isinstance(w, QDoubleSpinBox): - v = getattr(self.params, key) - w.setValue(100.0 * v if w.suffix() == " %" else v) - elif isinstance(w, QSlider): - w.setValue(int(round(getattr(self.params, key) * 100))) - elif isinstance(w, CurveEditor): - w.set_points(getattr(self.params, key)) - elif isinstance(w, OutlineEditor): - w.set_outline(getattr(self.params, key), getattr(self.params, f"repeat{key[-1]}")) - w.blockSignals(False) - for k in (1, 2): - self._push_outline(k) - self.name_edit.blockSignals(True) - self.name_edit.setText(self.params.name) - self.name_edit.blockSignals(False) - self._update_title() - self._sync_widgets() - - def _fresh_params(self) -> VaseParams: - """Default design, using the printer settings remembered from last time.""" - p = VaseParams() - # a first run borrows the printer from Maze Tube Designer or Lenticular Sign Maker - others = (QSettings("MazeTube", "MazeTubeDesigner"), QSettings("LenticularSign", "LenticularSignMaker")) - for key in REMEMBERED: - for v in (self.settings.value(f"default_{key}"),) + tuple(o.value(f"default_{key}") for o in others): - try: - if v is not None: - setattr(p, key, float(v)) - break - except (TypeError, ValueError): - pass - return p - - def _sync_widgets(self): - """Colour swatches, labels and greyed-out settings that follow other settings.""" - p = self.params - for k in FILAMENTS: - color = getattr(p, f"color{k}") - btn = self.widgets[f"color{k}"] - dark = QColor(color).lightnessF() < 0.55 - btn.setText(color.upper()) - btn.setStyleSheet(f"background: {color}; color: {'white' if dark else 'black'}; " - f"border: 1px solid #3b4049; border-radius: 3px;") - for k in (1, 2): - kind = getattr(p, f"shape{k}") - names = SHAPE_LABELS.get(kind, (None, None, None)) - for key, name in zip((f"count{k}", f"depth{k}", f"sharp{k}"), names): - if name: - self.labels[key].setText(name) - self.widgets[f"count{k}"].setMinimum(3 if kind == POLYGON else 1) - # a real spiral vase prints one filament: only the colour styles that need no change of filament - real = p.vase_mode == REAL_VASE - styles = REAL_COLOUR_STYLES if real else COLOUR_STYLES - combo = self.widgets["colour_style"] - combo.blockSignals(True) - if [(combo.itemData(i), combo.itemText(i)) for i in range(combo.count())] != styles: - combo.clear() - for value, text in styles: - combo.addItem(text, value) - combo.setCurrentIndex(max(0, combo.findData(colour_style_of(p)))) - combo.blockSignals(False) - self.labels["color1"].setText("Colour" if colour_style_of(p) == ONE_COLOUR else "Filament 1") - s = p.band_spacing - self.slider_notes["band_spacing"].setText( - "Even" if abs(s) < 0.05 else ("Thicker towards the top" if s > 0 else "Thicker towards the bottom")) - for k, win in self.outline_windows.items(): # a shape no longer drawn has nothing to draw - if win.isVisible() and getattr(p, f"shape{k}") != DRAWN: - win.close() - if hasattr(self, "simple"): - titles = {slug(t): t for t, _, _ in GALLERY} - titles["plain_cylinder"] = "Plain cylinder" - self.simple.refresh(p, self._widest(), titles.get(p.name, "")) - if hasattr(self, "a_lid"): - self.a_lid.setEnabled(p.box) - self._apply_mode() - - def _wanted(self, key) -> bool: - return wanted(self.params, key) - - def _apply_mode(self): - for key, (form, field) in self._rows.items(): - form.setRowVisible(field, self._wanted(key)) - for box, keys in self._groups: - box.setVisible(any(self._wanted(k) for k in keys)) - - def set_simple_mode(self, simple: bool, remember: bool = True): - self.simple_mode = simple - (self.simple_btn if simple else self.detail_btn).setChecked(True) - self.panels.setCurrentIndex(0 if simple else 1) - if remember: - self.settings.setValue("simple_mode", simple) - self._apply_mode() - - def _pick_color(self, key): - c = QColorDialog.getColor(QColor(getattr(self.params, key)), self, "Filament colour") - if c.isValid(): - self._on_param(key, c.name().upper()) - - def _apply_palette(self, name): - for k, v in palette(name).items(): - setattr(self.params, k, v) - self._on_param("color1", self.params.color1) - - def _set_expression(self, formula): - self.widgets["expression"].setPlainText(formula) # its textChanged updates the design - - def function_help(self): - FunctionHelp(self, self._set_expression).exec() - - def _build_actions(self): - st = self.style() - tb = self.addToolBar("Main") - tb.setMovable(False) - tb.setIconSize(QSize(20, 20)) - tb.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - - def act(text, slot, icon=None, shortcut=None, tip=None): - a = QAction(text, self) - if isinstance(icon, QIcon): - a.setIcon(icon) - elif icon is not None: - a.setIcon(st.standardIcon(icon)) - if shortcut: - a.setShortcut(QKeySequence(shortcut)) - if tip: - a.setToolTip(tip) - a.setStatusTip(tip) - a.triggered.connect(slot) - return a - - self.a_new = act("New", self.new_design, QStyle.SP_FileIcon, "Ctrl+N", "Start again from the default vase") - self.a_plain = act("New plain cylinder", self.use_plain, None, "Ctrl+Shift+N", - "Start from a plain cylinder, 200 mm across and 200 mm tall, with everything else off - " - "ready for your own function or a drawn cross-section") - self.a_open = act("Open…", self.open_design, QStyle.SP_DialogOpenButton, "Ctrl+O", "Open a saved vase design") - self.a_save = act("Save", self.save_design, QStyle.SP_DialogSaveButton, "Ctrl+S", "Save the design") - self.a_save_as = act("Save As…", lambda: self.save_design(as_new=True), None, "Ctrl+Shift+S") - self.a_exp_stl = act("Export STL…", self.export_stl, glyph_icon("export"), "Ctrl+E", - "The vase as one solid, watertight STL, for spiral vase mode") - self.a_exp_3mf = act("Export 3MF…", self.export_3mf, None, "Ctrl+Shift+E", - "The vase as a 3MF, in the middle of the bed, set to its first filament") - self.a_snap = act("Save picture…", self.save_snapshot, glyph_icon("picture"), "Ctrl+P", - "Save the 3D view as a PNG image") - self.a_print = act("Save for printing…", self.save_for_printing, glyph_icon("export"), "Ctrl+Shift+P", - "Save the vase for your printer's slicer, with how to print it") - self.a_lid = act("Save the lid for printing…", self.save_lid, None, None, - "A tissue box cover's lid for its open end, to print on its own: it hooks in at one end " - "and clicks in at the other") - self.a_slicer = act("Open in slicer", lambda: self.open_in_slicer(), None, "Ctrl+L", - "Open the vase saved for printing in your slicer") - self.a_pick_slicer = act("Choose slicer program…", lambda: self.choose_slicer(), None, None, - "Which program Open in slicer uses") - self.a_gallery = act("Choose a design…", self.choose_design, glyph_icon("gallery"), "Ctrl+G", - "Pictures of every ready-made vase, and a plain cylinder to start from") - self.a_big = act("Bigger text", lambda on: self._set_look(big=on), None, "Ctrl+=", - "Make all the writing bigger") - self.a_light = act("Light theme", lambda on: self._set_look(light=on), None, None, - "Dark writing on a light background") - for a, key in ((self.a_big, "big_text"), (self.a_light, "light_theme")): - a.setCheckable(True) - a.setChecked(self.settings.value(key, False, type=bool)) - self.a_quit = act("Exit", self.close, None, "Ctrl+Q") - self.a_undo = act("Undo", self.undo, glyph_icon("undo"), "Ctrl+Z", "Undo the last change") - self.a_redo = act("Redo", self.redo, glyph_icon("redo"), "Ctrl+Y", "Redo") - self.a_redo.setShortcuts([QKeySequence("Ctrl+Y"), QKeySequence("Ctrl+Shift+Z")]) - self.a_surprise = act("Surprise me", self.surprise_me, glyph_icon("dice"), "Ctrl+R", - "A new random vase that still prints without supports") - self.a_copy_plan = act("Copy colour changes", self.copy_colour_plan, None, "Ctrl+Shift+C", - "Copy the list of filament changes, to keep beside the slicer") - self.a_about = act("About", self.about) - self.a_printing = act("Printing it watertight…", self.printing_help) - self.a_function = act("Your own function…", self.function_help) - self.a_drawing = act("Drawing a cross-section…", self.drawing_help) - - tb.addAction(self.a_new) - tb.addAction(self.a_open) - tb.addAction(self.a_save) - tb.addSeparator() - tb.addAction(self.a_print) - exp_btn = QToolButton() - exp_btn.setDefaultAction(self.a_exp_stl) - exp_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - exp_btn.setPopupMode(QToolButton.MenuButtonPopup) - m = QMenu(exp_btn) - m.addAction(self.a_exp_stl) - m.addAction(self.a_exp_3mf) - m.addSeparator() - m.addAction(self.a_lid) - exp_btn.setMenu(m) - tb.addWidget(exp_btn) - tb.addAction(self.a_snap) - tb.addSeparator() - tb.addAction(self.a_surprise) - gal_btn = self.gal_btn = QToolButton() - gal_btn.setText("Gallery") - gal_btn.setIcon(glyph_icon("gallery")) - gal_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - gal_btn.setToolTip("Pictures of every ready-made vase to start from (Ctrl+G)") - gal_btn.clicked.connect(self.choose_design) - self.gallery_menu = QMenu(self) - self.gallery_menu.addAction(self.a_gallery) - self.gallery_menu.addSeparator() - self.gallery_menu.addAction(self.a_plain) - self.gallery_menu.addSeparator() - for title, about, _ in GALLERY: - a = self.gallery_menu.addAction(title) - a.setToolTip(about) - a.setStatusTip(about) - a.triggered.connect(lambda _=False, t=title: self.use_gallery(t)) - self.gallery_menu.setToolTipsVisible(True) - tb.addWidget(gal_btn) - tb.addSeparator() - tb.addAction(self.a_undo) - tb.addAction(self.a_redo) - - mb = self.menuBar() - fm = mb.addMenu("&File") - for a in (self.a_new, self.a_plain, self.a_open, self.a_save, self.a_save_as, None, self.a_print, - self.a_lid, self.a_slicer, self.a_pick_slicer, None, self.a_exp_stl, self.a_exp_3mf, self.a_snap, None, - self.a_quit): - fm.addAction(a) if a is not None else fm.addSeparator() - em = mb.addMenu("&Edit") - for a in (self.a_undo, self.a_redo, None, self.a_surprise, None, self.a_copy_plan): - em.addAction(a) if a is not None else em.addSeparator() - dm = mb.addMenu("&Designs") - dm.addActions(self.gallery_menu.actions()) - dm.addSeparator() - dm.addAction(act("Library, profiles and Surprise locks…", lambda: DesignTools(self).exec())) - fm.addAction(act("Save clearance samples…", lambda: save_calibration(self))) - vm = mb.addMenu("&View") - self.view_actions = [] - for i, name in enumerate(VIEW_NAMES): - a = QAction(name, self) - a.setShortcut(QKeySequence(f"Ctrl+{i + 1}")) - a.triggered.connect(lambda _=False, i=i: self.show_view(i)) - vm.addAction(a) - self.view_actions.append(a) - vm.addSeparator() - a = QAction("Spin", self) - a.setShortcut(QKeySequence("S")) - a.triggered.connect(self.spin_btn.toggle) - vm.addAction(a) - a = QAction("Watch it print", self) - a.setShortcut(QKeySequence("F5")) - a.triggered.connect(self.print_btn.toggle) - vm.addAction(a) - a = QAction("Reset view", self) - a.setShortcut(QKeySequence("Ctrl+0")) - a.triggered.connect(lambda: self.show_view(self.view)) - vm.addAction(a) - vm.addSeparator() - vm.addAction(act("Compare original and softened…", lambda: show_comparison(self))) - vm.addAction(self.a_big) - vm.addAction(self.a_light) - hm = mb.addMenu("&Help") - hm.addAction(self.a_printing) - hm.addAction(self.a_function) - hm.addAction(self.a_drawing) - hm.addSeparator() - hm.addAction(self.a_about) - - # A coffee cup after Help, for anyone who would like to chip in. - self.a_kofi = QAction(glyph_icon("coffee"), "", self) - self.a_kofi.setToolTip("Vaseinator is free — buy me a coffee") - self.a_kofi.setStatusTip("Vaseinator is free — buy me a coffee") - self.a_kofi.triggered.connect(lambda: QDesktopServices.openUrl(QUrl(KOFI_URL))) - mb.addAction(self.a_kofi) - self._update_undo() - - # ============================================================ params - def _on_param(self, key, value, live=False): - if key == "head": - self._set_head(value) - return - touched, message = apply_change(self.params, key, value) - for k in touched: - self._set_widget(k) - if message: - self.statusBar().showMessage(message, 8000) - if key in REMEMBERED: - self.settings.setValue(f"default_{key}", float(value)) - self._sync_widgets() - self._dirty = True - self._update_title() - d = self.design - if key in ("lid_grip", "lid_open") and d is not None and not d.draft and not self.regen_timer.isActive(): - for q in (d.p, d.asked): # the lid's own: the cover itself stays as it is - setattr(q, key, getattr(self.params, key)) - self._remember() - return - self._schedule(live=live, text=key in TEXT_KEYS) - - def _set_head(self, cm: float): - """Scale the design to fit a head ``cm`` round.""" - QApplication.setOverrideCursor(Qt.WaitCursor) - try: - fitted = fit_head(self.params, 10.0 * cm) - finally: - QApplication.restoreOverrideCursor() - self.apply_changes({k: getattr(fitted, k) for k in ("silhouette",) + HEAD_SCALED}) - self.statusBar().showMessage(f"Sized for a head {cm:g} cm round — Ctrl+Z to go back", 6000) - - def _show_head(self, d: VaseDesign): - """The head the design fits, in the head size boxes.""" - cm = round(d.head_fit / 10.0, 1) - w = self.widgets["head"] - w.blockSignals(True) - w.setValue(cm) - w.blockSignals(False) - self.simple.show_head(cm) - - def _set_widget(self, key): - w = self.widgets[key] - w.blockSignals(True) - if key == "widest": - w.setValue(self._widest()) - elif isinstance(w, CurveEditor): - w.set_points(getattr(self.params, key)) - elif isinstance(w, OutlineEditor): - w.blockSignals(False) - self._push_outline(int(key[-1])) - elif isinstance(w, QComboBox): - w.setCurrentIndex(max(0, w.findData(getattr(self.params, key)))) - elif isinstance(w, QSpinBox): - w.setValue(int(getattr(self.params, key))) - if key in ("repeat1", "repeat2"): - self._push_outline(int(key[-1])) - elif isinstance(w, QDoubleSpinBox): - v = getattr(self.params, key) - w.setValue(100.0 * v if w.suffix() == " %" else v) - elif isinstance(w, QCheckBox): - w.setChecked(bool(getattr(self.params, key))) - w.blockSignals(False) - - # ----------------------------------------------------- drawn outlines - def _outline_edited(self, k: int, pts, source, live=False): - """An editor changed drawn outline k: the vase follows, and so does the other editor.""" - self._on_param(f"drawn{k}", pts, live=live) - self._push_outline(k, source) - - def _push_outline(self, k: int, source=None): - """Show drawn outline k in its editors - the one in the panel and any full window - but the one it came - from.""" - p = self.params - pts, repeat = getattr(p, f"drawn{k}"), getattr(p, f"repeat{k}") - ed = self.widgets.get(f"drawn{k}") - if ed is not None and ed is not source: - ed.set_outline(pts, repeat) - win = self.outline_windows.get(k) - if win is not None and win.editor is not source: - win.set_outline(pts, repeat) - - def _show_close_up(self, k: int): - ed, b = self.widgets[f"drawn{k}"], self.close_up_buttons[k] - b.blockSignals(True) - b.setEnabled(ed.repeat > 1) - b.setChecked(ed.close_up()) - b.blockSignals(False) - - def _open_outline_window(self, k: int): - """The cross-section editor in a window of its own, as big as the screen.""" - win = self.outline_windows.get(k) - if win is None: - title = "Draw the cross-section" if k == 1 else "Draw the cross-section it turns into" - win = OutlineWindow(self, f"{title} — {APP_NAME}", self.outline_menus[k]) - win.editor.changed.connect(lambda pts, kk=k, e=win.editor: self._outline_edited(kk, pts, e, live=True)) - win.editor.finished.connect(lambda pts, kk=k, e=win.editor: self._outline_edited(kk, pts, e)) - win.repeat_changed.connect(lambda v, kk=k: self._on_param(f"repeat{kk}", int(v))) - self.outline_windows[k] = win - win.set_outline(getattr(self.params, f"drawn{k}"), getattr(self.params, f"repeat{k}")) - win.showMaximized() - win.raise_() - win.activateWindow() - - def _outline_from_shape(self, k: int): - outline_from_shape(self.params, k) - self._set_widget(f"repeat{k}") - self._set_widget(f"drawn{k}") - - def _use_outline(self, k: int, pts, repeat: int): - """Start drawn outline k again from given control points.""" - setattr(self.params, f"repeat{k}", int(repeat)) - self._set_widget(f"repeat{k}") - self._on_param(f"drawn{k}", [list(q) for q in pts]) - self._set_widget(f"drawn{k}") - - def _trace_outline(self, k: int): - start = self.settings.value("picture_dir", self._last_dir()) - path, _ = QFileDialog.getOpenFileName(QApplication.activeWindow() or self, "Trace a picture", start, - "Pictures (*.png *.jpg *.jpeg *.bmp *.gif *.webp);;All files (*)") - if not path: - return - self.settings.setValue("picture_dir", os.path.dirname(path)) - try: - pts = sections.trace_picture(path) - except (ValueError, OSError) as exc: - QMessageBox.warning(self, "Can't trace that picture", - f"{os.path.basename(path)}: {exc}. Draw the outline as one closed dark line on a " - "light or see-through ground.") - return - self._note = (f"Traced {os.path.basename(path)}: {len(pts)} points, " - f"{sum(q[2] for q in pts)} sharp corners. ") - self._use_outline(k, pts, 1) - - def _schedule(self, live=False, text=False): - """Rebuild after a pause; while something is being dragged, quick rough builds follow the hand.""" - if text: - self.regen_timer.start(600) - return - slow = self._last_full_ms > 220 - if live or slow: - self.draft_timer.start(15) - self.regen_timer.start(450 if live else 300) - else: - self.regen_timer.start(120) - - def _on_name(self, text): - self.params.name = text.strip() or "vase" - self._dirty = True - self.workspace.add("recovery", self.params) - self._save_workspace() - self._update_title() - - def _update_title(self): - f = os.path.basename(self.current_file) if self.current_file else "unsaved" - star = " •" if self._dirty else "" - self.setWindowTitle(f"{APP_NAME} — {self.params.name} ({f}){star}") - - # ======================================================= undo / redo - def _remember(self): - d = self.params.to_dict() - if self.history: - self.workspace.add("recovery", self.params) - self._save_workspace() - if not self.history or self.history[-1] != d: - self.history.append(d) - del self.history[:-200] - self.future.clear() - self._update_undo() - - def _save_workspace(self): - self.settings.setValue("workspace", self.workspace.dumps()) - - def _update_undo(self): - if hasattr(self, "a_undo"): - self.a_undo.setEnabled(len(self.history) > 1) - self.a_redo.setEnabled(bool(self.future)) - - def _restore(self, d): - self.params = VaseParams.from_dict(d) - self._dirty = True - self._refresh_widgets() - self.regenerate() - - def _flush(self): - """Build now if a change is still waiting.""" - if self.regen_timer.isActive() or self.draft_timer.isActive(): - self.regen_timer.stop() - self.draft_timer.stop() - self.regenerate() - - def undo(self): - self._flush() - if len(self.history) < 2: - return - self.future.append(self.history.pop()) - self._restore(self.history[-1]) - self._update_undo() - - def redo(self): - self._flush() - if not self.future: - return - d = self.future.pop() - self.history.append(d) - self._restore(d) - self._update_undo() - - def surprise_me(self, seed=None): - self._flush() - seed = random.randint(1, 999_999) if seed is None or seed is False else int(seed) - self.params = locked_surprise(self.params, seed, self.workspace.data["locks"]) - self._dirty = True - self._refresh_widgets() - self.regenerate() - self.statusBar().showMessage(f"Surprise #{seed}: {self.params.name.replace('_', ' ')} — Ctrl+Z to go " - "back", 8000) - - def apply_changes(self, changes: dict, live=False): - """Several settings at once - a picture picked in Simple mode.""" - p = self.params - for key, value in changes.items(): - setattr(p, key, value) - if "profile" in changes and p.profile != OWN_PROFILE: - p.silhouette = outline(p.profile, self._widest()) - self._dirty = True - self._refresh_widgets() - self._schedule(live=live) - - def choose_design(self): - """Every ready-made vase as a picture to click.""" - def entry(title, about, p, picture): - pts = np.asarray(p.silhouette, dtype=float) - return title, about, asset("gallery", picture), profile_icon(pts, QColor(p.color1)) - entries = [entry("Plain cylinder", "200 mm across and 200 mm tall, with everything else off: a blank " - "start for your own shape or function", plain_design(), "plain_cylinder.png")] - entries += [entry(t, about, gallery_design(t), slug(t) + ".png") for t, about, _ in GALLERY] - dlg = GalleryDialog(self, entries) - if dlg.exec() == QDialog.Accepted and dlg.chosen: - if dlg.chosen == "Plain cylinder": - self.use_plain() - else: - self.use_gallery(dlg.chosen) - - def draw_own_shape(self): - """Draw the shape from above, in a window of its own, starting from the shape it is.""" - if self.params.shape1 != DRAWN: - self._on_param("shape1", DRAWN) - self._flush() - self._open_outline_window(1) - - def _set_look(self, big=None, light=None): - """Bigger text, and the light or dark theme - remembered for next time.""" - if big is not None: - self.settings.setValue("big_text", bool(big)) - if light is not None: - self.settings.setValue("light_theme", bool(light)) - apply_theme(QApplication.instance(), light=self.settings.value("light_theme", False, type=bool), - scale=1.3 if self.settings.value("big_text", False, type=bool) else 1.0) - # the line icons are drawn in the theme's ink: draw them again - for a, kind in ((self.a_exp_stl, "export"), (self.a_print, "export"), (self.a_snap, "picture"), - (self.a_surprise, "dice"), (self.a_undo, "undo"), (self.a_redo, "redo"), - (self.a_gallery, "gallery"), (self.a_kofi, "coffee")): - a.setIcon(glyph_icon(kind)) - self.gal_btn.setIcon(glyph_icon("gallery")) - self.dice_btn.setIcon(glyph_icon("dice")) - - def use_plain(self): - """A blank start: the plain cylinder, keeping the printer settings.""" - self._flush() - self.params = plain_design(keep=self.params) - self._dirty = True - self._refresh_widgets() - self.regenerate() - self.statusBar().showMessage("Plain cylinder, 200 mm across and 200 mm tall - type your own function, or " - "choose Drawn as the cross-section; Ctrl+Z to go back", 12000) - - def use_gallery(self, title): - self._flush() - self.params = gallery_design(title, keep=self.params) - self._dirty = True - self._refresh_widgets() - self.regenerate() - self.statusBar().showMessage(f"{title} — your printer settings are kept; Ctrl+Z to go back", 8000) - - # ============================================================ build - def regenerate(self, draft=False): - if not draft: - self.draft_timer.stop() - t0 = time.perf_counter() - QApplication.setOverrideCursor(Qt.WaitCursor) - try: - d = VaseDesign(self.params.copy(), draft=draft) - except Exception as exc: # pragma: no cover - safety net for the GUI - self._show_info(None, [f"Internal error: {exc}"], []) - return - finally: - QApplication.restoreOverrideCursor() - if not draft: - self._remember() - if d.errors: - self._show_info(None, d.errors, d.warnings) - self.statusBar().showMessage("This vase cannot be built - see the panel on the right") - return - self.design = d - self._show_design(d) - self._show_head(d) - dt = (time.perf_counter() - t0) * 1000 - if not draft: - self._last_full_ms = dt - self._show_info(d, [], d.warnings) - note, self._note = self._note, "" - self.statusBar().showMessage(f"{note}Built in {dt:.0f} ms — {d.triangles:,} triangles", - 10000 if note else 6000) - - def _show_design(self, d: VaseDesign): - colors = d.p.colors() - phase = d.colour_phase(d.shell_verts[:, 2]) if d.gradient else None - self.viewer.set_mesh("vase", d.shell_verts, d.shell_faces, colors[d.bands[0][1]], - face_vals=d.shell_lean, vert_vals=phase, flat=d.rows is not None) - self.viewer.set_section_source(d.shell_verts, d.shell_faces) - self.viewer.set_bed(d.p.bed_x, d.p.bed_y, d.p.printer_height) - self.plots.set_design(d, colors) - self._apply_view() - if self.printing is not None: - self._anim_tick() - - def _show_info(self, d, errors, warnings): - self.info.setHtml(info_html(d, errors, warnings)) - - - # ============================================================ view - def show_view(self, idx): - self.view = idx - self._free_camera = False - self._fitted = None - self.view_btns[idx].setChecked(True) - self._apply_view() - - def _apply_view(self): - d, v = self.design, self.viewer - if d is None: - return - p = d.p - v.show_bed = self.view == PRINT_LAYOUT - v.section = self.view == SECTION - colors = p.colors() - v.bands = [(z, colors[f]) for z, f in d.bands] - v.gradient = [colors[f] for f in d.gradient_fils] - v.colmode = 3 if self.view == OVERHANGS else (2 if d.gradient else 1) - v.limit = p.max_overhang - v.look = p.look - if self.view == OVERHANGS: - self.hint.setText(f"Green: easy · orange: near {p.max_overhang:g}° · red: over it, needs support") - elif self.printing is None: - self.hint.setText("Drag to turn · wheel to zoom · right-drag to move") - size = (round(float(d.size[0]), 0), round(float(d.size[1]), 0), round(d.H, 0)) - if self._fitted != (self.view, size): - r = 0.5 * math.hypot(d.size[0], d.size[1]) - reach = math.hypot(r, 0.5 * d.H) - aspect = max(v.width(), 1) / max(v.height(), 1) - fov = math.radians(v.fov) * min(1.0, aspect) - v.target = np.array([0.0, 0.0, 0.5 * d.H]) - v.dist = reach / math.sin(fov / 2.0) * (1.75 if self.view == PRINT_LAYOUT else 1.12) - self._fitted = (self.view, size) - if not self._free_camera: - v.yaw, v.pitch = {VASE: (-60.0, 14.0), SECTION: (-90.0, 8.0), OVERHANGS: (-60.0, 14.0), - PRINT_LAYOUT: (-55.0, 32.0)}[self.view] - v.set_model(spin_transform(self.spin_angle)) - v.update() - - def _on_dragged(self): - self._free_camera = True - - def _on_resized(self): - if not self._free_camera: - self._fitted = None - self._apply_view() - - # ------------------------------------------------- spin and printing - def set_spin(self, on: bool): - self.spinning = bool(on) - self.settings.setValue("spin", self.spinning) - self._update_anim() - - def set_printing(self, on: bool): - if on and self.design is not None: - self.printing = time.perf_counter() - else: - self.printing = None - self.viewer.cut_z = None - self.viewer.set_lines(None) - self._apply_view() - self._update_anim() - - def _update_anim(self): - if self.spinning or self.printing is not None: - self._last_tick = time.perf_counter() - self.anim_timer.start() - else: - self.anim_timer.stop() - - def _print_length(self): - d = self.design - return float(np.clip(6.0 + d.H / 30.0, 6.0, 16.0)) - - def _anim_tick(self): - now = time.perf_counter() - dt, self._last_tick = now - self._last_tick, now - d, v = self.design, self.viewer - if d is None: - return - if self.spinning: - self.spin_angle = (self.spin_angle + SPIN_SPEED * min(dt, 0.1)) % 360.0 - v.set_model(spin_transform(self.spin_angle)) - if self.printing is not None: - span = self._print_length() - t = (now - self.printing) / span - if t > 1.25: # hold the finished vase a moment, then again - self.printing = now - t = 0.0 - t = min(t, 1.0) - # the real print time: the filament used up to each height, at an even speed - done = d.filament_at[-1] * t - z = float(np.interp(done, d.filament_at, d.filament_z)) if d.filament_at[-1] > 0 else t * d.H - z = min(max(z, 0.05), d.H) - v.cut_z = z if t < 1.0 else None - v.set_lines(d.contour(z) if t < 1.0 else None) - layer = min(int(math.ceil(z / d.p.layer_height)), d.layers) - self.hint.setText(f"Printing: layer {layer} of {d.layers} · {duration(d.seconds * t)} of " - f"{duration(d.seconds)}" if t < 1.0 else f"Done: {duration(d.seconds)}, " - f"{d.grams:.0f} g of filament") - v.update() - - # ============================================================ files - def _last_dir(self): - return self.settings.value("last_dir", os.path.join(os.path.expanduser("~"), "Documents")) - - def _remember_dir(self, path): - self.settings.setValue("last_dir", os.path.dirname(path)) - - def _safe_name(self): - name = "".join(ch if ch.isalnum() or ch in "-_ " else "_" for ch in self.params.name).strip() - return name or "vase" - - def new_design(self): - if not self._confirm_discard(): - return - self.params = self._fresh_params() - self.current_file = None - self._dirty = False - self._refresh_widgets() - self.regenerate() - - def _confirm_discard(self): - if not self._dirty: - return True - r = QMessageBox.question(self, "Unsaved changes", "Discard unsaved changes to the current design?", - QMessageBox.Discard | QMessageBox.Cancel, QMessageBox.Cancel) - return r == QMessageBox.Discard - - def open_design(self): - if not self._confirm_discard(): - return - path, _ = QFileDialog.getOpenFileName(self, "Open design", self._last_dir(), - f"Vaseinator designs (*{FILE_EXT} *.json);;All files (*)") - if path: - self.load_file(path) - - def load_file(self, path): - try: - params = load_design(path) - except Exception as exc: - QMessageBox.critical(self, "Open failed", f"Could not open the design:\n{exc}") - return - self._remember_dir(path) - self.params = params - self.current_file = path - self._dirty = False - self._refresh_widgets() - self.regenerate() - self.statusBar().showMessage(f"Opened {path}", 6000) - - def save_design(self, as_new=False): - path = self.current_file - if as_new or not path: - default = os.path.join(self._last_dir(), self._safe_name() + FILE_EXT) - path, _ = QFileDialog.getSaveFileName(self, "Save design", default, f"Vaseinator designs (*{FILE_EXT})") - if not path: - return - if not os.path.splitext(path)[1]: - path += FILE_EXT - try: - save_design(path, self.params) - except OSError as exc: - QMessageBox.critical(self, "Save failed", str(exc)) - return - self._remember_dir(path) - self.current_file = path - self._dirty = False - self._update_title() - self.statusBar().showMessage(f"Saved {path}", 6000) - - def _current_design(self): - """The design to export, rebuilt in full first if a change is still waiting.""" - self._flush() - d = self.design - if d is not None and d.draft: - self.regenerate() - d = self.design - if d is None or {**d.asked.to_dict(), "name": ""} != {**self.params.to_dict(), "name": ""}: - QMessageBox.warning(self, "Nothing to export", "The current settings do not make a vase.") - return None - try: - require_exportable(d) - except ValueError as exc: - QMessageBox.warning(self, "Not exported", str(exc)) - return None - return d - - def export_stl(self): - d = self._current_design() - if d is None: - return - base = self._safe_name() - if not base.endswith("_vase"): - base += "_vase" # make it obvious this is a solid model for spiral vase mode - path, _ = QFileDialog.getSaveFileName(self, "Export STL", os.path.join(self._last_dir(), base + ".stl"), - "STL files (*.stl)") - if not path: - return - if not path.lower().endswith(".stl"): - path += ".stl" - try: - QApplication.setOverrideCursor(Qt.WaitCursor) - try: - verts, faces = d.export_mesh() - finally: - QApplication.restoreOverrideCursor() - write_stl(path, verts, faces, base) - except OSError as exc: - QMessageBox.critical(self, "Export failed", str(exc)) - return - self._remember_dir(path) - if d.p.vase_mode == REAL_VASE: - how = "slice it with spiral vase mode on" - else: - how = ("the hollow pot: slice it with your usual settings (an STL has no colours: Export 3MF has the " - "bands as parts)") - self.statusBar().showMessage( - f"Exported {os.path.basename(path)} ({len(faces):,} triangles, watertight) to " - f"{os.path.dirname(path)} — {how}", 12000) - - def band_parts(self, d): - """The vase for write_3mf: a part per colour band, set to its filament.""" - colors = d.p.colors() - QApplication.setOverrideCursor(Qt.WaitCursor) - try: - parts = d.export_parts() - finally: - QApplication.restoreOverrideCursor() - many = len(parts) > 1 - return [(f"{self._safe_name()} band {k + 1}" if many else self._safe_name(), verts, faces, fil, colors[fil]) - for k, (fil, verts, faces) in enumerate(parts)] - - def export_3mf(self): - """The vase for the slicer: one solid for a real spiral vase, a part per colour band for a fake one.""" - d = self._current_design() - if d is None: - return - p = d.p - base = self._safe_name() - path, _ = QFileDialog.getSaveFileName(self, "Export 3MF", os.path.join(self._last_dir(), base + ".3mf"), - "3MF files (*.3mf)") - if not path: - return - if not path.lower().endswith(".3mf"): - path += ".3mf" - parts = self.band_parts(d) - try: - write_3mf(path, base, parts, (p.bed_x / 2.0, p.bed_y / 2.0, 0.0)) - except OSError as exc: - QMessageBox.critical(self, "Export failed", str(exc)) - return - self._remember_dir(path) - if p.vase_mode == REAL_VASE: - how = "turn on spiral vase" - else: - how = "leave spiral vase off and print with your usual settings" - if len(parts) > 1: - fils = ", ".join(str(f) for f in sorted({f for _, _, _, f, _ in parts})) - what = f"{len(parts)} bands set to filaments {fils}" - else: - what = "the vase, one part" - self.statusBar().showMessage( - f"Exported {os.path.basename(path)}: {what} — in Flash Studio use File ▸ Import (Ctrl+I), then {how}", - 15000) - - def save_for_printing(self): - """The one button Simple mode needs: the vase as a 3MF for the slicer, and plain steps to print it.""" - d = self._current_design() - if d is None: - return - base = self._safe_name() - path, _ = QFileDialog.getSaveFileName(self, "Save for printing", os.path.join(self._last_dir(), base + ".3mf"), - "3MF files, for the slicer (*.3mf)") - if not path: - return - if not path.lower().endswith(".3mf"): - path += ".3mf" - parts = self.band_parts(d) - try: - write_3mf(path, base, parts, (d.p.bed_x / 2.0, d.p.bed_y / 2.0, 0.0)) - except OSError as exc: - QMessageBox.critical(self, "Couldn't save it", str(exc)) - return - self._remember_dir(path) - self.print_file = path - self.statusBar().showMessage(f"Saved {os.path.basename(path)} for printing, in {os.path.dirname(path)}", - 12000) - self.steps = PrintSteps(self, d, path, len(parts), self._slicer(ask=False)) - self.steps.open_slicer.connect(lambda: self.open_in_slicer(path)) - self.steps.show() - - def save_lid(self): - """A tissue box cover's lid, made to fit it, as a 3MF for the slicer - with how to print it and put it on.""" - d = self._current_design() - if d is None: - return - lid = Lid(d) - try: - require_exportable(lid) - except ValueError as exc: - QMessageBox.warning(self, "No lid", str(exc)) - return - base = self._safe_name() + " lid" - path, _ = QFileDialog.getSaveFileName(self, "Save the lid for printing", - os.path.join(self._last_dir(), base + ".3mf"), - "3MF files, for the slicer (*.3mf)") - if not path: - return - if not path.lower().endswith(".3mf"): - path += ".3mf" - try: - write_3mf(path, base, [(base, lid.verts, lid.faces, 1, d.p.colors()[1])], - (d.p.bed_x / 2.0, d.p.bed_y / 2.0, 0.0)) - except OSError as exc: - QMessageBox.critical(self, "Couldn't save it", str(exc)) - return - self._remember_dir(path) - self.print_file = path - self.statusBar().showMessage(f"Saved {os.path.basename(path)} for printing, in {os.path.dirname(path)}", - 12000) - self.steps = PrintSteps(self, d, path, 1, self._slicer(ask=False), - steps=lambda where: lid_steps(lid, path, where)) - self.steps.open_slicer.connect(lambda: self.open_in_slicer(path)) - self.steps.show() - - def _slicer(self, ask=True): - """The slicer program to open a vase in: the one chosen before, else the first one installed.""" - path = self.settings.value("slicer", "", type=str) - if path and os.path.exists(path): - return path - found = slicers.find_slicers() - if found: - self.settings.setValue("slicer", found[0][1]) - return found[0][1] - return self.choose_slicer() if ask else None - - def choose_slicer(self): - found = slicers.find_slicers() - start = os.path.dirname(found[0][1]) if found else os.environ.get("ProgramFiles", os.path.expanduser("~")) - path, _ = QFileDialog.getOpenFileName(QApplication.activeWindow() or self, "Choose your slicer program", - start, "Programs (*.exe);;All files (*)") - if not path: - return None - self.settings.setValue("slicer", path) - self.statusBar().showMessage(f"Open in slicer will use {slicers.slicer_name(path)}", 8000) - return path - - def open_in_slicer(self, path=None): - """Open the vase saved for printing in the slicer (saving it first if it hasn't been).""" - path = path or getattr(self, "print_file", None) - if not path or not os.path.exists(path): - self.save_for_printing() - return - program = self._slicer() - if not program: - return - if slicers.open_in(program, path): - self.statusBar().showMessage(f"Opening {os.path.basename(path)} in {slicers.slicer_name(program)}…", - 10000) - else: - QMessageBox.warning(self, "Couldn't open the slicer", - f"{program} didn't start. Choose your slicer program in the File menu, or open " - f"{path} in it yourself.") - - def save_snapshot(self): - default = os.path.join(self._last_dir(), self._safe_name() + "_3d.png") - path, _ = QFileDialog.getSaveFileName(self, "Save picture", default, "PNG image (*.png)") - if not path: - return - if not path.lower().endswith(".png"): - path += ".png" - self.viewer.grabFramebuffer().save(path) - self._remember_dir(path) - self.statusBar().showMessage(f"Saved picture to {path}", 8000) - - def copy_colour_plan(self): - self._flush() - if self.design is None: - return - QApplication.clipboard().setText(self.design.colour_plan()) - self.statusBar().showMessage("Colour changes copied to the clipboard", 6000) - - def printing_help(self): - QMessageBox.information(self, "Printing it watertight", PRINTING_HELP) - - def drawing_help(self): - QMessageBox.information(self, "Drawing a cross-section", DRAWING_HELP) - - def about(self): - QMessageBox.about(self, f"About {APP_NAME}", ABOUT) - - def closeEvent(self, ev): - if not self._confirm_discard(): - ev.ignore() - return - self.workspace.add("recovery", self.params) - self._save_workspace() - self.settings.setValue("geometry", self.saveGeometry()) - super().closeEvent(ev) - - -# ============================================================== bootstrap -def apply_dark_theme(app: QApplication): - apply_theme(app, light=False) - - -def apply_theme(app: QApplication, light: bool = False, scale: float = 1.0): - """The look: dark (the usual) or light, and the writing at its own size or bigger.""" - app.setStyle("Fusion") - base = app.property("vaseinator_base_pt") - if base is None: - base = app.font().pointSizeF() - app.setProperty("vaseinator_base_pt", base) - font = app.font() - font.setPointSizeF(base * scale) - app.setFont(font) - title = f"QLabel#simpleTitle {{ font-weight: 700; font-size: {base * scale * 1.25:.1f}pt; }}" - if light: - _light_theme(app) - app.setStyleSheet(app.styleSheet() + title) - return - pal = QPalette() - c = QColor - pal.setColor(QPalette.Window, c(36, 39, 45)) - pal.setColor(QPalette.WindowText, c(222, 225, 230)) - pal.setColor(QPalette.Base, c(26, 28, 33)) - pal.setColor(QPalette.AlternateBase, c(44, 47, 54)) - pal.setColor(QPalette.ToolTipBase, c(52, 56, 64)) - pal.setColor(QPalette.ToolTipText, c(230, 230, 230)) - pal.setColor(QPalette.Text, c(222, 225, 230)) - pal.setColor(QPalette.Button, c(50, 54, 62)) - pal.setColor(QPalette.ButtonText, c(222, 225, 230)) - pal.setColor(QPalette.BrightText, c(255, 90, 90)) - pal.setColor(QPalette.Highlight, c(56, 132, 214)) - pal.setColor(QPalette.HighlightedText, c(255, 255, 255)) - pal.setColor(QPalette.Link, c(110, 180, 255)) - pal.setColor(QPalette.PlaceholderText, c(130, 135, 145)) - for role in (QPalette.Text, QPalette.ButtonText, QPalette.WindowText): - pal.setColor(QPalette.Disabled, role, c(120, 124, 132)) - app.setPalette(pal) - app.setStyleSheet(""" - QGroupBox { font-weight: 600; border: 1px solid #3b4049; border-radius: 6px; - margin-top: 12px; padding-top: 4px; } - QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 4px; color: #f0a356; } - QTextBrowser { border: none; } - QToolBar { border: none; spacing: 4px; padding: 3px; } - QSplitter::handle { background: #2b2e34; } - QPushButton#modeButton { border: 1px solid #3b4049; padding: 4px 12px; background: #2a2d33; } - QPushButton#modeButton:checked { background: #3884d6; color: white; border-color: #3884d6; } - QToolButton#pictureButton { border: 1px solid #3b4049; border-radius: 6px; padding: 4px; - background: #2a2d33; } - QToolButton#pictureButton:hover { border-color: #6b7380; } - QToolButton#pictureButton:checked { border: 2px solid #f0a356; background: #3a332b; } - QPushButton#bigButton { padding: 8px 10px; font-weight: 600; } - QPushButton#saveButton { background: #f0a356; color: #1a1c21; font-weight: 700; border-radius: 6px; - border: 1px solid #f0a356; } - QPushButton#saveButton:hover { background: #f5b878; } - QLabel#simpleTitle { color: #f0a356; } - """ + title) - - -def _light_theme(app: QApplication): - pal = QPalette() - c = QColor - pal.setColor(QPalette.Window, c(238, 240, 243)) - pal.setColor(QPalette.WindowText, c(28, 30, 34)) - pal.setColor(QPalette.Base, c(255, 255, 255)) - pal.setColor(QPalette.AlternateBase, c(232, 235, 239)) - pal.setColor(QPalette.ToolTipBase, c(255, 255, 238)) - pal.setColor(QPalette.ToolTipText, c(20, 20, 20)) - pal.setColor(QPalette.Text, c(28, 30, 34)) - pal.setColor(QPalette.Button, c(226, 229, 234)) - pal.setColor(QPalette.ButtonText, c(28, 30, 34)) - pal.setColor(QPalette.BrightText, c(200, 30, 30)) - pal.setColor(QPalette.Highlight, c(56, 132, 214)) - pal.setColor(QPalette.HighlightedText, c(255, 255, 255)) - pal.setColor(QPalette.Link, c(20, 100, 200)) - pal.setColor(QPalette.PlaceholderText, c(120, 125, 135)) - for role in (QPalette.Text, QPalette.ButtonText, QPalette.WindowText): - pal.setColor(QPalette.Disabled, role, c(150, 154, 162)) - app.setPalette(pal) - app.setStyleSheet(""" - QGroupBox { font-weight: 600; border: 1px solid #c4c9d1; border-radius: 6px; - margin-top: 12px; padding-top: 4px; } - QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 4px; color: #b45f12; } - QTextBrowser { border: none; } - QToolBar { border: none; spacing: 4px; padding: 3px; } - QSplitter::handle { background: #d5d9df; } - QPushButton#modeButton { border: 1px solid #c4c9d1; padding: 4px 12px; background: #e6e8ec; } - QPushButton#modeButton:checked { background: #3884d6; color: white; border-color: #3884d6; } - QToolButton#pictureButton { border: 1px solid #c4c9d1; border-radius: 6px; padding: 4px; - background: #f6f7f9; } - QToolButton#pictureButton:hover { border-color: #8c939e; } - QToolButton#pictureButton:checked { border: 2px solid #e08a2c; background: #fcefe0; } - QPushButton#bigButton { padding: 8px 10px; font-weight: 600; } - QPushButton#saveButton { background: #e08a2c; color: white; font-weight: 700; border-radius: 6px; - border: 1px solid #e08a2c; } - QPushButton#saveButton:hover { background: #ea9b44; } - QLabel#simpleTitle { color: #b45f12; } - """) - - -def place_for_testing(win: QMainWindow, argv): - """Put a test run's window on another screen than the main one (the second, unless --screen N), - so testing doesn't get in the way of whoever is at the computer. - - Windows places a new window itself, so the window is shown see-through, - moved once it is up, and only then made visible. - """ - from PySide6.QtCore import QRect - - screens = QApplication.screens() - index = int(argv[argv.index("--screen") + 1]) if "--screen" in argv else (1 if len(screens) > 1 else 0) - screen = screens[min(max(index, 0), len(screens) - 1)] - g = screen.availableGeometry() - win.setWindowOpacity(0.0) - win.show() - QApplication.processEvents() - win.windowHandle().setScreen(screen) - win.setGeometry(QRect(g.x() + 10, g.y() + 30, min(1480, g.width() - 20), min(940, g.height() - 40))) - QApplication.processEvents() - win.setWindowOpacity(1.0) - - -def main(argv=None): - argv = list(sys.argv if argv is None else argv) - fmt = QSurfaceFormat() - fmt.setDepthBufferSize(24) - fmt.setSamples(8) - fmt.setVersion(2, 1) - fmt.setProfile(QSurfaceFormat.CompatibilityProfile) - QSurfaceFormat.setDefaultFormat(fmt) - - app = QApplication(argv) - app.setApplicationName(APP_NAME) - app.setOrganizationName("Vaseinator") - apply_dark_theme(app) - settings = None - if "--screenshot" in argv: # a test run keeps its hands off the remembered settings - import tempfile - settings = QSettings(os.path.join(tempfile.gettempdir(), "Vaseinator-test.ini"), QSettings.IniFormat) - settings.clear() - settings.setValue("spin", False) - win = MainWindow(settings) - if "--screenshot" in argv: - place_for_testing(win, argv) - else: - win.show() - - valued = ("--screenshot", "--view", "--export-test", "--yaw", "--pitch", "--zoom", "--gallery", "--surprise", - "--print-at", "--spin", "--screen") - files = [a for i, a in enumerate(argv) if i > 0 and not a.startswith("--") - and argv[i - 1] not in valued and os.path.isfile(a)] - if files: - win.load_file(files[0]) - - # developer hook: --screenshot out.png [--view N] [--gallery NAME] [--surprise SEED] [--yaw a] - # [--pitch a] [--zoom k] [--print-at 0..1] [--spin deg] [--detailed] [--export-test DIR] - # [--screen N] (the window opens on the second screen, if there is one) [--only-3d] - if "--screenshot" in argv: - out = argv[argv.index("--screenshot") + 1] - win.set_simple_mode("--detailed" not in argv, remember=False) - if "--gallery" in argv: - win.use_gallery(argv[argv.index("--gallery") + 1]) - if "--surprise" in argv: - win.surprise_me(int(argv[argv.index("--surprise") + 1])) - if "--view" in argv: - win.show_view(int(argv[argv.index("--view") + 1])) - if "--export-test" in argv: - out_dir = argv[argv.index("--export-test") + 1] - d = win.design - write_stl(os.path.join(out_dir, "t_vase.stl"), d.verts, d.faces, "t") - write_3mf(os.path.join(out_dir, "t.3mf"), "t", win.band_parts(d), (d.p.bed_x / 2, d.p.bed_y / 2, 0.0)) - if d.p.box: - lid = Lid(d) - write_3mf(os.path.join(out_dir, "t_lid.3mf"), "t lid", [("t lid", lid.verts, lid.faces, 1, (1, 1, 1))]) - - def snap(): - win._fitted = None - win._apply_view() - if "--spin" in argv: - win.spin_angle = float(argv[argv.index("--spin") + 1]) - win.viewer.set_model(spin_transform(win.spin_angle)) - for flag, attr in (("--yaw", "yaw"), ("--pitch", "pitch")): - if flag in argv: - setattr(win.viewer, attr, float(argv[argv.index(flag) + 1])) - if "--zoom" in argv: - win.viewer.dist /= float(argv[argv.index("--zoom") + 1]) - if "--print-at" in argv: - t = float(argv[argv.index("--print-at") + 1]) - d = win.design - z = float(np.interp(d.filament_at[-1] * t, d.filament_at, d.filament_z)) - win.viewer.cut_z = z - win.viewer.set_lines(d.contour(z)) - layer = int(math.ceil(z / d.p.layer_height)) - win.hint.setText(f"Printing: layer {layer} of {d.layers} · {duration(d.seconds * t)} of " - f"{duration(d.seconds)}") - win.viewer.repaint() - (win.viewer.grabFramebuffer() if "--only-3d" in argv else win.grab()).save(out) - win._dirty = False # a test run has nothing to save: close without asking - app.quit() - QTimer.singleShot(2200, snap) - - return app.exec() - - -if __name__ == "__main__": - sys.exit(main()) +"""Vaseinator - main window.""" + +from __future__ import annotations + +import math +import json +import os +import random +import sys +import time + +import numpy as np +from PySide6.QtCore import QSettings, QSize, Qt, QTimer, QUrl, Signal +from PySide6.QtGui import (QAction, QColor, QDesktopServices, QFontDatabase, QIcon, QKeySequence, QPainter, + QPalette, QPen, QPixmap, QSurfaceFormat) +from PySide6.QtWidgets import (QApplication, QCheckBox, QColorDialog, QComboBox, QDialog, QDialogButtonBox, + QDoubleSpinBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel, + QLineEdit, QMainWindow, QMenu, QMessageBox, QPlainTextEdit, QPushButton, + QScrollArea, QSlider, QSpinBox, QSplitter, QStackedWidget, QStyle, + QTextBrowser, QToolButton, QVBoxLayout, QWidget) + +from . import sections, slicers +from .workspace import Workspace, locked_surprise +from .design_tools import DesignTools, show_comparison, save_calibration +from .curves import CurveEditor +from .expr import EXAMPLES +from .geometry import HEAD_SCALED, VaseDesign, duration, fit_head, require_exportable +from .outline_editor import CLOSE_UP_FROM, OutlineEditor, OutlineWindow +from .params import (DRAWN, FILAMENTS, ONE_COLOUR, OWN_PROFILE, POLYGON, REAL_VASE, VaseParams, colour_style_of, + load_design, save_design) +from .plots import ShapePlots +from .lid import Lid +from .report import ABOUT, APP_NAME, DRAWING_HELP, PRINTING_HELP, function_help, info_html, lid_steps, print_steps +from .settings import (COLOUR_STYLES, REAL_COLOUR_STYLES, REMEMBERED, SPEC, TEXT_KEYS, change as apply_change, + outline_from_shape, wanted) +from .presets import GALLERY, gallery_design, outline, palette, plain_design, slug, surprise +from .simple_panel import GalleryDialog, SimplePanel, asset, profile_icon +from .spinbox import ComboBox, DoubleSpinBox, Slider, SpinBox +from .shapes import PALETTES, SHAPE_LABELS +from .stl import write_stl +from .threemf import write_3mf +from .viewer import Viewer3D, spin_transform + +FILE_EXT = ".vase" +KOFI_URL = "https://ko-fi.com/rickapps" +VASE, SECTION, OVERHANGS, PRINT_LAYOUT = 0, 1, 2, 3 +VIEW_NAMES = ("Vase", "Section", "Overhangs", "Print layout") +SPIN_SPEED = 30.0 # degrees a second, on the turntable + + + +def swatch(color: str, size: int = 14) -> QIcon: + pm = QPixmap(size, size) + pm.fill(Qt.transparent) + p = QPainter(pm) + p.setPen(QPen(QColor(20, 22, 26), 1)) + p.setBrush(QColor(color)) + p.drawRect(0, 0, size - 1, size - 1) + p.end() + return QIcon(pm) + + +def palette_icon(colours) -> QIcon: + pm = QPixmap(56, 14) + pm.fill(Qt.transparent) + p = QPainter(pm) + for k, c in enumerate(colours): + p.setPen(QPen(QColor(20, 22, 26), 1)) + p.setBrush(QColor(c)) + p.drawRect(k * 14, 0, 13, 13) + p.end() + return QIcon(pm) + + +def glyph_icon(kind: str) -> QIcon: + """Small light line icons that read well on the dark theme.""" + from PySide6.QtCore import QPointF, QRectF + + pm = QPixmap(64, 64) + pm.fill(Qt.transparent) + p = QPainter(pm) + p.setRenderHint(QPainter.Antialiasing) + app = QApplication.instance() + ink = app.palette().color(QPalette.WindowText) if app is not None else QColor(225, 230, 236) + pen = QPen(ink, 5, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin) + p.setPen(pen) + if kind == "export": + p.drawLine(QPointF(32, 8), QPointF(32, 40)) + p.drawPolyline([QPointF(19, 28), QPointF(32, 41), QPointF(45, 28)]) + p.drawPolyline([QPointF(10, 40), QPointF(10, 54), QPointF(54, 54), QPointF(54, 40)]) + elif kind == "picture": + p.drawRoundedRect(QRectF(8, 14, 48, 38), 4, 4) + pen.setColor(QColor(240, 163, 86)) + p.setPen(pen) + p.drawPolyline([QPointF(14, 44), QPointF(26, 30), QPointF(34, 38), QPointF(42, 26), QPointF(50, 38)]) + elif kind == "dice": + p.drawRoundedRect(QRectF(10, 10, 44, 44), 9, 9) + p.setBrush(QColor(240, 163, 86)) + p.setPen(Qt.NoPen) + for x, y in ((22, 22), (42, 22), (32, 32), (22, 42), (42, 42)): + p.drawEllipse(QPointF(x, y), 4.5, 4.5) + elif kind == "gallery": + pen.setColor(QColor(240, 163, 86)) + p.setPen(pen) + p.drawPolyline([QPointF(20, 8), QPointF(20, 16), QPointF(12, 34), QPointF(16, 56), QPointF(48, 56), + QPointF(52, 34), QPointF(44, 16), QPointF(44, 8)]) + p.drawLine(QPointF(16, 8), QPointF(48, 8)) + elif kind in ("undo", "redo"): + sx = 1 if kind == "undo" else -1 + cx = 32 + p.drawArc(QRectF(14, 18, 36, 32), (0 if sx > 0 else 180) * 16, (180 if sx > 0 else -180) * 16) + tip = QPointF(cx - sx * 18, 34) + p.drawPolyline([QPointF(tip.x() - sx * 7, 25), tip, QPointF(tip.x() + sx * 9, 28)]) + elif kind == "coffee": + p.drawRoundedRect(QRectF(14, 26, 30, 26), 5, 5) # cup + p.drawArc(QRectF(36, 30, 16, 16), -90 * 16, 180 * 16) # handle, ends on the cup + pen.setColor(QColor(240, 163, 86)) + p.setPen(pen) + for x in (21, 29, 37): # steam + p.drawLine(QPointF(x, 10), QPointF(x, 20)) + p.end() + return QIcon(pm) + + +def make_app_icon() -> QIcon: + here = os.path.dirname(os.path.abspath(__file__)) + for cand in (os.path.join(here, "..", "assets", "icon.png"), + os.path.join(getattr(sys, "_MEIPASS", here), "assets", "icon.png")): + if os.path.exists(cand): + return QIcon(cand) + return QIcon() + + +class FunctionHelp(QDialog): + """What a formula can use, with examples to try.""" + + def __init__(self, parent, insert): + super().__init__(parent) + self.setWindowTitle("Your own function") + self.resize(640, 560) + text = QTextBrowser() + text.setHtml(function_help()) + box = QVBoxLayout(self) + box.addWidget(text) + row = QHBoxLayout() + row.addWidget(QLabel("Try an example:")) + pick = ComboBox() + for name, formula in EXAMPLES: + pick.addItem(name, formula) + row.addWidget(pick, 1) + use = QPushButton("Use it") + use.clicked.connect(lambda: (insert(pick.currentData()), self.accept())) + row.addWidget(use) + box.addLayout(row) + buttons = QDialogButtonBox(QDialogButtonBox.Close) + buttons.rejected.connect(self.reject) + box.addWidget(buttons) + + +class PrintSteps(QDialog): + """After Save for printing: where the file is, and plain steps to print it - the vase's, or the steps + given (a lid's, say, as steps(where)).""" + open_slicer = Signal() + + def __init__(self, parent, d, path, parts: int, slicer, steps=None): + super().__init__(parent) + self.setWindowTitle("How to print it") + where = slicers.slicer_name(slicer) if slicer else "your slicer" + text = QLabel(steps(where) if steps else print_steps(d, path, parts, where)) + text.setWordWrap(True) + text.setTextInteractionFlags(Qt.TextSelectableByMouse) + box = QVBoxLayout(self) + box.addWidget(text) + row = QHBoxLayout() + if slicer: + go = QPushButton(f"Open in {where}") + go.setObjectName("saveButton") + go.setMinimumHeight(36) + go.clicked.connect(lambda: (self.open_slicer.emit(), self.accept())) + row.addWidget(go) + show = QPushButton("Show the file") + show.clicked.connect(lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(os.path.dirname(path)))) + row.addWidget(show) + done = QPushButton("Close") + done.clicked.connect(self.accept) + row.addWidget(done) + box.addLayout(row) + self.resize(560, 320) + + +class MainWindow(QMainWindow): + def __init__(self, settings: QSettings | None = None): + super().__init__() + self.settings = settings or QSettings("Vaseinator", "Vaseinator") + if self.settings.value("big_text", False, type=bool) or self.settings.value("light_theme", False, type=bool): + apply_theme(QApplication.instance(), light=self.settings.value("light_theme", False, type=bool), + scale=1.3 if self.settings.value("big_text", False, type=bool) else 1.0) + self.simple_mode = self.settings.value("simple_mode", True, type=bool) + self.params = self._fresh_params() + try: + saved = self.settings.value("workspace", "") + self.workspace = Workspace(json.loads(saved) if saved else None) + except (ValueError, TypeError): + self.settings.setValue("workspace_unreadable_backup", saved) + self.workspace = Workspace() + self.design: VaseDesign | None = None + self.current_file = None + self.view = VASE + self._free_camera = False + self._fitted = None + self._dirty = False + self.widgets = {} + self.labels = {} + self.outline_menus = {} # Start from, for each drawn cross-section + self.close_up_buttons = {} + self.outline_windows = {} # the cross-section editors opened full window + self.slider_notes = {} + self.history: list[dict] = [] + self.future: list[dict] = [] + self.spin_angle = 0.0 + self.spinning = self.settings.value("spin", True, type=bool) + self.printing = None # the print animation: its start time + self._last_full_ms = 0.0 + self._note = "" # said with the next "Built in ..." message + self._last_tick = time.perf_counter() + + self.regen_timer = QTimer(self) + self.regen_timer.setSingleShot(True) + self.regen_timer.timeout.connect(self.regenerate) + self.draft_timer = QTimer(self) + self.draft_timer.setSingleShot(True) + self.draft_timer.timeout.connect(lambda: self.regenerate(draft=True)) + self.anim_timer = QTimer(self) + self.anim_timer.setInterval(16) + self.anim_timer.timeout.connect(self._anim_tick) + + self.setWindowIcon(make_app_icon()) + self._build_ui() + self._build_actions() + self.resize(1480, 940) + geo = self.settings.value("geometry") + if geo is not None: + self.restoreGeometry(geo) + self.regenerate() + self._update_anim() + + # ================================================================== UI + def _build_ui(self): + panel = QWidget() + pl = QVBoxLayout(panel) + pl.setContentsMargins(8, 8, 8, 8) + pl.setSpacing(8) + + mode_row = QHBoxLayout() + mode_row.setSpacing(0) + self.simple_btn = QPushButton("Simple") + self.detail_btn = QPushButton("Advanced") + self.simple_btn.setToolTip("A few choices made from pictures: size, shape, twist, pattern and colour.") + self.detail_btn.setToolTip("Every setting, with curves to draw how things change up the vase.") + for b in (self.simple_btn, self.detail_btn): + b.setCheckable(True) + b.setAutoExclusive(True) + b.setMinimumHeight(28) + b.setObjectName("modeButton") + mode_row.addWidget(b) + (self.simple_btn if self.simple_mode else self.detail_btn).setChecked(True) + self.simple_btn.clicked.connect(lambda: self.set_simple_mode(True)) + self.detail_btn.clicked.connect(lambda: self.set_simple_mode(False)) + + name_row = QHBoxLayout() + name_row.addWidget(QLabel("Name")) + self.name_edit = QLineEdit(self.params.name) + self.name_edit.setToolTip("Design name - used for exported file names.") + self.name_edit.textChanged.connect(self._on_name) + name_row.addWidget(self.name_edit) + dice = self.dice_btn = QToolButton() + dice.setIcon(glyph_icon("dice")) + dice.setToolTip("Surprise me: a new random vase that still prints (Ctrl+R)") + dice.clicked.connect(self.surprise_me) + name_row.addWidget(dice) + pl.addLayout(name_row) + + self._rows = {} + self._groups = [] + for title, items in SPEC: + box = QGroupBox(title) + form = QFormLayout(box) + form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) + form.setContentsMargins(8, 12, 8, 8) + form.setHorizontalSpacing(10) + form.setVerticalSpacing(5) + for key, label, kind, lo, hi, step, extra, tip in items: + w = self._make_widget(key, kind, lo, hi, step, extra) + if tip: + w.setToolTip(tip) + if label: + lab = QLabel(label) + if tip: + lab.setToolTip(tip) + self.labels[key] = lab + form.addRow(lab, w) + else: + form.addRow(w) + self._rows[key] = (form, w) + self._groups.append((box, [it[0] for it in items])) + pl.addWidget(box) + pl.addStretch(1) + + scroll = QScrollArea() + scroll.setWidget(panel) + scroll.setWidgetResizable(True) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + # Simple: a few choices made from pictures; Advanced: every setting + self.simple = SimplePanel() + self.simple.param.connect(lambda k, v: self._on_param(k, v)) + self.simple.live.connect(lambda k, v: self._on_param(k, v, live=True)) + self.simple.changes.connect(self.apply_changes) + self.simple.choose_design.connect(self.choose_design) + self.simple.surprise.connect(lambda: self.surprise_me()) + self.simple.draw_own.connect(self.draw_own_shape) + self.simple.save.connect(self.save_for_printing) + self.simple.save_lid.connect(self.save_lid) + self.simple.advanced.connect(lambda: self.set_simple_mode(False)) + simple_scroll = QScrollArea() + simple_scroll.setWidget(self.simple) + simple_scroll.setWidgetResizable(True) + simple_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.panels = QStackedWidget() + self.panels.addWidget(simple_scroll) + self.panels.addWidget(scroll) + self.panels.setCurrentIndex(0 if self.simple_mode else 1) + left = QWidget() + lv = QVBoxLayout(left) + lv.setContentsMargins(8, 8, 0, 0) + lv.setSpacing(6) + lv.addLayout(mode_row) + lv.addWidget(self.panels, 1) + left.setMinimumWidth(370) + left.setMaximumWidth(480) + + self.viewer = Viewer3D() + self.viewer.dragged.connect(self._on_dragged) + self.viewer.reset_requested.connect(lambda: self.show_view(self.view)) + self.viewer.resized.connect(self._on_resized) + self.plots = ShapePlots() + self.info = QTextBrowser() + self.info.setOpenExternalLinks(False) + self.info.setMinimumWidth(280) + + bottom = QSplitter(Qt.Horizontal) + bottom.addWidget(self.plots) + bottom.addWidget(self.info) + bottom.setStretchFactor(0, 3) + bottom.setStretchFactor(1, 2) + bottom.setSizes([560, 500]) + + right = QSplitter(Qt.Vertical) + right.addWidget(self._make_view_bar()) + right.addWidget(self.viewer) + right.addWidget(bottom) + right.setStretchFactor(1, 3) + right.setStretchFactor(2, 2) + right.setSizes([36, 560, 300]) + right.setCollapsible(0, False) + right.handle(1).setEnabled(False) + + main = QSplitter(Qt.Horizontal) + main.addWidget(left) + main.addWidget(right) + main.setStretchFactor(1, 1) + main.setSizes([380, 1100]) + self.setCentralWidget(main) + self.statusBar().showMessage("Ready") + self._refresh_widgets() + + def _make_view_bar(self): + bar = QWidget() + bar.setMaximumHeight(40) + h = QHBoxLayout(bar) + h.setContentsMargins(6, 4, 6, 4) + h.addWidget(QLabel("Show")) + row = QHBoxLayout() + row.setSpacing(0) + self.view_btns = [] + tips = ("The vase as it will look (Ctrl+1)", + "Cut in half through the middle: the single printed wall and the solid bottom (Ctrl+2)", + "How far each part of the wall leans: green is easy, orange near the limit, red needs " + "support (Ctrl+3)", + "On the printer bed, inside the space the printer can reach (Ctrl+4)") + for idx, text in enumerate(VIEW_NAMES): + b = QPushButton(text) + b.setCheckable(True) + b.setAutoExclusive(True) + b.setMinimumHeight(28) + b.setObjectName("modeButton") + b.setToolTip(tips[idx]) + b.clicked.connect(lambda _=False, i=idx: self.show_view(i)) + row.addWidget(b) + self.view_btns.append(b) + self.view_btns[self.view].setChecked(True) + h.addLayout(row) + h.addSpacing(10) + self.spin_btn = QPushButton("⟳ Spin") + self.spin_btn.setCheckable(True) + self.spin_btn.setChecked(self.spinning) + self.spin_btn.setObjectName("modeButton") + self.spin_btn.setMinimumHeight(28) + self.spin_btn.setToolTip("Turn the vase slowly on a turntable (S)") + self.spin_btn.toggled.connect(self.set_spin) + h.addWidget(self.spin_btn) + self.print_btn = QPushButton("▶ Watch it print") + self.print_btn.setCheckable(True) + self.print_btn.setObjectName("modeButton") + self.print_btn.setMinimumHeight(28) + self.print_btn.setToolTip("Build the vase up as the printer would, keeping to the real print time " + "(wide layers take longer) (F5)") + self.print_btn.toggled.connect(self.set_printing) + h.addWidget(self.print_btn) + h.addSpacing(10) + self.hint = QLabel("Drag to turn · wheel to zoom") + self.hint.setStyleSheet("color: #9aa3ad;") + h.addWidget(self.hint) + h.addStretch(1) + fit = QToolButton() + fit.setText("Reset view") + fit.setToolTip("Back to the chosen view (double-click the 3D view, or press F)") + fit.clicked.connect(lambda: self.show_view(self.view)) + h.addWidget(fit) + return bar + + def _make_widget(self, key, kind, lo, hi, step, extra): + def row(*parts): + box = QWidget() + hl = QHBoxLayout(box) + hl.setContentsMargins(0, 0, 0, 0) + hl.setSpacing(6) + for part in parts: + hl.addWidget(part, 0 if isinstance(part, QToolButton) else 1) + return box + + if kind == "widest": + s = DoubleSpinBox() + s.setDecimals(1) + s.setRange(lo, hi) + s.setSingleStep(step) + s.setSuffix(extra) + s.setKeyboardTracking(False) + s.setValue(self._widest()) + s.valueChanged.connect(lambda v: self._on_param("widest", float(v))) + self.widgets[key] = s + return s + if kind == "head": # not a setting of its own: it scales the design to fit + s = DoubleSpinBox() + s.setDecimals(1) + s.setRange(lo, hi) + s.setSingleStep(step) + s.setSuffix(extra) + s.setKeyboardTracking(False) + s.setValue(57.0) + s.valueChanged.connect(lambda v: self._on_param("head", float(v))) + self.widgets[key] = s + return s + if kind == "palette": + b = QToolButton() + b.setText("Choose a palette") + b.setPopupMode(QToolButton.InstantPopup) + b.setToolButtonStyle(Qt.ToolButtonTextOnly) + m = QMenu(b) + for name, cols in PALETTES: + a = m.addAction(palette_icon(cols), name) + a.triggered.connect(lambda _=False, n=name: self._apply_palette(n)) + b.setMenu(m) + b.setMinimumHeight(24) + self.widgets[key] = b + return b + if kind == "outline": # a drawn cross-section, and where to start one from + k = key[-1] + box = QWidget() + vl = QVBoxLayout(box) + vl.setContentsMargins(0, 0, 0, 0) + vl.setSpacing(4) + ed = OutlineEditor() + ed.set_outline(getattr(self.params, key), getattr(self.params, f"repeat{k}")) + ed.changed.connect(lambda pts, kk=int(k), e=ed: self._outline_edited(kk, pts, e, live=True)) + ed.finished.connect(lambda pts, kk=int(k), e=ed: self._outline_edited(kk, pts, e)) + start = QToolButton() + start.setText("Start from") + start.setPopupMode(QToolButton.InstantPopup) + m = QMenu(start) + for name, pts, repeat in sections.OUTLINES: + m.addAction(name).triggered.connect( + lambda _=False, kk=k, pp=pts, rr=repeat: self._use_outline(kk, pp, rr)) + m.addSeparator() + m.addAction("Trace a picture…").triggered.connect(lambda _=False, kk=k: self._trace_outline(kk)) + start.setMenu(m) + start.setToolTip("Start again from a ready-made outline, or trace one from a picture: dark lines " + "drawn round a shape on a light or see-through ground.") + close = QToolButton() + close.setText("Close up") + close.setCheckable(True) + close.setToolTip("Show one repeat close up, turned to point upwards - or the whole outline.\n" + f"Above {CLOSE_UP_FROM} repeats it starts close up.") + close.toggled.connect(ed.set_close_up) + ed.view_changed.connect(lambda kk=int(k): self._show_close_up(kk)) + big = QToolButton() + big.setText("Full window…") + big.setToolTip("Draw it in a window of its own, as big as the screen: the wheel zooms, a right-drag " + "moves it.") + big.clicked.connect(lambda _=False, kk=int(k): self._open_outline_window(kk)) + self.outline_menus[int(k)] = m + self.close_up_buttons[int(k)] = close + buttons = QHBoxLayout() + buttons.setContentsMargins(0, 0, 0, 0) + buttons.addWidget(start) + buttons.addWidget(close) + buttons.addWidget(big) + buttons.addStretch(1) + vl.addWidget(ed) + vl.addLayout(buttons) + self.widgets[key] = ed + return box + val = getattr(self.params, key) + if kind == "float": + suffix, _, special = extra.partition("|") + s = DoubleSpinBox() + s.setDecimals(2) + s.setRange(lo, hi) + s.setSingleStep(step) + s.setSuffix(suffix) + if special: + s.setSpecialValueText(special) + s.setValue(val) + s.setKeyboardTracking(False) + s.valueChanged.connect(lambda v, k=key: self._on_param(k, float(v))) + s.setMinimumWidth(110) + w = s + elif kind == "int" or kind == "seed": + suffix, _, special = extra.partition("|") + s = SpinBox() + s.setRange(int(lo), int(hi)) + s.setSingleStep(int(step)) + s.setSuffix(suffix) + if special: + s.setSpecialValueText(special) + s.setValue(int(val)) + s.setKeyboardTracking(False) + s.valueChanged.connect(lambda v, k=key: self._on_param(k, int(v))) + self.widgets[key] = s + if kind == "seed": + roll = QToolButton() + roll.setIcon(glyph_icon("dice")) + roll.setToolTip("Another one") + roll.clicked.connect(lambda _=False, ss=s: ss.setValue(random.randint(0, 99999))) + return row(s, roll) + return s + elif kind == "pct": + s = DoubleSpinBox() + s.setDecimals(0) + s.setRange(lo, hi) + s.setSingleStep(step) + s.setSuffix(extra) + s.setValue(100.0 * val) + s.setKeyboardTracking(False) + s.valueChanged.connect(lambda v, k=key: self._on_param(k, float(v) / 100.0)) + w = s + elif kind == "bool": + w = QCheckBox() + w.setChecked(val) + w.toggled.connect(lambda v, k=key: self._on_param(k, bool(v))) + elif kind == "choice": + w = ComboBox() + for value, label in lo: + w.addItem(label, value) + w.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon) + w.setMinimumContentsLength(12) + w.setCurrentIndex(max(0, w.findData(val))) + w.currentIndexChanged.connect(lambda i, k=key, ww=w: self._on_param(k, ww.itemData(i))) + elif kind == "color": + w = QPushButton() + w.setMinimumHeight(24) + w.clicked.connect(lambda _=False, k=key: self._pick_color(k)) + elif kind == "slider": + box = QWidget() + vl = QVBoxLayout(box) + vl.setContentsMargins(0, 0, 0, 0) + vl.setSpacing(1) + s = Slider(Qt.Horizontal) + s.setRange(int(round(lo * 100)), int(round(hi * 100))) + s.setPageStep(10) + s.setTickInterval(100) + s.setTickPosition(QSlider.TicksBelow) + s.setValue(int(round(val * 100))) + s.valueChanged.connect(lambda v, k=key: self._on_param(k, v / 100.0, live=True)) + s.sliderReleased.connect(lambda: self._on_param(key, s.value() / 100.0)) + self.slider_notes[key] = QLabel() + self.slider_notes[key].setStyleSheet("color: #9aa3ad;") + vl.addWidget(s) + vl.addWidget(self.slider_notes[key]) + self.widgets[key] = s + return box + elif kind == "curve": + w = CurveEditor(**lo) + w.set_points(val) + w.changed.connect(lambda pts, k=key: self._on_param(k, pts, live=True)) + w.finished.connect(lambda pts, k=key: self._on_param(k, pts)) + elif kind == "expr": + box = QWidget() + vl = QVBoxLayout(box) + vl.setContentsMargins(0, 0, 0, 0) + vl.setSpacing(4) + t = QPlainTextEdit(val) + mono = QFontDatabase.systemFont(QFontDatabase.FixedFont) + t.setFont(mono) + t.setPlaceholderText("e.g. 1.5 * sin(6*theta + 4*pi*h)") + t.setTabChangesFocus(True) + t.setFixedHeight(54) + t.textChanged.connect(lambda k=key, tt=t: self._on_param(k, tt.toPlainText())) + self.widgets[key] = t + ex = QToolButton() + ex.setText("Examples") + ex.setPopupMode(QToolButton.InstantPopup) + m = QMenu(ex) + for name, formula in EXAMPLES: + a = m.addAction(f"{name}: {formula}") + a.triggered.connect(lambda _=False, f=formula: self._set_expression(f)) + m.addSeparator() + m.addAction("Clear").triggered.connect(lambda: self._set_expression("")) + ex.setMenu(m) + helpb = QToolButton() + helpb.setText("Help…") + helpb.clicked.connect(self.function_help) + buttons = QHBoxLayout() + buttons.setContentsMargins(0, 0, 0, 0) + buttons.addWidget(ex) + buttons.addWidget(helpb) + buttons.addStretch(1) + vl.addWidget(t) + vl.addLayout(buttons) + return box + else: + raise ValueError(kind) + self.widgets[key] = w + return w + + def _widest(self) -> float: + return max(d for _, d in self.params.silhouette) + + def _refresh_widgets(self): + """Push self.params into the widgets without triggering updates.""" + for key, w in self.widgets.items(): + w.blockSignals(True) + if key == "widest": + w.setValue(self._widest()) + elif key in ("palette", "head"): # the head size follows the design once it is built + pass + elif isinstance(w, QComboBox): + w.setCurrentIndex(max(0, w.findData(getattr(self.params, key)))) + elif isinstance(w, QCheckBox): + w.setChecked(bool(getattr(self.params, key))) + elif isinstance(w, QPlainTextEdit): + if w.toPlainText() != getattr(self.params, key): + w.setPlainText(getattr(self.params, key)) + elif isinstance(w, QSpinBox): + w.setValue(int(getattr(self.params, key))) + elif isinstance(w, QDoubleSpinBox): + v = getattr(self.params, key) + w.setValue(100.0 * v if w.suffix() == " %" else v) + elif isinstance(w, QSlider): + w.setValue(int(round(getattr(self.params, key) * 100))) + elif isinstance(w, CurveEditor): + w.set_points(getattr(self.params, key)) + elif isinstance(w, OutlineEditor): + w.set_outline(getattr(self.params, key), getattr(self.params, f"repeat{key[-1]}")) + w.blockSignals(False) + for k in (1, 2): + self._push_outline(k) + self.name_edit.blockSignals(True) + self.name_edit.setText(self.params.name) + self.name_edit.blockSignals(False) + self._update_title() + self._sync_widgets() + + def _fresh_params(self) -> VaseParams: + """Default design, using the printer settings remembered from last time.""" + p = VaseParams() + # a first run borrows the printer from Maze Tube Designer or Lenticular Sign Maker + others = (QSettings("MazeTube", "MazeTubeDesigner"), QSettings("LenticularSign", "LenticularSignMaker")) + for key in REMEMBERED: + for v in (self.settings.value(f"default_{key}"),) + tuple(o.value(f"default_{key}") for o in others): + try: + if v is not None: + setattr(p, key, float(v)) + break + except (TypeError, ValueError): + pass + return p + + def _sync_widgets(self): + """Colour swatches, labels and greyed-out settings that follow other settings.""" + p = self.params + for k in FILAMENTS: + color = getattr(p, f"color{k}") + btn = self.widgets[f"color{k}"] + dark = QColor(color).lightnessF() < 0.55 + btn.setText(color.upper()) + btn.setStyleSheet(f"background: {color}; color: {'white' if dark else 'black'}; " + f"border: 1px solid #3b4049; border-radius: 3px;") + for k in (1, 2): + kind = getattr(p, f"shape{k}") + names = SHAPE_LABELS.get(kind, (None, None, None)) + for key, name in zip((f"count{k}", f"depth{k}", f"sharp{k}"), names): + if name: + self.labels[key].setText(name) + self.widgets[f"count{k}"].setMinimum(3 if kind == POLYGON else 1) + # a real spiral vase prints one filament: only the colour styles that need no change of filament + real = p.vase_mode == REAL_VASE + styles = REAL_COLOUR_STYLES if real else COLOUR_STYLES + combo = self.widgets["colour_style"] + combo.blockSignals(True) + if [(combo.itemData(i), combo.itemText(i)) for i in range(combo.count())] != styles: + combo.clear() + for value, text in styles: + combo.addItem(text, value) + combo.setCurrentIndex(max(0, combo.findData(colour_style_of(p)))) + combo.blockSignals(False) + self.labels["color1"].setText("Colour" if colour_style_of(p) == ONE_COLOUR else "Filament 1") + s = p.band_spacing + self.slider_notes["band_spacing"].setText( + "Even" if abs(s) < 0.05 else ("Thicker towards the top" if s > 0 else "Thicker towards the bottom")) + for k, win in self.outline_windows.items(): # a shape no longer drawn has nothing to draw + if win.isVisible() and getattr(p, f"shape{k}") != DRAWN: + win.close() + if hasattr(self, "simple"): + titles = {slug(t): t for t, _, _ in GALLERY} + titles["plain_cylinder"] = "Plain cylinder" + self.simple.refresh(p, self._widest(), titles.get(p.name, "")) + if hasattr(self, "a_lid"): + self.a_lid.setEnabled(p.box) + self._apply_mode() + + def _wanted(self, key) -> bool: + return wanted(self.params, key) + + def _apply_mode(self): + for key, (form, field) in self._rows.items(): + form.setRowVisible(field, self._wanted(key)) + for box, keys in self._groups: + box.setVisible(any(self._wanted(k) for k in keys)) + + def set_simple_mode(self, simple: bool, remember: bool = True): + self.simple_mode = simple + (self.simple_btn if simple else self.detail_btn).setChecked(True) + self.panels.setCurrentIndex(0 if simple else 1) + if remember: + self.settings.setValue("simple_mode", simple) + self._apply_mode() + + def _pick_color(self, key): + c = QColorDialog.getColor(QColor(getattr(self.params, key)), self, "Filament colour") + if c.isValid(): + self._on_param(key, c.name().upper()) + + def _apply_palette(self, name): + for k, v in palette(name).items(): + setattr(self.params, k, v) + self._on_param("color1", self.params.color1) + + def _set_expression(self, formula): + self.widgets["expression"].setPlainText(formula) # its textChanged updates the design + + def function_help(self): + FunctionHelp(self, self._set_expression).exec() + + def _build_actions(self): + st = self.style() + tb = self.addToolBar("Main") + tb.setMovable(False) + tb.setIconSize(QSize(20, 20)) + tb.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + + def act(text, slot, icon=None, shortcut=None, tip=None): + a = QAction(text, self) + if isinstance(icon, QIcon): + a.setIcon(icon) + elif icon is not None: + a.setIcon(st.standardIcon(icon)) + if shortcut: + a.setShortcut(QKeySequence(shortcut)) + if tip: + a.setToolTip(tip) + a.setStatusTip(tip) + a.triggered.connect(slot) + return a + + self.a_new = act("New", self.new_design, QStyle.SP_FileIcon, "Ctrl+N", "Start again from the default vase") + self.a_plain = act("New plain cylinder", self.use_plain, None, "Ctrl+Shift+N", + "Start from a plain cylinder, 200 mm across and 200 mm tall, with everything else off - " + "ready for your own function or a drawn cross-section") + self.a_open = act("Open…", self.open_design, QStyle.SP_DialogOpenButton, "Ctrl+O", "Open a saved vase design") + self.a_save = act("Save", self.save_design, QStyle.SP_DialogSaveButton, "Ctrl+S", "Save the design") + self.a_save_as = act("Save As…", lambda: self.save_design(as_new=True), None, "Ctrl+Shift+S") + self.a_exp_stl = act("Export STL…", self.export_stl, glyph_icon("export"), "Ctrl+E", + "The vase as one solid, watertight STL, for spiral vase mode") + self.a_exp_3mf = act("Export 3MF…", self.export_3mf, None, "Ctrl+Shift+E", + "The vase as a 3MF, in the middle of the bed, set to its first filament") + self.a_snap = act("Save picture…", self.save_snapshot, glyph_icon("picture"), "Ctrl+P", + "Save the 3D view as a PNG image") + self.a_print = act("Save for printing…", self.save_for_printing, glyph_icon("export"), "Ctrl+Shift+P", + "Save the vase for your printer's slicer, with how to print it") + self.a_lid = act("Save the lid for printing…", self.save_lid, None, None, + "A tissue box cover's lid for its open end, to print on its own: it hooks in at one end " + "and clicks in at the other") + self.a_slicer = act("Open in slicer", lambda: self.open_in_slicer(), None, "Ctrl+L", + "Open the vase saved for printing in your slicer") + self.a_pick_slicer = act("Choose slicer program…", lambda: self.choose_slicer(), None, None, + "Which program Open in slicer uses") + self.a_gallery = act("Choose a design…", self.choose_design, glyph_icon("gallery"), "Ctrl+G", + "Pictures of every ready-made vase, and a plain cylinder to start from") + self.a_big = act("Bigger text", lambda on: self._set_look(big=on), None, "Ctrl+=", + "Make all the writing bigger") + self.a_light = act("Light theme", lambda on: self._set_look(light=on), None, None, + "Dark writing on a light background") + for a, key in ((self.a_big, "big_text"), (self.a_light, "light_theme")): + a.setCheckable(True) + a.setChecked(self.settings.value(key, False, type=bool)) + self.a_quit = act("Exit", self.close, None, "Ctrl+Q") + self.a_undo = act("Undo", self.undo, glyph_icon("undo"), "Ctrl+Z", "Undo the last change") + self.a_redo = act("Redo", self.redo, glyph_icon("redo"), "Ctrl+Y", "Redo") + self.a_redo.setShortcuts([QKeySequence("Ctrl+Y"), QKeySequence("Ctrl+Shift+Z")]) + self.a_surprise = act("Surprise me", self.surprise_me, glyph_icon("dice"), "Ctrl+R", + "A new random vase that still prints without supports") + self.a_copy_plan = act("Copy colour changes", self.copy_colour_plan, None, "Ctrl+Shift+C", + "Copy the list of filament changes, to keep beside the slicer") + self.a_about = act("About", self.about) + self.a_printing = act("Printing it watertight…", self.printing_help) + self.a_function = act("Your own function…", self.function_help) + self.a_drawing = act("Drawing a cross-section…", self.drawing_help) + + tb.addAction(self.a_new) + tb.addAction(self.a_open) + tb.addAction(self.a_save) + tb.addSeparator() + tb.addAction(self.a_print) + exp_btn = QToolButton() + exp_btn.setDefaultAction(self.a_exp_stl) + exp_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + exp_btn.setPopupMode(QToolButton.MenuButtonPopup) + m = QMenu(exp_btn) + m.addAction(self.a_exp_stl) + m.addAction(self.a_exp_3mf) + m.addSeparator() + m.addAction(self.a_lid) + exp_btn.setMenu(m) + tb.addWidget(exp_btn) + tb.addAction(self.a_snap) + tb.addSeparator() + tb.addAction(self.a_surprise) + gal_btn = self.gal_btn = QToolButton() + gal_btn.setText("Gallery") + gal_btn.setIcon(glyph_icon("gallery")) + gal_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + gal_btn.setToolTip("Pictures of every ready-made vase to start from (Ctrl+G)") + gal_btn.clicked.connect(self.choose_design) + self.gallery_menu = QMenu(self) + self.gallery_menu.addAction(self.a_gallery) + self.gallery_menu.addSeparator() + self.gallery_menu.addAction(self.a_plain) + self.gallery_menu.addSeparator() + for title, about, _ in GALLERY: + a = self.gallery_menu.addAction(title) + a.setToolTip(about) + a.setStatusTip(about) + a.triggered.connect(lambda _=False, t=title: self.use_gallery(t)) + self.gallery_menu.setToolTipsVisible(True) + tb.addWidget(gal_btn) + tb.addSeparator() + tb.addAction(self.a_undo) + tb.addAction(self.a_redo) + + mb = self.menuBar() + fm = mb.addMenu("&File") + for a in (self.a_new, self.a_plain, self.a_open, self.a_save, self.a_save_as, None, self.a_print, + self.a_lid, self.a_slicer, self.a_pick_slicer, None, self.a_exp_stl, self.a_exp_3mf, self.a_snap, None, + self.a_quit): + fm.addAction(a) if a is not None else fm.addSeparator() + em = mb.addMenu("&Edit") + for a in (self.a_undo, self.a_redo, None, self.a_surprise, None, self.a_copy_plan): + em.addAction(a) if a is not None else em.addSeparator() + dm = mb.addMenu("&Designs") + dm.addActions(self.gallery_menu.actions()) + dm.addSeparator() + dm.addAction(act("Library, profiles and Surprise locks…", lambda: DesignTools(self).exec())) + fm.addAction(act("Save clearance samples…", lambda: save_calibration(self))) + vm = mb.addMenu("&View") + self.view_actions = [] + for i, name in enumerate(VIEW_NAMES): + a = QAction(name, self) + a.setShortcut(QKeySequence(f"Ctrl+{i + 1}")) + a.triggered.connect(lambda _=False, i=i: self.show_view(i)) + vm.addAction(a) + self.view_actions.append(a) + vm.addSeparator() + a = QAction("Spin", self) + a.setShortcut(QKeySequence("S")) + a.triggered.connect(self.spin_btn.toggle) + vm.addAction(a) + a = QAction("Watch it print", self) + a.setShortcut(QKeySequence("F5")) + a.triggered.connect(self.print_btn.toggle) + vm.addAction(a) + a = QAction("Reset view", self) + a.setShortcut(QKeySequence("Ctrl+0")) + a.triggered.connect(lambda: self.show_view(self.view)) + vm.addAction(a) + vm.addSeparator() + vm.addAction(act("Compare original and softened…", lambda: show_comparison(self))) + vm.addAction(self.a_big) + vm.addAction(self.a_light) + hm = mb.addMenu("&Help") + hm.addAction(self.a_printing) + hm.addAction(self.a_function) + hm.addAction(self.a_drawing) + hm.addSeparator() + hm.addAction(self.a_about) + + # A coffee cup after Help, for anyone who would like to chip in. + self.a_kofi = QAction(glyph_icon("coffee"), "", self) + self.a_kofi.setToolTip("Vaseinator is free — buy me a coffee") + self.a_kofi.setStatusTip("Vaseinator is free — buy me a coffee") + self.a_kofi.triggered.connect(lambda: QDesktopServices.openUrl(QUrl(KOFI_URL))) + mb.addAction(self.a_kofi) + self._update_undo() + + # ============================================================ params + def _on_param(self, key, value, live=False): + if key == "head": + self._set_head(value) + return + touched, message = apply_change(self.params, key, value) + for k in touched: + self._set_widget(k) + if message: + self.statusBar().showMessage(message, 8000) + if key in REMEMBERED: + self.settings.setValue(f"default_{key}", float(value)) + self._sync_widgets() + self._dirty = True + self._update_title() + d = self.design + if key in ("lid_grip", "lid_open") and d is not None and not d.draft and not self.regen_timer.isActive(): + for q in (d.p, d.asked): # the lid's own: the cover itself stays as it is + setattr(q, key, getattr(self.params, key)) + self._remember() + return + self._schedule(live=live, text=key in TEXT_KEYS) + + def _set_head(self, cm: float): + """Scale the design to fit a head ``cm`` round.""" + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + fitted = fit_head(self.params, 10.0 * cm) + finally: + QApplication.restoreOverrideCursor() + self.apply_changes({k: getattr(fitted, k) for k in ("silhouette",) + HEAD_SCALED}) + self.statusBar().showMessage(f"Sized for a head {cm:g} cm round — Ctrl+Z to go back", 6000) + + def _show_head(self, d: VaseDesign): + """The head the design fits, in the head size boxes.""" + cm = round(d.head_fit / 10.0, 1) + w = self.widgets["head"] + w.blockSignals(True) + w.setValue(cm) + w.blockSignals(False) + self.simple.show_head(cm) + + def _set_widget(self, key): + w = self.widgets[key] + w.blockSignals(True) + if key == "widest": + w.setValue(self._widest()) + elif isinstance(w, CurveEditor): + w.set_points(getattr(self.params, key)) + elif isinstance(w, OutlineEditor): + w.blockSignals(False) + self._push_outline(int(key[-1])) + elif isinstance(w, QComboBox): + w.setCurrentIndex(max(0, w.findData(getattr(self.params, key)))) + elif isinstance(w, QSpinBox): + w.setValue(int(getattr(self.params, key))) + if key in ("repeat1", "repeat2"): + self._push_outline(int(key[-1])) + elif isinstance(w, QDoubleSpinBox): + v = getattr(self.params, key) + w.setValue(100.0 * v if w.suffix() == " %" else v) + elif isinstance(w, QCheckBox): + w.setChecked(bool(getattr(self.params, key))) + w.blockSignals(False) + + # ----------------------------------------------------- drawn outlines + def _outline_edited(self, k: int, pts, source, live=False): + """An editor changed drawn outline k: the vase follows, and so does the other editor.""" + self._on_param(f"drawn{k}", pts, live=live) + self._push_outline(k, source) + + def _push_outline(self, k: int, source=None): + """Show drawn outline k in its editors - the one in the panel and any full window - but the one it came + from.""" + p = self.params + pts, repeat = getattr(p, f"drawn{k}"), getattr(p, f"repeat{k}") + ed = self.widgets.get(f"drawn{k}") + if ed is not None and ed is not source: + ed.set_outline(pts, repeat) + win = self.outline_windows.get(k) + if win is not None and win.editor is not source: + win.set_outline(pts, repeat) + + def _show_close_up(self, k: int): + ed, b = self.widgets[f"drawn{k}"], self.close_up_buttons[k] + b.blockSignals(True) + b.setEnabled(ed.repeat > 1) + b.setChecked(ed.close_up()) + b.blockSignals(False) + + def _open_outline_window(self, k: int): + """The cross-section editor in a window of its own, as big as the screen.""" + win = self.outline_windows.get(k) + if win is None: + title = "Draw the cross-section" if k == 1 else "Draw the cross-section it turns into" + win = OutlineWindow(self, f"{title} — {APP_NAME}", self.outline_menus[k]) + win.editor.changed.connect(lambda pts, kk=k, e=win.editor: self._outline_edited(kk, pts, e, live=True)) + win.editor.finished.connect(lambda pts, kk=k, e=win.editor: self._outline_edited(kk, pts, e)) + win.repeat_changed.connect(lambda v, kk=k: self._on_param(f"repeat{kk}", int(v))) + self.outline_windows[k] = win + win.set_outline(getattr(self.params, f"drawn{k}"), getattr(self.params, f"repeat{k}")) + win.showMaximized() + win.raise_() + win.activateWindow() + + def _outline_from_shape(self, k: int): + outline_from_shape(self.params, k) + self._set_widget(f"repeat{k}") + self._set_widget(f"drawn{k}") + + def _use_outline(self, k: int, pts, repeat: int): + """Start drawn outline k again from given control points.""" + setattr(self.params, f"repeat{k}", int(repeat)) + self._set_widget(f"repeat{k}") + self._on_param(f"drawn{k}", [list(q) for q in pts]) + self._set_widget(f"drawn{k}") + + def _trace_outline(self, k: int): + start = self.settings.value("picture_dir", self._last_dir()) + path, _ = QFileDialog.getOpenFileName(QApplication.activeWindow() or self, "Trace a picture", start, + "Pictures (*.png *.jpg *.jpeg *.bmp *.gif *.webp);;All files (*)") + if not path: + return + self.settings.setValue("picture_dir", os.path.dirname(path)) + try: + pts = sections.trace_picture(path) + except (ValueError, OSError) as exc: + QMessageBox.warning(self, "Can't trace that picture", + f"{os.path.basename(path)}: {exc}. Draw the outline as one closed dark line on a " + "light or see-through ground.") + return + self._note = (f"Traced {os.path.basename(path)}: {len(pts)} points, " + f"{sum(q[2] for q in pts)} sharp corners. ") + self._use_outline(k, pts, 1) + + def _schedule(self, live=False, text=False): + """Rebuild after a pause; while something is being dragged, quick rough builds follow the hand.""" + if text: + self.regen_timer.start(600) + return + slow = self._last_full_ms > 220 + if live or slow: + self.draft_timer.start(15) + self.regen_timer.start(450 if live else 300) + else: + self.regen_timer.start(120) + + def _on_name(self, text): + self.params.name = text.strip() or "vase" + self._dirty = True + self.workspace.add("recovery", self.params) + self._save_workspace() + self._update_title() + + def _update_title(self): + f = os.path.basename(self.current_file) if self.current_file else "unsaved" + star = " •" if self._dirty else "" + self.setWindowTitle(f"{APP_NAME} — {self.params.name} ({f}){star}") + + # ======================================================= undo / redo + def _remember(self): + d = self.params.to_dict() + if self.history: + self.workspace.add("recovery", self.params) + self._save_workspace() + if not self.history or self.history[-1] != d: + self.history.append(d) + del self.history[:-200] + self.future.clear() + self._update_undo() + + def _save_workspace(self): + self.settings.setValue("workspace", self.workspace.dumps()) + + def _update_undo(self): + if hasattr(self, "a_undo"): + self.a_undo.setEnabled(len(self.history) > 1) + self.a_redo.setEnabled(bool(self.future)) + + def _restore(self, d): + self.params = VaseParams.from_dict(d) + self._dirty = True + self._refresh_widgets() + self.regenerate() + + def _flush(self): + """Build now if a change is still waiting.""" + if self.regen_timer.isActive() or self.draft_timer.isActive(): + self.regen_timer.stop() + self.draft_timer.stop() + self.regenerate() + + def undo(self): + self._flush() + if len(self.history) < 2: + return + self.future.append(self.history.pop()) + self._restore(self.history[-1]) + self._update_undo() + + def redo(self): + self._flush() + if not self.future: + return + d = self.future.pop() + self.history.append(d) + self._restore(d) + self._update_undo() + + def surprise_me(self, seed=None): + self._flush() + seed = random.randint(1, 999_999) if seed is None or seed is False else int(seed) + self.params = locked_surprise(self.params, seed, self.workspace.data["locks"]) + self._dirty = True + self._refresh_widgets() + self.regenerate() + self.statusBar().showMessage(f"Surprise #{seed}: {self.params.name.replace('_', ' ')} — Ctrl+Z to go " + "back", 8000) + + def apply_changes(self, changes: dict, live=False): + """Several settings at once - a picture picked in Simple mode.""" + p = self.params + for key, value in changes.items(): + setattr(p, key, value) + if "profile" in changes and p.profile != OWN_PROFILE: + p.silhouette = outline(p.profile, self._widest()) + self._dirty = True + self._refresh_widgets() + self._schedule(live=live) + + def choose_design(self): + """Every ready-made vase as a picture to click.""" + def entry(title, about, p, picture): + pts = np.asarray(p.silhouette, dtype=float) + return title, about, asset("gallery", picture), profile_icon(pts, QColor(p.color1)) + entries = [entry("Plain cylinder", "200 mm across and 200 mm tall, with everything else off: a blank " + "start for your own shape or function", plain_design(), "plain_cylinder.png")] + entries += [entry(t, about, gallery_design(t), slug(t) + ".png") for t, about, _ in GALLERY] + dlg = GalleryDialog(self, entries) + if dlg.exec() == QDialog.Accepted and dlg.chosen: + if dlg.chosen == "Plain cylinder": + self.use_plain() + else: + self.use_gallery(dlg.chosen) + + def draw_own_shape(self): + """Draw the shape from above, in a window of its own, starting from the shape it is.""" + if self.params.shape1 != DRAWN: + self._on_param("shape1", DRAWN) + self._flush() + self._open_outline_window(1) + + def _set_look(self, big=None, light=None): + """Bigger text, and the light or dark theme - remembered for next time.""" + if big is not None: + self.settings.setValue("big_text", bool(big)) + if light is not None: + self.settings.setValue("light_theme", bool(light)) + apply_theme(QApplication.instance(), light=self.settings.value("light_theme", False, type=bool), + scale=1.3 if self.settings.value("big_text", False, type=bool) else 1.0) + # the line icons are drawn in the theme's ink: draw them again + for a, kind in ((self.a_exp_stl, "export"), (self.a_print, "export"), (self.a_snap, "picture"), + (self.a_surprise, "dice"), (self.a_undo, "undo"), (self.a_redo, "redo"), + (self.a_gallery, "gallery"), (self.a_kofi, "coffee")): + a.setIcon(glyph_icon(kind)) + self.gal_btn.setIcon(glyph_icon("gallery")) + self.dice_btn.setIcon(glyph_icon("dice")) + + def use_plain(self): + """A blank start: the plain cylinder, keeping the printer settings.""" + self._flush() + self.params = plain_design(keep=self.params) + self._dirty = True + self._refresh_widgets() + self.regenerate() + self.statusBar().showMessage("Plain cylinder, 200 mm across and 200 mm tall - type your own function, or " + "choose Drawn as the cross-section; Ctrl+Z to go back", 12000) + + def use_gallery(self, title): + self._flush() + self.params = gallery_design(title, keep=self.params) + self._dirty = True + self._refresh_widgets() + self.regenerate() + self.statusBar().showMessage(f"{title} — your printer settings are kept; Ctrl+Z to go back", 8000) + + # ============================================================ build + def regenerate(self, draft=False): + if not draft: + self.draft_timer.stop() + t0 = time.perf_counter() + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + d = VaseDesign(self.params.copy(), draft=draft) + except Exception as exc: # pragma: no cover - safety net for the GUI + self._show_info(None, [f"Internal error: {exc}"], []) + return + finally: + QApplication.restoreOverrideCursor() + if not draft: + self._remember() + if d.errors: + self._show_info(None, d.errors, d.warnings) + self.statusBar().showMessage("This vase cannot be built - see the panel on the right") + return + self.design = d + self._show_design(d) + self._show_head(d) + dt = (time.perf_counter() - t0) * 1000 + if not draft: + self._last_full_ms = dt + self._show_info(d, [], d.warnings) + note, self._note = self._note, "" + self.statusBar().showMessage(f"{note}Built in {dt:.0f} ms — {d.triangles:,} triangles", + 10000 if note else 6000) + + def _show_design(self, d: VaseDesign): + colors = d.p.colors() + phase = d.colour_phase(d.shell_verts[:, 2]) if d.gradient else None + self.viewer.set_mesh("vase", d.shell_verts, d.shell_faces, colors[d.bands[0][1]], + face_vals=d.shell_lean, vert_vals=phase, flat=d.rows is not None) + self.viewer.set_section_source(d.shell_verts, d.shell_faces) + self.viewer.set_bed(d.p.bed_x, d.p.bed_y, d.p.printer_height) + self.plots.set_design(d, colors) + self._apply_view() + if self.printing is not None: + self._anim_tick() + + def _show_info(self, d, errors, warnings): + self.info.setHtml(info_html(d, errors, warnings)) + + + # ============================================================ view + def show_view(self, idx): + self.view = idx + self._free_camera = False + self._fitted = None + self.view_btns[idx].setChecked(True) + self._apply_view() + + def _apply_view(self): + d, v = self.design, self.viewer + if d is None: + return + p = d.p + v.show_bed = self.view == PRINT_LAYOUT + v.section = self.view == SECTION + colors = p.colors() + v.bands = [(z, colors[f]) for z, f in d.bands] + v.gradient = [colors[f] for f in d.gradient_fils] + v.colmode = 3 if self.view == OVERHANGS else (2 if d.gradient else 1) + v.limit = p.max_overhang + v.look = p.look + if self.view == OVERHANGS: + self.hint.setText(f"Green: easy · orange: near {p.max_overhang:g}° · red: over it, needs support") + elif self.printing is None: + self.hint.setText("Drag to turn · wheel to zoom · right-drag to move") + size = (round(float(d.size[0]), 0), round(float(d.size[1]), 0), round(d.H, 0)) + if self._fitted != (self.view, size): + r = 0.5 * math.hypot(d.size[0], d.size[1]) + reach = math.hypot(r, 0.5 * d.H) + aspect = max(v.width(), 1) / max(v.height(), 1) + fov = math.radians(v.fov) * min(1.0, aspect) + v.target = np.array([0.0, 0.0, 0.5 * d.H]) + v.dist = reach / math.sin(fov / 2.0) * (1.75 if self.view == PRINT_LAYOUT else 1.12) + self._fitted = (self.view, size) + if not self._free_camera: + v.yaw, v.pitch = {VASE: (-60.0, 14.0), SECTION: (-90.0, 8.0), OVERHANGS: (-60.0, 14.0), + PRINT_LAYOUT: (-55.0, 32.0)}[self.view] + v.set_model(spin_transform(self.spin_angle)) + v.update() + + def _on_dragged(self): + self._free_camera = True + + def _on_resized(self): + if not self._free_camera: + self._fitted = None + self._apply_view() + + # ------------------------------------------------- spin and printing + def set_spin(self, on: bool): + self.spinning = bool(on) + self.settings.setValue("spin", self.spinning) + self._update_anim() + + def set_printing(self, on: bool): + if on and self.design is not None: + self.printing = time.perf_counter() + else: + self.printing = None + self.viewer.cut_z = None + self.viewer.set_lines(None) + self._apply_view() + self._update_anim() + + def _update_anim(self): + if self.spinning or self.printing is not None: + self._last_tick = time.perf_counter() + self.anim_timer.start() + else: + self.anim_timer.stop() + + def _print_length(self): + d = self.design + return float(np.clip(6.0 + d.H / 30.0, 6.0, 16.0)) + + def _anim_tick(self): + now = time.perf_counter() + dt, self._last_tick = now - self._last_tick, now + d, v = self.design, self.viewer + if d is None: + return + if self.spinning: + self.spin_angle = (self.spin_angle + SPIN_SPEED * min(dt, 0.1)) % 360.0 + v.set_model(spin_transform(self.spin_angle)) + if self.printing is not None: + span = self._print_length() + t = (now - self.printing) / span + if t > 1.25: # hold the finished vase a moment, then again + self.printing = now + t = 0.0 + t = min(t, 1.0) + # the real print time: the filament used up to each height, at an even speed + done = d.filament_at[-1] * t + z = float(np.interp(done, d.filament_at, d.filament_z)) if d.filament_at[-1] > 0 else t * d.H + z = min(max(z, 0.05), d.H) + v.cut_z = z if t < 1.0 else None + v.set_lines(d.contour(z) if t < 1.0 else None) + layer = min(int(math.ceil(z / d.p.layer_height)), d.layers) + self.hint.setText(f"Printing: layer {layer} of {d.layers} · {duration(d.seconds * t)} of " + f"{duration(d.seconds)}" if t < 1.0 else f"Done: {duration(d.seconds)}, " + f"{d.grams:.0f} g of filament") + v.update() + + # ============================================================ files + def _last_dir(self): + return self.settings.value("last_dir", os.path.join(os.path.expanduser("~"), "Documents")) + + def _remember_dir(self, path): + self.settings.setValue("last_dir", os.path.dirname(path)) + + def _safe_name(self): + name = "".join(ch if ch.isalnum() or ch in "-_ " else "_" for ch in self.params.name).strip() + return name or "vase" + + def new_design(self): + if not self._confirm_discard(): + return + self.params = self._fresh_params() + self.current_file = None + self._dirty = False + self._refresh_widgets() + self.regenerate() + + def _confirm_discard(self): + if not self._dirty: + return True + r = QMessageBox.question(self, "Unsaved changes", "Discard unsaved changes to the current design?", + QMessageBox.Discard | QMessageBox.Cancel, QMessageBox.Cancel) + return r == QMessageBox.Discard + + def open_design(self): + if not self._confirm_discard(): + return + path, _ = QFileDialog.getOpenFileName(self, "Open design", self._last_dir(), + f"Vaseinator designs (*{FILE_EXT} *.json);;All files (*)") + if path: + self.load_file(path) + + def load_file(self, path): + try: + params = load_design(path) + except Exception as exc: + QMessageBox.critical(self, "Open failed", f"Could not open the design:\n{exc}") + return + self._remember_dir(path) + self.params = params + self.current_file = path + self._dirty = False + self._refresh_widgets() + self.regenerate() + self.statusBar().showMessage(f"Opened {path}", 6000) + + def save_design(self, as_new=False): + path = self.current_file + if as_new or not path: + default = os.path.join(self._last_dir(), self._safe_name() + FILE_EXT) + path, _ = QFileDialog.getSaveFileName(self, "Save design", default, f"Vaseinator designs (*{FILE_EXT})") + if not path: + return + if not os.path.splitext(path)[1]: + path += FILE_EXT + try: + save_design(path, self.params) + except OSError as exc: + QMessageBox.critical(self, "Save failed", str(exc)) + return + self._remember_dir(path) + self.current_file = path + self._dirty = False + self._update_title() + self.statusBar().showMessage(f"Saved {path}", 6000) + + def _current_design(self): + """The design to export, rebuilt in full first if a change is still waiting.""" + self._flush() + d = self.design + if d is not None and d.draft: + self.regenerate() + d = self.design + if d is None or {**d.asked.to_dict(), "name": ""} != {**self.params.to_dict(), "name": ""}: + QMessageBox.warning(self, "Nothing to export", "The current settings do not make a vase.") + return None + try: + require_exportable(d) + except ValueError as exc: + QMessageBox.warning(self, "Not exported", str(exc)) + return None + return d + + def export_stl(self): + d = self._current_design() + if d is None: + return + base = self._safe_name() + if not base.endswith("_vase"): + base += "_vase" # make it obvious this is a solid model for spiral vase mode + path, _ = QFileDialog.getSaveFileName(self, "Export STL", os.path.join(self._last_dir(), base + ".stl"), + "STL files (*.stl)") + if not path: + return + if not path.lower().endswith(".stl"): + path += ".stl" + try: + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + verts, faces = d.export_mesh() + finally: + QApplication.restoreOverrideCursor() + write_stl(path, verts, faces, base) + except OSError as exc: + QMessageBox.critical(self, "Export failed", str(exc)) + return + self._remember_dir(path) + if d.p.vase_mode == REAL_VASE: + how = "slice it with spiral vase mode on" + else: + how = ("the hollow pot: slice it with your usual settings (an STL has no colours: Export 3MF has the " + "bands as parts)") + self.statusBar().showMessage( + f"Exported {os.path.basename(path)} ({len(faces):,} triangles, watertight) to " + f"{os.path.dirname(path)} — {how}", 12000) + + def band_parts(self, d): + """The vase for write_3mf: a part per colour band, set to its filament.""" + colors = d.p.colors() + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + parts = d.export_parts() + finally: + QApplication.restoreOverrideCursor() + many = len(parts) > 1 + return [(f"{self._safe_name()} band {k + 1}" if many else self._safe_name(), verts, faces, fil, colors[fil]) + for k, (fil, verts, faces) in enumerate(parts)] + + def export_3mf(self): + """The vase for the slicer: one solid for a real spiral vase, a part per colour band for a fake one.""" + d = self._current_design() + if d is None: + return + p = d.p + base = self._safe_name() + path, _ = QFileDialog.getSaveFileName(self, "Export 3MF", os.path.join(self._last_dir(), base + ".3mf"), + "3MF files (*.3mf)") + if not path: + return + if not path.lower().endswith(".3mf"): + path += ".3mf" + parts = self.band_parts(d) + try: + write_3mf(path, base, parts, (p.bed_x / 2.0, p.bed_y / 2.0, 0.0)) + except OSError as exc: + QMessageBox.critical(self, "Export failed", str(exc)) + return + self._remember_dir(path) + if p.vase_mode == REAL_VASE: + how = "turn on spiral vase" + else: + how = "leave spiral vase off and print with your usual settings" + if len(parts) > 1: + fils = ", ".join(str(f) for f in sorted({f for _, _, _, f, _ in parts})) + what = f"{len(parts)} bands set to filaments {fils}" + else: + what = "the vase, one part" + self.statusBar().showMessage( + f"Exported {os.path.basename(path)}: {what} — in Flash Studio use File ▸ Import (Ctrl+I), then {how}", + 15000) + + def save_for_printing(self): + """The one button Simple mode needs: the vase as a 3MF for the slicer, and plain steps to print it.""" + d = self._current_design() + if d is None: + return + base = self._safe_name() + path, _ = QFileDialog.getSaveFileName(self, "Save for printing", os.path.join(self._last_dir(), base + ".3mf"), + "3MF files, for the slicer (*.3mf)") + if not path: + return + if not path.lower().endswith(".3mf"): + path += ".3mf" + parts = self.band_parts(d) + try: + write_3mf(path, base, parts, (d.p.bed_x / 2.0, d.p.bed_y / 2.0, 0.0)) + except OSError as exc: + QMessageBox.critical(self, "Couldn't save it", str(exc)) + return + self._remember_dir(path) + self.print_file = path + self.statusBar().showMessage(f"Saved {os.path.basename(path)} for printing, in {os.path.dirname(path)}", + 12000) + self.steps = PrintSteps(self, d, path, len(parts), self._slicer(ask=False)) + self.steps.open_slicer.connect(lambda: self.open_in_slicer(path)) + self.steps.show() + + def save_lid(self): + """A tissue box cover's lid, made to fit it, as a 3MF for the slicer - with how to print it and put it on.""" + d = self._current_design() + if d is None: + return + lid = Lid(d) + try: + require_exportable(lid) + except ValueError as exc: + QMessageBox.warning(self, "No lid", str(exc)) + return + base = self._safe_name() + " lid" + path, _ = QFileDialog.getSaveFileName(self, "Save the lid for printing", + os.path.join(self._last_dir(), base + ".3mf"), + "3MF files, for the slicer (*.3mf)") + if not path: + return + if not path.lower().endswith(".3mf"): + path += ".3mf" + try: + write_3mf(path, base, [(base, lid.verts, lid.faces, 1, d.p.colors()[1])], + (d.p.bed_x / 2.0, d.p.bed_y / 2.0, 0.0)) + except OSError as exc: + QMessageBox.critical(self, "Couldn't save it", str(exc)) + return + self._remember_dir(path) + self.print_file = path + self.statusBar().showMessage(f"Saved {os.path.basename(path)} for printing, in {os.path.dirname(path)}", + 12000) + self.steps = PrintSteps(self, d, path, 1, self._slicer(ask=False), + steps=lambda where: lid_steps(lid, path, where)) + self.steps.open_slicer.connect(lambda: self.open_in_slicer(path)) + self.steps.show() + + def _slicer(self, ask=True): + """The slicer program to open a vase in: the one chosen before, else the first one installed.""" + path = self.settings.value("slicer", "", type=str) + if path and os.path.exists(path): + return path + found = slicers.find_slicers() + if found: + self.settings.setValue("slicer", found[0][1]) + return found[0][1] + return self.choose_slicer() if ask else None + + def choose_slicer(self): + found = slicers.find_slicers() + start = os.path.dirname(found[0][1]) if found else os.environ.get("ProgramFiles", os.path.expanduser("~")) + path, _ = QFileDialog.getOpenFileName(QApplication.activeWindow() or self, "Choose your slicer program", + start, "Programs (*.exe);;All files (*)") + if not path: + return None + self.settings.setValue("slicer", path) + self.statusBar().showMessage(f"Open in slicer will use {slicers.slicer_name(path)}", 8000) + return path + + def open_in_slicer(self, path=None): + """Open the vase saved for printing in the slicer (saving it first if it hasn't been).""" + path = path or getattr(self, "print_file", None) + if not path or not os.path.exists(path): + self.save_for_printing() + return + program = self._slicer() + if not program: + return + if slicers.open_in(program, path): + self.statusBar().showMessage(f"Opening {os.path.basename(path)} in {slicers.slicer_name(program)}…", + 10000) + else: + QMessageBox.warning(self, "Couldn't open the slicer", + f"{program} didn't start. Choose your slicer program in the File menu, or open " + f"{path} in it yourself.") + + def save_snapshot(self): + default = os.path.join(self._last_dir(), self._safe_name() + "_3d.png") + path, _ = QFileDialog.getSaveFileName(self, "Save picture", default, "PNG image (*.png)") + if not path: + return + if not path.lower().endswith(".png"): + path += ".png" + self.viewer.grabFramebuffer().save(path) + self._remember_dir(path) + self.statusBar().showMessage(f"Saved picture to {path}", 8000) + + def copy_colour_plan(self): + self._flush() + if self.design is None: + return + QApplication.clipboard().setText(self.design.colour_plan()) + self.statusBar().showMessage("Colour changes copied to the clipboard", 6000) + + def printing_help(self): + QMessageBox.information(self, "Printing it watertight", PRINTING_HELP) + + def drawing_help(self): + QMessageBox.information(self, "Drawing a cross-section", DRAWING_HELP) + + def about(self): + QMessageBox.about(self, f"About {APP_NAME}", ABOUT) + + def closeEvent(self, ev): + if not self._confirm_discard(): + ev.ignore() + return + self.workspace.add("recovery", self.params) + self._save_workspace() + self.settings.setValue("geometry", self.saveGeometry()) + super().closeEvent(ev) + + +# ============================================================== bootstrap +def apply_dark_theme(app: QApplication): + apply_theme(app, light=False) + + +def apply_theme(app: QApplication, light: bool = False, scale: float = 1.0): + """The look: dark (the usual) or light, and the writing at its own size or bigger.""" + app.setStyle("Fusion") + base = app.property("vaseinator_base_pt") + if base is None: + base = app.font().pointSizeF() + app.setProperty("vaseinator_base_pt", base) + font = app.font() + font.setPointSizeF(base * scale) + app.setFont(font) + title = f"QLabel#simpleTitle {{ font-weight: 700; font-size: {base * scale * 1.25:.1f}pt; }}" + if light: + _light_theme(app) + app.setStyleSheet(app.styleSheet() + title) + return + pal = QPalette() + c = QColor + pal.setColor(QPalette.Window, c(36, 39, 45)) + pal.setColor(QPalette.WindowText, c(222, 225, 230)) + pal.setColor(QPalette.Base, c(26, 28, 33)) + pal.setColor(QPalette.AlternateBase, c(44, 47, 54)) + pal.setColor(QPalette.ToolTipBase, c(52, 56, 64)) + pal.setColor(QPalette.ToolTipText, c(230, 230, 230)) + pal.setColor(QPalette.Text, c(222, 225, 230)) + pal.setColor(QPalette.Button, c(50, 54, 62)) + pal.setColor(QPalette.ButtonText, c(222, 225, 230)) + pal.setColor(QPalette.BrightText, c(255, 90, 90)) + pal.setColor(QPalette.Highlight, c(56, 132, 214)) + pal.setColor(QPalette.HighlightedText, c(255, 255, 255)) + pal.setColor(QPalette.Link, c(110, 180, 255)) + pal.setColor(QPalette.PlaceholderText, c(130, 135, 145)) + for role in (QPalette.Text, QPalette.ButtonText, QPalette.WindowText): + pal.setColor(QPalette.Disabled, role, c(120, 124, 132)) + app.setPalette(pal) + app.setStyleSheet(""" + QGroupBox { font-weight: 600; border: 1px solid #3b4049; border-radius: 6px; + margin-top: 12px; padding-top: 4px; } + QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 4px; color: #f0a356; } + QTextBrowser { border: none; } + QToolBar { border: none; spacing: 4px; padding: 3px; } + QSplitter::handle { background: #2b2e34; } + QPushButton#modeButton { border: 1px solid #3b4049; padding: 4px 12px; background: #2a2d33; } + QPushButton#modeButton:checked { background: #3884d6; color: white; border-color: #3884d6; } + QToolButton#pictureButton { border: 1px solid #3b4049; border-radius: 6px; padding: 4px; + background: #2a2d33; } + QToolButton#pictureButton:hover { border-color: #6b7380; } + QToolButton#pictureButton:checked { border: 2px solid #f0a356; background: #3a332b; } + QPushButton#bigButton { padding: 8px 10px; font-weight: 600; } + QPushButton#saveButton { background: #f0a356; color: #1a1c21; font-weight: 700; border-radius: 6px; + border: 1px solid #f0a356; } + QPushButton#saveButton:hover { background: #f5b878; } + QLabel#simpleTitle { color: #f0a356; } + """ + title) + + +def _light_theme(app: QApplication): + pal = QPalette() + c = QColor + pal.setColor(QPalette.Window, c(238, 240, 243)) + pal.setColor(QPalette.WindowText, c(28, 30, 34)) + pal.setColor(QPalette.Base, c(255, 255, 255)) + pal.setColor(QPalette.AlternateBase, c(232, 235, 239)) + pal.setColor(QPalette.ToolTipBase, c(255, 255, 238)) + pal.setColor(QPalette.ToolTipText, c(20, 20, 20)) + pal.setColor(QPalette.Text, c(28, 30, 34)) + pal.setColor(QPalette.Button, c(226, 229, 234)) + pal.setColor(QPalette.ButtonText, c(28, 30, 34)) + pal.setColor(QPalette.BrightText, c(200, 30, 30)) + pal.setColor(QPalette.Highlight, c(56, 132, 214)) + pal.setColor(QPalette.HighlightedText, c(255, 255, 255)) + pal.setColor(QPalette.Link, c(20, 100, 200)) + pal.setColor(QPalette.PlaceholderText, c(120, 125, 135)) + for role in (QPalette.Text, QPalette.ButtonText, QPalette.WindowText): + pal.setColor(QPalette.Disabled, role, c(150, 154, 162)) + app.setPalette(pal) + app.setStyleSheet(""" + QGroupBox { font-weight: 600; border: 1px solid #c4c9d1; border-radius: 6px; + margin-top: 12px; padding-top: 4px; } + QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 4px; color: #b45f12; } + QTextBrowser { border: none; } + QToolBar { border: none; spacing: 4px; padding: 3px; } + QSplitter::handle { background: #d5d9df; } + QPushButton#modeButton { border: 1px solid #c4c9d1; padding: 4px 12px; background: #e6e8ec; } + QPushButton#modeButton:checked { background: #3884d6; color: white; border-color: #3884d6; } + QToolButton#pictureButton { border: 1px solid #c4c9d1; border-radius: 6px; padding: 4px; + background: #f6f7f9; } + QToolButton#pictureButton:hover { border-color: #8c939e; } + QToolButton#pictureButton:checked { border: 2px solid #e08a2c; background: #fcefe0; } + QPushButton#bigButton { padding: 8px 10px; font-weight: 600; } + QPushButton#saveButton { background: #e08a2c; color: white; font-weight: 700; border-radius: 6px; + border: 1px solid #e08a2c; } + QPushButton#saveButton:hover { background: #ea9b44; } + QLabel#simpleTitle { color: #b45f12; } + """) + + +def place_for_testing(win: QMainWindow, argv): + """Put a test run's window on another screen than the main one (the second, unless --screen N), + so testing doesn't get in the way of whoever is at the computer. + + Windows places a new window itself, so the window is shown see-through, + moved once it is up, and only then made visible. + """ + from PySide6.QtCore import QRect + + screens = QApplication.screens() + index = int(argv[argv.index("--screen") + 1]) if "--screen" in argv else (1 if len(screens) > 1 else 0) + screen = screens[min(max(index, 0), len(screens) - 1)] + g = screen.availableGeometry() + win.setWindowOpacity(0.0) + win.show() + QApplication.processEvents() + win.windowHandle().setScreen(screen) + win.setGeometry(QRect(g.x() + 10, g.y() + 30, min(1480, g.width() - 20), min(940, g.height() - 40))) + QApplication.processEvents() + win.setWindowOpacity(1.0) + + +def main(argv=None): + argv = list(sys.argv if argv is None else argv) + fmt = QSurfaceFormat() + fmt.setDepthBufferSize(24) + fmt.setSamples(8) + fmt.setVersion(2, 1) + fmt.setProfile(QSurfaceFormat.CompatibilityProfile) + QSurfaceFormat.setDefaultFormat(fmt) + + app = QApplication(argv) + app.setApplicationName(APP_NAME) + app.setOrganizationName("Vaseinator") + apply_dark_theme(app) + settings = None + if "--screenshot" in argv: # a test run keeps its hands off the remembered settings + import tempfile + settings = QSettings(os.path.join(tempfile.gettempdir(), "Vaseinator-test.ini"), QSettings.IniFormat) + settings.clear() + settings.setValue("spin", False) + win = MainWindow(settings) + if "--screenshot" in argv: + place_for_testing(win, argv) + else: + win.show() + + valued = ("--screenshot", "--view", "--export-test", "--yaw", "--pitch", "--zoom", "--gallery", "--surprise", + "--print-at", "--spin", "--screen") + files = [a for i, a in enumerate(argv) if i > 0 and not a.startswith("--") + and argv[i - 1] not in valued and os.path.isfile(a)] + if files: + win.load_file(files[0]) + + # developer hook: --screenshot out.png [--view N] [--gallery NAME] [--surprise SEED] [--yaw a] + # [--pitch a] [--zoom k] [--print-at 0..1] [--spin deg] [--detailed] [--export-test DIR] + # [--screen N] (the window opens on the second screen, if there is one) [--only-3d] + if "--screenshot" in argv: + out = argv[argv.index("--screenshot") + 1] + win.set_simple_mode("--detailed" not in argv, remember=False) + if "--gallery" in argv: + win.use_gallery(argv[argv.index("--gallery") + 1]) + if "--surprise" in argv: + win.surprise_me(int(argv[argv.index("--surprise") + 1])) + if "--view" in argv: + win.show_view(int(argv[argv.index("--view") + 1])) + if "--export-test" in argv: + out_dir = argv[argv.index("--export-test") + 1] + d = win.design + write_stl(os.path.join(out_dir, "t_vase.stl"), d.verts, d.faces, "t") + write_3mf(os.path.join(out_dir, "t.3mf"), "t", win.band_parts(d), (d.p.bed_x / 2, d.p.bed_y / 2, 0.0)) + if d.p.box: + lid = Lid(d) + write_3mf(os.path.join(out_dir, "t_lid.3mf"), "t lid", [("t lid", lid.verts, lid.faces, 1, (1, 1, 1))]) + + def snap(): + win._fitted = None + win._apply_view() + if "--spin" in argv: + win.spin_angle = float(argv[argv.index("--spin") + 1]) + win.viewer.set_model(spin_transform(win.spin_angle)) + for flag, attr in (("--yaw", "yaw"), ("--pitch", "pitch")): + if flag in argv: + setattr(win.viewer, attr, float(argv[argv.index(flag) + 1])) + if "--zoom" in argv: + win.viewer.dist /= float(argv[argv.index("--zoom") + 1]) + if "--print-at" in argv: + t = float(argv[argv.index("--print-at") + 1]) + d = win.design + z = float(np.interp(d.filament_at[-1] * t, d.filament_at, d.filament_z)) + win.viewer.cut_z = z + win.viewer.set_lines(d.contour(z)) + layer = int(math.ceil(z / d.p.layer_height)) + win.hint.setText(f"Printing: layer {layer} of {d.layers} · {duration(d.seconds * t)} of " + f"{duration(d.seconds)}") + win.viewer.repaint() + (win.viewer.grabFramebuffer() if "--only-3d" in argv else win.grab()).save(out) + win._dirty = False # a test run has nothing to save: close without asking + app.quit() + QTimer.singleShot(2200, snap) + + return app.exec() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/vaseinator/design_tools.py b/vaseinator/design_tools.py index 51da76b..5b0b60e 100644 --- a/vaseinator/design_tools.py +++ b/vaseinator/design_tools.py @@ -1,237 +1,237 @@ -"""Desktop controls for the shared portable design tools.""" - -import json - -from PySide6.QtCore import Qt, QSize, QByteArray -from PySide6.QtGui import QIcon, QPixmap, QPainter -from PySide6.QtSvg import QSvgRenderer -from PySide6.QtSvgWidgets import QSvgWidget -from PySide6.QtWidgets import ( - QDialog, - QVBoxLayout, - QHBoxLayout, - QLabel, - QPushButton, - QCheckBox, - QTabWidget, - QWidget, - QListWidget, - QListWidgetItem, - QInputDialog, - QFileDialog, - QMessageBox, -) - -from .workspace import LOCKS, comparison, calibration_parts, CALIBRATION_HELP, thumbnail -from .params import VaseParams -from .threemf import write_3mf - - -def show_comparison(win): - win._flush() - d = win._current_design() - if d is None: - return - data = comparison(d) - dlg = QDialog(win) - dlg.setWindowTitle("Original and softened") - dlg.resize(700, 500) - layout = QVBoxLayout(dlg) - label = QLabel(data["text"]) - label.setWordWrap(True) - layout.addWidget(label) - svg = QSvgWidget() - svg.load(QByteArray(data["svg"].encode())) - layout.addWidget(svg) - dlg.exec() - - -def save_calibration(win): - path, _ = QFileDialog.getSaveFileName( - win, "Save clearance samples", "clearance_samples.3mf", "3MF (*.3mf)" - ) - if not path: - return - if not path.lower().endswith(".3mf"): - path += ".3mf" - try: - write_3mf( - path, - "Clearance samples", - calibration_parts(), - (win.params.bed_x / 2, win.params.bed_y / 2, 0), - ) - except OSError as exc: - QMessageBox.warning(win, "Could not save samples", str(exc)) - return - QMessageBox.information(win, "How to use the samples", CALIBRATION_HELP) - - -class DesignTools(QDialog): - def __init__(self, win): - super().__init__(win) - self.win = win - self.setWindowTitle("Design library and profiles") - self.resize(760, 620) - layout = QVBoxLayout(self) - layout.addWidget( - QLabel( - "Keep these when using Surprise me (printer settings are always kept):" - ) - ) - row = QHBoxLayout() - self.lock_boxes = {} - for name in LOCKS: - box = QCheckBox(name) - box.setChecked(name in win.workspace.data["locks"]) - row.addWidget(box) - self.lock_boxes[name] = box - box.toggled.connect(lambda on, n=name: self.lock(n, on)) - layout.addLayout(row) - note = QLabel( - "Library and recovery stay on this computer. Export a library backup to transfer it to the web app. Recovery keeps the last 30 settled designs. Thumbnails are silhouette sketches." - ) - note.setWordWrap(True) - layout.addWidget(note) - tabs = QTabWidget() - layout.addWidget(tabs) - self.lists = {} - for category, title in [ - ("designs", "My designs"), - ("profiles", "Printer and material profiles"), - ("recovery", "Recovery"), - ]: - page = QWidget() - box = QVBoxLayout(page) - entries = QListWidget() - entries.setIconSize(QSize(80, 80)) - box.addWidget(entries) - self.lists[category] = entries - buttons = QHBoxLayout() - box.addLayout(buttons) - - def button(text, callback): - b = QPushButton(text) - b.clicked.connect(callback) - buttons.addWidget(b) - - if category == "designs": - button("Save current variant", lambda: self.save("designs")) - button("Favourite", lambda: self.selected("designs", "favorite")) - if category == "profiles": - button("Save printer", lambda: self.save("profiles", "printer")) - button("Save material", lambda: self.save("profiles", "material")) - button( - "Restore / apply", - lambda _=False, c=category: self.selected(c, "restore"), - ) - button("Remove", lambda _=False, c=category: self.selected(c, "delete")) - tabs.addTab(page, title) - row = QHBoxLayout() - layout.addLayout(row) - for text, fn in [ - ("Export library backup", self.export_file), - ("Import library", self.import_file), - ("Close", self.accept), - ]: - b = QPushButton(text) - b.clicked.connect(fn) - row.addWidget(b) - self.refresh() - - def lock(self, name, on): - locks = set(self.win.workspace.data["locks"]) - locks.add(name) if on else locks.discard(name) - self.win.workspace.command("locks", self.win.params, {"locks": list(locks)}) - self.win._save_workspace() - - def refresh(self): - for name, box in self.lock_boxes.items(): - box.blockSignals(True) - box.setChecked(name in self.win.workspace.data["locks"]) - box.blockSignals(False) - for category, widget in self.lists.items(): - widget.clear() - for e in sorted( - self.win.workspace.data[category], - key=lambda e: (e["favorite"], e["created"]), - reverse=True, - ): - text = ( - ("★ " if e["favorite"] else "") + e["name"] + " — " + e["created"] - ) - if category == "profiles": - text = e["kind"].title() + ": " + text - item = QListWidgetItem(text) - item.setData(Qt.UserRole, e["id"]) - if category == "designs": - renderer = QSvgRenderer( - QByteArray( - thumbnail(VaseParams.from_dict(e["params"])).encode() - ) - ) - pix = QPixmap(80, 80) - pix.fill(Qt.transparent) - painter = QPainter(pix) - renderer.render(painter) - painter.end() - item.setIcon(QIcon(pix)) - widget.addItem(item) - - def save(self, category, kind="printer"): - name, ok = QInputDialog.getText( - self, - "Save variant" if category == "designs" else "Save profile", - "Name:", - text=self.win.params.name, - ) - if ok and name.strip(): - self.run("save", dict(category=category, name=name, kind=kind)) - - def selected(self, category, op): - item = self.lists[category].currentItem() - if item is None: - return - if ( - op == "delete" - and QMessageBox.question(self, "Remove entry", "Remove this saved entry?") - != QMessageBox.Yes - ): - return - self.run(op, dict(category=category, id=item.data(Qt.UserRole))) - - def run(self, op, payload): - try: - self.win._flush() - result = self.win.workspace.command(op, self.win.params, payload) - if result["params"]: - if payload.get("category") != "profiles": - self.win.current_file = None - self.win._restore(result["params"]) - self.win._save_workspace() - self.refresh() - except (ValueError, KeyError, StopIteration) as exc: - QMessageBox.warning(self, "Design tools", str(exc)) - - def export_file(self): - path, _ = QFileDialog.getSaveFileName( - self, "Export library", "vaseinator-library.json", "JSON (*.json)" - ) - if path: - try: - with open(path, "w", encoding="utf-8") as fh: - fh.write(self.win.workspace.dumps()) - except OSError as exc: - QMessageBox.warning(self, "Export failed", str(exc)) - - def import_file(self): - path, _ = QFileDialog.getOpenFileName( - self, "Import library", "", "JSON (*.json)" - ) - if path: - try: - with open(path, encoding="utf-8") as fh: - data = json.load(fh) - self.run("import", data) - except (ValueError, OSError) as exc: - QMessageBox.warning(self, "Import failed", str(exc)) +"""Desktop controls for the shared portable design tools.""" + +import json + +from PySide6.QtCore import Qt, QSize, QByteArray +from PySide6.QtGui import QIcon, QPixmap, QPainter +from PySide6.QtSvg import QSvgRenderer +from PySide6.QtSvgWidgets import QSvgWidget +from PySide6.QtWidgets import ( + QDialog, + QVBoxLayout, + QHBoxLayout, + QLabel, + QPushButton, + QCheckBox, + QTabWidget, + QWidget, + QListWidget, + QListWidgetItem, + QInputDialog, + QFileDialog, + QMessageBox, +) + +from .workspace import LOCKS, comparison, calibration_parts, CALIBRATION_HELP, thumbnail +from .params import VaseParams +from .threemf import write_3mf + + +def show_comparison(win): + win._flush() + d = win._current_design() + if d is None: + return + data = comparison(d) + dlg = QDialog(win) + dlg.setWindowTitle("Original and softened") + dlg.resize(700, 500) + layout = QVBoxLayout(dlg) + label = QLabel(data["text"]) + label.setWordWrap(True) + layout.addWidget(label) + svg = QSvgWidget() + svg.load(QByteArray(data["svg"].encode())) + layout.addWidget(svg) + dlg.exec() + + +def save_calibration(win): + path, _ = QFileDialog.getSaveFileName( + win, "Save clearance samples", "clearance_samples.3mf", "3MF (*.3mf)" + ) + if not path: + return + if not path.lower().endswith(".3mf"): + path += ".3mf" + try: + write_3mf( + path, + "Clearance samples", + calibration_parts(), + (win.params.bed_x / 2, win.params.bed_y / 2, 0), + ) + except OSError as exc: + QMessageBox.warning(win, "Could not save samples", str(exc)) + return + QMessageBox.information(win, "How to use the samples", CALIBRATION_HELP) + + +class DesignTools(QDialog): + def __init__(self, win): + super().__init__(win) + self.win = win + self.setWindowTitle("Design library and profiles") + self.resize(760, 620) + layout = QVBoxLayout(self) + layout.addWidget( + QLabel( + "Keep these when using Surprise me (printer settings are always kept):" + ) + ) + row = QHBoxLayout() + self.lock_boxes = {} + for name in LOCKS: + box = QCheckBox(name) + box.setChecked(name in win.workspace.data["locks"]) + row.addWidget(box) + self.lock_boxes[name] = box + box.toggled.connect(lambda on, n=name: self.lock(n, on)) + layout.addLayout(row) + note = QLabel( + "Library and recovery stay on this computer. Export a library backup to transfer it to the web app. Recovery keeps the last 30 settled designs. Thumbnails are silhouette sketches." + ) + note.setWordWrap(True) + layout.addWidget(note) + tabs = QTabWidget() + layout.addWidget(tabs) + self.lists = {} + for category, title in [ + ("designs", "My designs"), + ("profiles", "Printer and material profiles"), + ("recovery", "Recovery"), + ]: + page = QWidget() + box = QVBoxLayout(page) + entries = QListWidget() + entries.setIconSize(QSize(80, 80)) + box.addWidget(entries) + self.lists[category] = entries + buttons = QHBoxLayout() + box.addLayout(buttons) + + def button(text, callback): + b = QPushButton(text) + b.clicked.connect(callback) + buttons.addWidget(b) + + if category == "designs": + button("Save current variant", lambda: self.save("designs")) + button("Favourite", lambda: self.selected("designs", "favorite")) + if category == "profiles": + button("Save printer", lambda: self.save("profiles", "printer")) + button("Save material", lambda: self.save("profiles", "material")) + button( + "Restore / apply", + lambda _=False, c=category: self.selected(c, "restore"), + ) + button("Remove", lambda _=False, c=category: self.selected(c, "delete")) + tabs.addTab(page, title) + row = QHBoxLayout() + layout.addLayout(row) + for text, fn in [ + ("Export library backup", self.export_file), + ("Import library", self.import_file), + ("Close", self.accept), + ]: + b = QPushButton(text) + b.clicked.connect(fn) + row.addWidget(b) + self.refresh() + + def lock(self, name, on): + locks = set(self.win.workspace.data["locks"]) + locks.add(name) if on else locks.discard(name) + self.win.workspace.command("locks", self.win.params, {"locks": list(locks)}) + self.win._save_workspace() + + def refresh(self): + for name, box in self.lock_boxes.items(): + box.blockSignals(True) + box.setChecked(name in self.win.workspace.data["locks"]) + box.blockSignals(False) + for category, widget in self.lists.items(): + widget.clear() + for e in sorted( + self.win.workspace.data[category], + key=lambda e: (e["favorite"], e["created"]), + reverse=True, + ): + text = ( + ("★ " if e["favorite"] else "") + e["name"] + " — " + e["created"] + ) + if category == "profiles": + text = e["kind"].title() + ": " + text + item = QListWidgetItem(text) + item.setData(Qt.UserRole, e["id"]) + if category == "designs": + renderer = QSvgRenderer( + QByteArray( + thumbnail(VaseParams.from_dict(e["params"])).encode() + ) + ) + pix = QPixmap(80, 80) + pix.fill(Qt.transparent) + painter = QPainter(pix) + renderer.render(painter) + painter.end() + item.setIcon(QIcon(pix)) + widget.addItem(item) + + def save(self, category, kind="printer"): + name, ok = QInputDialog.getText( + self, + "Save variant" if category == "designs" else "Save profile", + "Name:", + text=self.win.params.name, + ) + if ok and name.strip(): + self.run("save", dict(category=category, name=name, kind=kind)) + + def selected(self, category, op): + item = self.lists[category].currentItem() + if item is None: + return + if ( + op == "delete" + and QMessageBox.question(self, "Remove entry", "Remove this saved entry?") + != QMessageBox.Yes + ): + return + self.run(op, dict(category=category, id=item.data(Qt.UserRole))) + + def run(self, op, payload): + try: + self.win._flush() + result = self.win.workspace.command(op, self.win.params, payload) + if result["params"]: + if payload.get("category") != "profiles": + self.win.current_file = None + self.win._restore(result["params"]) + self.win._save_workspace() + self.refresh() + except (ValueError, KeyError, StopIteration) as exc: + QMessageBox.warning(self, "Design tools", str(exc)) + + def export_file(self): + path, _ = QFileDialog.getSaveFileName( + self, "Export library", "vaseinator-library.json", "JSON (*.json)" + ) + if path: + try: + with open(path, "w", encoding="utf-8") as fh: + fh.write(self.win.workspace.dumps()) + except OSError as exc: + QMessageBox.warning(self, "Export failed", str(exc)) + + def import_file(self): + path, _ = QFileDialog.getOpenFileName( + self, "Import library", "", "JSON (*.json)" + ) + if path: + try: + with open(path, encoding="utf-8") as fh: + data = json.load(fh) + self.run("import", data) + except (ValueError, OSError) as exc: + QMessageBox.warning(self, "Import failed", str(exc)) diff --git a/vaseinator/geometry.py b/vaseinator/geometry.py index 933d1b9..0ca1709 100644 --- a/vaseinator/geometry.py +++ b/vaseinator/geometry.py @@ -1,1620 +1,1620 @@ -"""The vase itself: its surface, made printable, as closed watertight meshes. - -The surface is a radial height map r(theta, z) on a grid of heights and -angles. Because every height has exactly one radius in each direction, -each layer is one simple closed loop round the axis - just what a spiral -vase slicer needs - and the mesh can never cross itself: every triangle -lies in its own wedge of angle between two heights. - -Overhangs are measured on the real surface normal. A wall leaning out *or -in* overhangs the single printed line beneath it, so both count. Where the -wall leans too far, the radius is limited column by column (a Lipschitz -envelope from above and below, averaged), with the allowed change per layer -worked out from how the wall faces there - a flank that faces sideways may -move a long way sideways. The finished triangles are then checked, and the -limit tightened until every one of them is within it. -""" - -from __future__ import annotations - -import math - -import numpy as np - -from .expr import ExprError, compile_expression -from . import hollow, sections -from .params import (ALMOND_EYES, BANDS, CHIN_MOUTH, DIAMOND, DRAWN, EVENLY, FAKE_VASE, GRADIENT, GRILLE_MOUTH, - MAX_BANDS, NO_EYES, NO_HOLES, NO_MOUTH, NO_NOSE, NO_TISSUE, ONE_COLOUR, OPEN_MOUTH, OWN, - RAISED_NOSE, REAL_VASE, RECTANGLE, ROUND, SAME, SLOTS, STRIPES, TISSUE_OVAL, TISSUE_ROUND, - VORONOI, VaseParams, colour_style_of, face_holes, needs_normal) -from .shapes import (TABLE, TAU, blur, change, lobes_needed, organic, pchip, progress, rounded_rectangle, sample, - shape_table, smooth_max, smoothstep, strength) - -TOLERANCE = 0.3 # degrees over the overhang limit still counted as within it -HOLE_WORDS = {VORONOI: "Voronoi", ROUND: "round holes", DIAMOND: "diamonds", SLOTS: "slots"} -VORONOI_BRIDGE = 0.7 # a Voronoi oval's round top is bridged once it is this share of the cells' size across -VORONOI_LONGEST = 14.0 # ... and never more than this (mm): a round top that size bridges on most printers -HEAD_ROOM = 10.0 # mm more round the inside of a mask or helmet than round the head it fits -SIMPLIFY_ERROR = 1e-3 # how far saving may simplify the mesh: pyfqmr's lossless error, a few hundredths of a mm -SIMPLIFY_MM = 0.02 # ... and in the web version, meshoptimizer's (mm) -BOX_ROOM = 2.0 # mm to spare each side between a tissue box cover and the box of tissues inside it -BOX_TOP_ROOM = 3.0 # ... and over the box's top -BOX_END_ROOM = 2.0 # ... and more inside each end, for the lid's tabs -LEDGE_DEPTH = 1.2 # a cover's ledges, inside each end at the rim, that its lid clicks in behind (mm) -LEDGE_FROM = 4.0 # ... how far in from the rim each starts, the cover on -LEDGE_RAMP = 3.5 # ... the gentle slope the lid's tabs ride over, going on -LEDGE_FLAT = 0.4 # ... its flat -LEDGE_LEAN = 35.0 # ... and the steep side they catch on, from upright: an overhang as the cover prints, - # with room for a pattern's own lean -LEDGE_CLEAR = 13.0 # a cover's holes keep this far from its rim, clear of the lid's tabs (mm) -TISSUE_MARGIN = 8.0 # the tissue hole keeps at least this much of the cover's top round it (mm) -TISSUE_WORDS = {1: "a slot", 2: "an oval", 3: "a round hole"} -# what grows with a design sized for a head: its height and the face, in mm -HEAD_SCALED = ("height", "eye_width", "eye_height", "eye_apart", "eye_up", "nose_width", "nose_length", - "nose_depth", "nose_up", "mouth_width", "mouth_height", "mouth_up", "mouth_smile", "strap_up") - - -def require_exportable(design): - """One export gate for desktop and browser, including lids.""" - if design.errors: - raise ValueError("; ".join(design.errors)) - if not design.closed: - raise ValueError("The mesh is not closed. Please report this design before printing it.") - return design - - -# ===================================================================== meshes -def grid_side(z, theta, R): - """The wall from a grid: rows of radii at shared angles. - - Returns (verts, faces, band of each face, first ring, last ring). Each - cell is split along the diagonal whose ends are closer in radius, so - ridges that spiral up the vase stay smooth. - """ - K, N = R.shape - c, s = np.cos(theta)[None, :], np.sin(theta)[None, :] - verts = np.stack([R * c, R * s, np.broadcast_to(z[:, None], R.shape)], -1).reshape(-1, 3) - idx = np.arange(K * N).reshape(K, N) - nxt = np.roll(idx, -1, axis=1) - v00, v01, v10, v11 = idx[:-1], nxt[:-1], idx[1:], nxt[1:] - Rn = np.roll(R, -1, axis=1) - along = (np.abs(R[:-1] - Rn[1:]) <= np.abs(Rn[:-1] - R[1:]))[..., None] - t1 = np.where(along, np.stack([v00, v01, v11], -1), np.stack([v00, v01, v10], -1)) - t2 = np.where(along, np.stack([v00, v11, v10], -1), np.stack([v01, v11, v10], -1)) - faces = np.concatenate([t1.reshape(-1, 3), t2.reshape(-1, 3)]) - band = np.tile(np.repeat(np.arange(K - 1), N), 2) - return verts, faces, band, idx[0].copy(), idx[-1].copy() - - -def loft_side(rows): - """The wall from rings with angles of their own: (z, angles ascending, radii) each. - - Neighbouring rings are joined by walking round both at once, always - stepping on along the one whose next point comes first. - """ - verts, starts, o = [], [], 0 - for z, a, r in rows: - verts.append(np.stack([r * np.cos(a), r * np.sin(a), np.full(len(a), z)], 1)) - starts.append(o) - o += len(a) - faces, band = [], [] - for k in range(len(rows) - 1): - A, B = rows[k][1], rows[k + 1][1] - na, nb, sa, sb = len(A), len(B), starts[k], starts[k + 1] - d = np.mod(B - A[0] + math.pi, TAU) - math.pi - j0 = int(np.argmin(np.abs(d))) - b0 = A[0] + d[j0] - au = A[0] + np.append(np.mod(A - A[0], TAU), TAU) - bu = b0 + np.append(np.mod(np.roll(B, -j0) - B[j0], TAU), TAU) - i = j = 0 - while i < na or j < nb: - if j >= nb or (i < na and au[i + 1] <= bu[j + 1]): - faces.append((sa + i % na, sa + (i + 1) % na, sb + (j0 + j) % nb)) - i += 1 - else: - faces.append((sa + i % na, sb + (j0 + j + 1) % nb, sb + (j0 + j) % nb)) - j += 1 - band.append(k) - verts = np.concatenate(verts) - first = np.arange(starts[0], starts[0] + len(rows[0][1])) - last = np.arange(starts[-1], starts[-1] + len(rows[-1][1])) - return verts, np.array(faces, dtype=np.int64), np.array(band), first, last - - -def grid_side_xy(z, P): - """The wall from rows of outline points (K, N, 2), point i of each row joined to point i of the next. - - Returns (verts, faces, band of each face, first ring, last ring). Each cell - is split along its shorter diagonal. - """ - K, N = P.shape[:2] - verts = np.concatenate([P, np.broadcast_to(z[:, None, None], (K, N, 1))], axis=2).reshape(-1, 3) - idx = np.arange(K * N).reshape(K, N) - nxt = np.roll(idx, -1, axis=1) - v00, v01, v10, v11 = idx[:-1], nxt[:-1], idx[1:], nxt[1:] - d1 = np.linalg.norm(verts[v00] - verts[v11], axis=-1) - d2 = np.linalg.norm(verts[v01] - verts[v10], axis=-1) - along = (d1 <= d2)[..., None] - t1 = np.where(along, np.stack([v00, v01, v11], -1), np.stack([v00, v01, v10], -1)) - t2 = np.where(along, np.stack([v00, v11, v10], -1), np.stack([v01, v11, v10], -1)) - faces = np.concatenate([t1.reshape(-1, 3), t2.reshape(-1, 3)]) - band = np.tile(np.repeat(np.arange(K - 1), N), 2) - return verts, faces, band, idx[0].copy(), idx[-1].copy() - - -def limit_slope(v, step): - """A 1D curve kept within a most change per step (step[i] between v[i] and v[i+1]). - - The largest curve under it and the smallest over it that keep within the - limit, averaged - as the columns of a radial vase are softened. - """ - lo, hi = v.copy(), v.copy() - for i in range(1, len(v)): - lo[i] = min(lo[i], lo[i - 1] + step[i - 1]) - hi[i] = max(hi[i], hi[i - 1] - step[i - 1]) - for i in range(len(v) - 2, -1, -1): - lo[i] = min(lo[i], lo[i + 1] + step[i]) - hi[i] = max(hi[i], hi[i + 1] - step[i]) - return 0.5 * (lo + hi) - - -def fall_gently(v, step: float): - """The largest curve under v that changes by at most step from one value to the next.""" - lo = v.copy() - for i in range(1, len(v)): - lo[i] = min(lo[i], lo[i - 1] + step) - for i in range(len(v) - 2, -1, -1): - lo[i] = min(lo[i], lo[i + 1] + step) - return lo - - -def fan(center: int, ring, up: bool): - """A flat cap: triangles from the centre to each side of a ring.""" - ring = np.asarray(ring) - nxt = np.roll(ring, -1) - c = np.full(len(ring), center) - return np.stack([c, ring, nxt] if up else [c, nxt, ring], 1) - - -def annulus(outer, inner): - """A flat, upward-facing ring between two rings of the same angles.""" - o, i = np.asarray(outer), np.asarray(inner) - o1, i1 = np.roll(o, -1), np.roll(i, -1) - return np.concatenate([np.stack([o, o1, i1], 1), np.stack([o, i1, i], 1)]) - - -def lighter(verts, faces): - """The mesh with its triangles merged wherever that moves the surface by no more than a few hundredths of a - millimetre - the slicer's Simplify model, done before saving, so a fine mesh makes a file the slicer opens - quickly: with pyfqmr in the desktop app, meshoptimizer in the web version. As it was if neither is there, or - if the result wouldn't be closed.""" - try: - import pyfqmr - except ImportError: - pyfqmr = None - for k in range(3): # each time a little more careful, if it didn't stay closed - if pyfqmr is not None: - s = pyfqmr.Simplify() - s.setMesh(np.asarray(verts, dtype=np.float64), np.asarray(faces, dtype=np.int32)) - s.simplify_mesh_lossless(epsilon=SIMPLIFY_ERROR / 4 ** k, verbose=False) - v, f, _ = s.getMesh() - else: - try: - import meshopt # the web version: given to its Python by the page - except ImportError: - return verts, faces - kept = meshopt.simplify(np.asarray(verts, dtype=np.float32).tobytes(), - np.asarray(faces, dtype=np.uint32).tobytes(), SIMPLIFY_MM / 2 ** k) - f = np.frombuffer(bytes(kept.to_py()), dtype=np.uint32).reshape(-1, 3).astype(np.int64) - used = np.unique(f) # only the corners still in use - v, f = np.asarray(verts)[used], np.searchsorted(used, f) - # a triangle and its twin facing the other way are a fin with nothing inside: neither is needed - f = np.asarray(f, dtype=np.int64) - _, which, count = np.unique(np.sort(f, axis=1), axis=0, return_inverse=True, return_counts=True) - f = f[count[which.ravel()] == 1] - if len(f) and edge_check(f)[0]: - return np.asarray(v, dtype=np.float64), f - return verts, faces - - -def face_normals(verts, faces): - tri = verts[faces] - n = np.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0]) - ln = np.linalg.norm(n, axis=1, keepdims=True) - ln[ln == 0] = 1.0 - return n / ln - - -def lean_angles(verts, faces): - """How far each face leans from upright (degrees), out or in alike.""" - n = face_normals(verts, faces) - return np.degrees(np.arcsin(np.clip(np.abs(n[:, 2]), 0.0, 1.0))) - - -def mesh_volume(verts, faces) -> float: - tri = verts[faces] - return float(np.einsum("ij,ij->i", tri[:, 0], np.cross(tri[:, 1], tri[:, 2])).sum() / 6.0) - - -def edge_check(faces): - """Whether a mesh is closed: every edge used once each way, by exactly two faces. - - Returns (closed, number of bad edges). - """ - f = np.asarray(faces, dtype=np.int64) - a = f.ravel() - b = np.roll(f, -1, axis=1).ravel() - n = int(f.max()) + 1 - fwd = np.sort(a * n + b) - rev = np.sort(b * n + a) - twice = int((np.diff(fwd) == 0).sum()) # an edge used twice the same way - unmatched = int((fwd != rev).sum()) # an edge with no face the other way - return twice == 0 and unmatched == 0, twice + unmatched - - -def inset(angles, radii, width): - """Radii of a ring moved in by ``width`` square to the wall (as a radial map).""" - a = np.asarray(angles) - r = np.asarray(radii) - da = np.mod(np.roll(a, -1, axis=-1) - np.roll(a, 1, axis=-1), TAU) - da[da == 0] = 1e-9 - rth = (np.roll(r, -1, axis=-1) - np.roll(r, 1, axis=-1)) / da - out = r - width * np.sqrt(r * r + rth * rth) / np.maximum(r, 1e-6) - return np.maximum(out, np.maximum(0.15 * r, 0.2)) - - -def soften(R, dth, dz, angle_deg, scale=None, rounds=4): - """Limit the radius so that no part of the wall leans more than angle_deg from upright. - - Column by column, the largest surface under R and the smallest one over - it that keep within the lean are found and averaged: peaks are cut back - and hollows filled in by the same amount. ``scale`` tightens the limit - point by point where the finished triangles showed it was needed. - """ - t = math.tan(math.radians(max(angle_deg, 1.0))) - R = R.copy() - for _ in range(rounds): - # how far the wall faces sideways there - smoothed round the vase, so that neighbouring - # columns are allowed much the same and a sharp tip passing across them doesn't streak - rth = (np.roll(R, -1, axis=1) - np.roll(R, 1, axis=1)) / (2.0 * dth) - k = np.sqrt(R * R + rth * rth) / np.maximum(R, 1e-6) - k = t * np.exp(blur_around(np.log(k), 1.5)) - if scale is not None: - k = k * scale - step = np.minimum(k[:-1], k[1:]) * dz # most each row may differ from the next - lo, hi = R.copy(), R.copy() - for j in range(1, len(R)): - np.minimum(lo[j], lo[j - 1] + step[j - 1], out=lo[j]) - np.maximum(hi[j], hi[j - 1] - step[j - 1], out=hi[j]) - for j in range(len(R) - 2, -1, -1): - np.minimum(lo[j], lo[j + 1] + step[j], out=lo[j]) - np.maximum(hi[j], hi[j + 1] - step[j], out=hi[j]) - new = 0.5 * (lo + hi) - moved = float(np.abs(new - R).max()) - R = new - if moved < 1e-4: - break - return R - - -def ease_twist(R, dth, omega, angle_deg, rounds=3): - """Soften the flanks of a fast-twisting shape round the vase, layer by layer. - - A flank that faces sideways and is carried sideways by the twist leans by - tan(lean) = r·ω·|r_θ| / sqrt(r² + r_θ²). Where r·ω is more than the limit - allows, only a gentler flank can keep within it, so |r_θ| is limited round - each layer (envelopes from above and below, averaged, as for the columns). - Every layer of a twisted shape is softened alike, so it stays one clean - twisted shape. A little is left in hand for the outline's own slope. - """ - t = math.tan(math.radians(max(angle_deg, 1.0))) * 0.9 - N = R.shape[1] - for _ in range(rounds): - rw = R * omega[:, None] - if not (rw > t).any(): - break - bound = np.where(rw > t, R * t / np.sqrt(np.maximum(rw * rw - t * t, 1e-12)), np.inf) - step = np.minimum(bound, np.roll(bound, -1, axis=1)) * dth # between column i and i + 1 - lo, hi = R.copy(), R.copy() - for _ in range(2): # twice round, for the wrap - for i in range(N): - np.minimum(lo[:, i], lo[:, i - 1] + step[:, i - 1], out=lo[:, i]) - np.maximum(hi[:, i], hi[:, i - 1] - step[:, i - 1], out=hi[:, i]) - for i in range(N - 1, -1, -1): - np.minimum(lo[:, i], lo[:, (i + 1) % N] + step[:, i], out=lo[:, i]) - np.maximum(hi[:, i], hi[:, (i + 1) % N] - step[:, i], out=hi[:, i]) - R = 0.5 * (lo + hi) - return R - - -def blur_around(a, sigma_cols): - """Smooth a grid round the vase (each row on its own), sigma in columns.""" - N = a.shape[1] - k = np.fft.rfftfreq(N, d=1.0 / N) - gain = np.exp(-0.5 * (k * sigma_cols * TAU / N) ** 2) - return np.fft.irfft(np.fft.rfft(a, axis=1) * gain[None, :], n=N, axis=1) - - -def layer_top(z: float, layer: float, first: float | None = None) -> tuple[int, float]: - """The first layer that starts at height z or above: (its number from 1, the height of its top). - - The first layer may be thicker than the rest, as slicers often make it. - """ - first = layer if first is None else first - if z <= first * 0.5: - return 1, first - k = max(math.ceil((z - first) / layer - 0.5), 0) # whole layers above the first one, below z - return k + 2, round(first + (k + 1) * layer, 4) - - -def duration(seconds: float) -> str: - m = int(round(seconds / 60.0)) - return f"{m // 60} h {m % 60:02d} min" if m >= 60 else f"{max(m, 1)} min" - - -# ===================================================================== design -class VaseDesign: - """A vase built from its parameters: the solid to export, the shell to show, and its sums.""" - - def __init__(self, p: VaseParams, draft: bool = False): - self.errors: list[str] = [] - self.warnings: list[str] = [] - self.notes: list[str] = [] - self.asked = p.copy() # the settings as given: p below is as built, a box's shape and all - self.box_room = 0.0 - if p.box: # a tissue box cover: its shape comes from the box it fits - self.box_room = box_room(p) - p = box_shape(p) - if p.vase_mode == REAL_VASE and needs_normal(p): - p = p.copy() - p.vase_mode = FAKE_VASE - self.notes.append("Holes, a face, a tissue box cover, an open bottom and a wall part way round need a " - "normal print, with walls (spiral vase mode prints one closed line a layer): it is " - "printed normally.") - if p.closed_top and p.around < 360.0: - p = p.copy() - p.closed_top = False - self.notes.append("A closed top needs the wall all the way round: the top is left open.") - self.p = p - self.draft = draft - self.pot_rows = None - self.bridge = 0.0 - self.holes_open = False - self.pattern_open = False - self.bridge_allowed = 0.0 # how wide a hole's top may be bridged (mm): Voronoi ovals - self.hole_share = 0.0 - self.head_fit = 0.0 - self.ceiling = 0.0 - self.floor_hole = None # a tissue box cover's hole through its top, on the bed - self.tissue = None # ... its length and width (mm) - self.fn = None - self.closed = None - self.free = DRAWN in (self.p.shape1, self.p.shape2) # a drawn outline: built point by point - self.outer_rows = None - try: - if self._check(): - if self.free: - self._free_surface() - if self.errors: - return - self._free_printable() - inner = np.stack([sections.inset(row, self.wall) for row in self.P]) - self._pot(self.P, inner) - self.outer_rows = [(zz, np.arctan2(r[:, 1], r[:, 0]), np.hypot(r[:, 0], r[:, 1])) - for zz, r in zip(self.z, self.P)] - else: - self._grid_size() - self._surface() - self._printable() - if self.rows is None: - c, s = np.cos(self.theta), np.sin(self.theta) - Ri = inset(self.theta, self.R, self.wall) - self._pot(np.stack([self.R * c, self.R * s], -1), np.stack([Ri * c, Ri * s], -1)) - else: - self._shell() # low-poly: the facets' own shell - self._measure() - self._colours() - self._advice() - except ExprError as exc: - self.errors.append(f"Your own function: {exc}") - - # ------------------------------------------------------------ checks - def _check(self) -> bool: - p = self.p - if p.height < 5.0: - self.errors.append("The vase must be at least 5 mm tall.") - if len(p.silhouette) < 2 or min(d for _, d in p.silhouette) <= 0: - self.errors.append("The outline needs at least two points, every one wider than nothing.") - for key, label in (("line_width", "Line width"), ("layer_height", "Layer height"), - ("filament_d", "Filament diameter"), ("speed", "Print speed"), - ("max_flow", "Max flow"), ("density", "Filament density")): - if getattr(p, key) <= 0: - self.errors.append(f"{label} must be more than zero.") - if p.facet_size <= 0: - self.errors.append("Mesh detail must be more than zero.") - try: - self.fn = compile_expression(p.expression) - except ExprError as exc: - self.errors.append(f"Your own function: {exc}") - return not self.errors - - def _grid_size(self): - p = self.p - self.H = H = float(p.height) - sil = np.asarray(p.silhouette, dtype=float) - tex = ((abs(p.rib_depth) if p.rib_count > 0 else 0.0) + (abs(p.ring_depth) if p.ring_count > 0 else 0.0) - + (abs(p.bump_depth) if p.bump_around > 0 else 0.0) + 1.2 * max(p.organic_depth, 0.0)) - r_est = 0.5 * sil[:, 1].max() + tex - step = max(p.facet_size, 0.15) * (2.6 if self.draft else 1.0) - per = 10 if self.draft else 22 # points round each lobe or rib - lobes = [lobes_needed(p.shape1, p.count1)] - if p.shape2 != SAME: - lobes.append(lobes_needed(p.shape2, p.count2)) - if p.rib_count > 0: - lobes.append(p.rib_count * (2 if p.rib_cross else 1)) - if p.bump_around > 0: - lobes.append(p.bump_around) - N = max(math.ceil(TAU * r_est / step), per * max(lobes), 96) - M = max(math.ceil(H / step), (8 if self.draft else 16) * max(p.ring_count, 1), 24) - if p.bump_around > 0: - M = max(M, (8 if self.draft else 16) * p.bump_up) - if p.organic_depth > 0: # a few points across every lump - fine = max(p.organic_scale, 2.0) / (5 if self.draft else 10) - N = max(N, math.ceil(TAU * r_est / fine)) - M = max(M, math.ceil(H / fine)) - if self.fn is not None: - N = max(N, 180 if self.draft else 480) - limit = 140_000 if self.draft else 1_200_000 - if N * M > limit: - f = math.sqrt(limit / (N * M)) - N, M = max(int(N * f), 64), max(int(M * f), 16) - spacing = step / f - if not self.draft and spacing > 0.4: # finer than that, nobody would see it - self.notes.append(f"The surface detail was capped at about {spacing:.1f} mm to keep the mesh a " - "manageable size.") - self.N = (N + 3) // 4 * 4 - self.M = M - - # ----------------------------------------------------------- surface - def twist_at(self, h): - """How far the shape has turned at height h (radians).""" - p = self.p - if p.twist_style == OWN: - deg = progress(OWN, h, p.twist_curve) - else: - deg = p.twist * progress(p.twist_style, h, swings=p.swings) - return np.radians(deg) - - def _texture(self, frame, z, mean_r: float): - """The surface patterns in mm, out (+) or in, at angles ``frame`` (rows, columns) and heights z.""" - p = self.p - h = z / self.H - tex = np.zeros(np.broadcast(frame, z[:, None]).shape) - if p.rib_count > 0 and p.rib_depth != 0: - k = 1.0 + 7.0 * p.rib_sharp ** 2 - lean = math.radians(p.rib_lean) * h[:, None] - rib = (0.5 * (1.0 + np.cos(p.rib_count * (frame - lean)))) ** k - if p.rib_cross: - rib = np.maximum(rib, (0.5 * (1.0 + np.cos(p.rib_count * (frame + lean)))) ** k) - tex += p.rib_depth * rib - if p.ring_count > 0 and p.ring_depth != 0: - k = 1.0 + 7.0 * p.ring_sharp ** 2 - tex += p.ring_depth * ((0.5 * (1.0 - np.cos(TAU * p.ring_count * h))) ** k)[:, None] - if p.bump_around > 0 and p.bump_depth != 0: - tex += p.bump_depth * (0.5 * (1.0 + np.cos(p.bump_around * frame) - * np.cos(TAU * max(p.bump_up, 1) * h)[:, None])) ** 1.5 - if p.organic_depth > 0: - tex += p.organic_depth * organic(frame, z[:, None], p.organic_scale, mean_r, p.organic_seed) - return tex - - def _envelope(self, z): - """How strong the surface patterns are at each height: the strength curve, faded at foot and rim.""" - p = self.p - env = strength(p.strength, z / self.H, p.strength_curve) - if p.fade_foot > 0: - env = env * smoothstep(z / p.fade_foot) - if p.fade_rim > 0: - env = env * smoothstep((self.H - z) / p.fade_rim) - return env - - def _surface(self): - p, H, N, M = self.p, self.H, self.N, self.M - self.z = z = np.linspace(0.0, H, M + 1) - self.theta = theta = np.arange(N) * (TAU / N) - self.dz, self.dth = H / M, TAU / N - h = z / H - sil = np.asarray(p.silhouette, dtype=float) - R0, q = self._dome(z, 0.5 * pchip(sil[:, 0], sil[:, 1], h)) - self.tau = tau = self.twist_at(h) - TH, TA = theta[None, :], tau[:, None] - - tab1 = blur(shape_table(p.shape1, p.count1, p.depth1, p.sharp1), self.dth) - if p.shape2 != SAME: - tab2 = blur(shape_table(p.shape2, p.count2, p.depth2, p.sharp2), self.dth) - mix = change(p.morph, h, p.morph_curve, p.morph_swings)[:, None] - mix = np.clip(mix, 0.0, 1.0) - a1, a2 = math.radians(p.turn1), math.radians(p.turn2) - - def section(angle): - s = sample(tab1, angle - a1) - if p.shape2 != SAME: - s = (1.0 - mix) * s + mix * sample(tab2, angle - a2) - return s - - s = section(TH - TA) - if p.lattice: - # round the crossing ridges over a couple of mesh columns, so they don't alias - steep = float(np.abs(np.diff(tab1)).max()) * TABLE / TAU - s = smooth_max(s, section(TH + TA), 3.0 * self.dth * steep) - r = R0[:, None] * s - - frame = TH - TA if p.texture_twist else np.broadcast_to(TH, r.shape) - if p.even_spacing: - frame = self._round_the_wall(r, tau if p.texture_twist else np.zeros_like(tau)) - r = r + (q * self._envelope(z))[:, None] * self._texture(frame, z, float(R0.mean())) - if p.nose == RAISED_NOSE: - # along the surface from the front (-y: the column a quarter turn before the start) - front = r[:, 3 * N // 4] - s = (np.mod(TH - 1.5 * math.pi + math.pi, TAU) - math.pi) * front[:, None] - r = r + hollow.nose(s, z, p, 0.9 * math.tan(math.radians(p.max_overhang))) - - if self.fn is not None: - env_vars = dict(theta=np.broadcast_to(TH, r.shape), phi=frame, z=z[:, None], h=h[:, None], r=r, - H=np.float64(H)) - add = self.fn(**env_vars) - # does the function meet itself round the back? Try it a whole turn on. - wrap = self.fn(theta=np.full((M + 1, 1), TAU), phi=frame[:, :1] + TAU, z=z[:, None], - h=h[:, None], r=r[:, :1], H=np.float64(H)) - if float(np.abs(wrap[:, 0] - add[:, 0]).max()) > 0.05: - self.warnings.append( - "Your own function doesn't meet itself round the vase, so there is a step " - "up the back. Use whole numbers with the angle, e.g. sin(5*theta), not sin(2.5*theta).") - r = r + add - if p.closed_top: # nothing sticks out past the point, patterns and all - r = np.minimum(r, self._cone(z)[:, None]) - - floor_r = max(1.5, 2.0 * self.wall) - near = r < floor_r - if p.closed_top: # a closed top's point comes in to it, as it should - near &= (self._cone(z) > 2.0 * floor_r)[:, None] - if near.any(): - self.notes.append(f"Where the shape came near the middle it is kept {floor_r:.1f} mm out from it.") - self.R_design = np.maximum(r, floor_r) - - def _round_the_wall(self, r, start): - """How far round the wall each grid point is, as an angle - 2 pi all the way round - measured along the - wall from the direction ``start`` (each row's), rather than as the angle from the middle: patterns laid - out by it are as close together on a box's long sides as round its ends.""" - N = self.N - c, s = np.cos(self.theta), np.sin(self.theta) - x, y = r * c, r * s - step = np.hypot(np.roll(x, -1, axis=1) - x, np.roll(y, -1, axis=1) - y) - along = np.concatenate([np.zeros((len(r), 1)), np.cumsum(step, axis=1)], axis=1) # (rows, N + 1) - share = along / along[:, -1:] * TAU - f = np.mod(start, TAU) / self.dth # where each row starts, between two columns - i0 = np.floor(f).astype(np.intp) % N - t = f - np.floor(f) - rows = np.arange(len(r)) - first = share[rows, i0] * (1.0 - t) + share[rows, i0 + 1] * t - return share[:, :N] - first[:, None] - - def _cone(self, z): - """A closed top's point: the widest the vase may be at each height, leaning in at a little under the - overhang limit up to a point the width of the narrowest the wall may come to the axis.""" - tip = max(1.5, 2.0 * self.wall) - return tip + (self.H - z) * 0.92 * math.tan(math.radians(self.p.max_overhang)) - - def _dome(self, z, R0): - """The outline's radius at each height, closed over in a point if the top is closed; and how far each - height was narrowed (1: not at all), for the patterns to shrink with it.""" - if not self.p.closed_top: - return R0, np.ones_like(R0) - # the lesser of the outline and the point, rounded where they meet (never leaning more than either) - cone = self._cone(z) - k = 0.12 * float(R0.max()) - capped = np.minimum(R0, cone) - k * np.log1p(np.exp(-np.abs(R0 - cone) / k)) - q = capped / np.maximum(R0, 1e-9) - narrowed = np.nonzero(q < 0.999)[0] - self.dome_from = float(z[narrowed[0]]) if len(narrowed) else self.H - return capped, q - - # ----------------------------------------------------- printable solid - def _rows(self, R): - """The low-poly rings: (z, angles, radii) for each row of facets.""" - p, H = self.p, self.H - rng = np.random.default_rng(int(p.facet_seed)) - Nf, Mf = max(int(p.facets_around), 3), max(int(p.facets_up), 1) - rows = [] - for j in range(Mf + 1): - zj = H * j / Mf - a = TAU * np.arange(Nf) / Nf + (j % 2) * math.pi / Nf + float(self.twist_at(np.array([zj / H]))[0]) - if p.facet_rough > 0: - a = a + rng.uniform(-0.35, 0.35, Nf) * p.facet_rough * TAU / Nf - r = self._sample(R, zj, a) - if p.facet_rough > 0: - r = r * (1.0 + rng.uniform(-0.05, 0.05, Nf) * p.facet_rough) - rows.append((zj, a, r)) - return rows - - def _sample(self, R, zq, aq): - """The radius between the grid points (bilinear, round the back too).""" - fj = min(max(zq / self.dz, 0.0), self.M) - j0 = min(int(fj), self.M - 1) - tj = fj - j0 - fi = np.mod(aq, TAU) / self.dth - i0 = np.floor(fi).astype(np.int64) - ti = fi - i0 - i0 %= self.N - i1 = (i0 + 1) % self.N - lo = R[j0, i0] * (1 - ti) + R[j0, i1] * ti - hi = R[j0 + 1, i0] * (1 - ti) + R[j0 + 1, i1] * ti - return lo * (1 - tj) + hi * tj - - def _solid(self, R): - """The solid to export from a surface: (verts, faces, band of each wall face, wall face count, rows).""" - rows = self._rows(R) if self.p.facets else None - if rows is None: - verts, faces, band, first, last = grid_side(self.z, self.theta, R) - else: - verts, faces, band, first, last = loft_side(rows) - nv = len(verts) - verts = np.concatenate([verts, [[0.0, 0.0, 0.0], [0.0, 0.0, self.H]]]) - side = len(faces) - faces = np.concatenate([faces, fan(nv, first, up=False), fan(nv + 1, last, up=True)]) - return verts, faces, band, side, rows - - def _soften(self, target, scale): - """The design softened, with the correction smoothed round the vase and softened again. - - Each column is softened on its own, so on its own the correction can - differ a little from column to column and leave fine streaks; smoothing - it sideways and softening once more keeps the surface clean. - """ - base = self.R_base - R = soften(base, self.dth, self.dz, target, scale) - for _ in range(2): - fix = blur_around(R - base, 2.0) - R = soften(base + fix, self.dth, self.dz, target, scale) - return R - - def _printable(self): - p = self.p - target = p.max_overhang - scale = np.ones_like(self.R_design) # where the limit has to be tighter than it looks - R = self.R_design - self.R_base = self.R_design - if p.soften: # a fast twist first: gentler flanks, layer by layer - omega = np.abs(np.gradient(self.tau, self.dz)) - self.R_base = ease_twist(self.R_design, self.dth, omega, p.max_overhang) - for attempt in range(14 if p.soften else 1): - R = self._soften(target, scale) if p.soften else self.R_design - verts, faces, band, side, rows = self._solid(R) - lean = lean_angles(verts, faces[:side]) - worst = float(lean.max()) - if worst <= p.max_overhang + TOLERANCE or not p.soften: - break - if rows is None: - # tighten only round the faces that still lean too far - # by as much as each face is over, spread a little and feathered over a few columns - # and rows, so the tightening itself leaves no crease - over = lean > p.max_overhang + 0.5 * TOLERANCE - cut = 1.0 - math.tan(math.radians(p.max_overhang - 0.25)) / np.tan(np.radians(lean[over])) - cut = np.clip(cut + 0.03, 0.03, 0.6) - corner = faces[:side][over].ravel() - keep = corner < (self.M + 1) * self.N - j, i = np.divmod(corner[keep], self.N) - tight = np.zeros_like(scale) - np.maximum.at(tight, (j, i), np.repeat(cut, 3)[keep]) - for axis in (0, 1): - tight = np.maximum(tight, np.maximum(np.roll(tight, 1, axis), np.roll(tight, -1, axis))) - tight = blur_around(tight, 1.5) - scale *= 1.0 - np.clip(tight, 0.0, 0.6) - if attempt >= 6: # still not there: ask a little less everywhere too - target -= max(1.0, worst - p.max_overhang) - else: - # low-poly: the facets cut across the softened surface, so ask for less everywhere - target -= max(0.75, worst - p.max_overhang) - if target < 2.0: - break - self.R, self.rows = R, rows - self.verts, self.faces, self.side = verts, faces, side - self.band, self.lean = band, lean - self.worst = worst - self.softened = float(np.abs(R - self.R_design).max()) if p.soften else 0.0 - self.row_moved = np.abs(R - self.R_design).max(axis=1) if rows is None else None - if not self.draft: - self.closed = edge_check(faces)[0] - nb = int(band.max()) + 1 - self.band_lean = np.zeros(nb) - np.maximum.at(self.band_lean, band, lean) - self.triangles = len(faces) - lo, hi = verts.min(0), verts.max(0) - self.size = hi - lo - self.top = self.H - - # --------------------------------------------------------- shown shell - @property - def wall(self) -> float: - """How thick the printed wall is: one spiral line, or a fake vase's walls side by side.""" - p = self.p - return p.line_width * (max(int(p.walls), 1) if p.vase_mode == FAKE_VASE else 1) - - @property - def normal(self) -> bool: - """Printed normally, with walls - rather than seamless, in spiral vase mode.""" - return self.p.vase_mode == FAKE_VASE - - def _pot(self, O, I): - """What the printer makes: the hollow pot - the outer face O and inner face I (K, N, 2) at the grid's - heights, the floor or an open bottom, the rim - with any holes cut right through. For a normal print it - is what is exported; seamless, it is what the single line makes, for the 3D view.""" - p = self.p - zb = 0.0 if p.open_bottom else min(max(p.bottom, p.layer_height), 0.5 * self.H) - self.zb = zb - f = self._holes(O, zb) - if p.open_bottom: - zs, (Os, Is, fs), kb = self.z, (O, I, f), None - else: - zs, (Os, Is, fs), at = hollow.insert_rows(self.z, [O, I, f], [zb]) - kb = at[zb] - if p.box: # the ledges a lid clicks in behind - heights = ledge_heights(p) - zs, (Os, Is, fs), _ = hollow.insert_rows(zs, [Os, Is, fs], [self.H - h for h in heights]) - Is = ledges(zs, Is, self.H, heights) - b = None - if p.hole_swell > 0 and (fs > 0).any(): - # rounded edges: the wall swells out and in round every hole, curving from outside to in - far = max(6.0, 0.5 * p.hole_size) if p.holes != NO_HOLES else 6.0 - b = hollow.swell(fs, zs, p.hole_swell, far, 0.6 * math.tan(math.radians(p.max_overhang))) - kr = self._roof(zs, Os, kb) if p.closed_top else None - if p.box and p.tissue_hole != NO_TISSUE and kb is not None: - self.floor_hole = self._tissue_hole(Is[kb]) - if not self.draft: - # leave out the rows and columns that lie in line with their neighbours: far fewer triangles where the - # wall is flat or straight, every one kept where a pattern, a hole or a curve needs it - b = np.zeros_like(fs) if b is None else b - zs, Os, Is, fs, b, rk = hollow.simplify(zs, Os, Is, fs, b, [k for k in (kb, kr) if k is not None]) - kb = None if kb is None else int(np.searchsorted(rk, kb)) - kr = None if kr is None else int(np.searchsorted(rk, kr)) - verts, faces, kinds = hollow.pot(zs, Os, Is, fs, floor_row=kb, roof_row=kr, swell=b, - floor_hole=self.floor_hole) - kinds, self.bridge, side_worst = hollow.bridges(verts, faces, kinds, p.max_overhang, - longest=max(8.0, self.bridge_allowed + 1.0)) - self.pot_rows = (zs, Os, Is, fs, kb, kr, np.zeros_like(fs) if b is None else b) - lo, hi = (0 if kb is None else kb), (len(zs) - 1 if kr is None else kr) - if kr is not None: - self.ceiling = hollow.bridge_span(Is[kr]) - fs = np.where(np.arange(len(zs))[:, None] >= kr, np.minimum(fs, -1e-9), fs) - # the head it fits: round the inside, where the head goes in - the narrowest place below the - # widest; a mask, the widest place of the head it is shaped round - per = hollow.row_lengths(Is[lo:hi + 1])[2] - widest = int(np.argmax(per)) - fit = float(per.max() if p.around < 360.0 else per[:widest + 1].min()) - self.head_fit = max(fit - HEAD_ROOM, 0.0) - self.holes_open = bool((fs > 0).any()) - self.hole_share = float((fs > 0).mean()) if self.holes_open else 0.0 - self._set_shell(verts, faces, kinds) - self.worst = max(self.worst, side_worst) - if self.normal: # what is exported, and what the panel counts - self.triangles = len(faces) - self.size = verts.max(0) - verts.min(0) - if not self.draft: - self.closed = bool(self.closed) and edge_check(faces)[0] - self.inner_rows = [(zz, np.arctan2(r[:, 1], r[:, 0]), np.hypot(r[:, 0], r[:, 1])) - for zz, r in zip(zs[lo:hi + 1], Is[lo:hi + 1])] - - def _tissue_hole(self, inside): - """The hole in a tissue box cover's top for the tissues to come out of, as a loop round the middle, - anticlockwise - kept TISSUE_MARGIN clear of the walls inside. None if there is no room for it.""" - p = self.p - along_y = p.turn1 % 180.0 > 45.0 # the box's length runs along y - half = np.abs(np.asarray(inside, dtype=float)).max(axis=0) - TISSUE_MARGIN - long_room, short_room = (half[1], half[0]) if along_y else (half[0], half[1]) - w = min(0.5 * p.tissue_width, short_room) - length = w if p.tissue_hole == TISSUE_ROUND else max(min(0.5 * p.tissue_length, long_room), w) - if p.tissue_hole == TISSUE_ROUND: - w = length = min(w, long_room) - if w < 3.0: - self.notes.append("There is no room in the top for the tissue hole.") - return None - if w < 0.5 * p.tissue_width - 0.05 or (p.tissue_hole != TISSUE_ROUND and length < 0.5 * p.tissue_length - 0.05): - self.notes.append(f"The tissue hole was made smaller, to keep {TISSUE_MARGIN:g} mm of the top round it.") - step = max(p.facet_size, 0.3) * (2.6 if self.draft else 1.0) - if p.tissue_hole == TISSUE_OVAL: - n = max(int(2.0 * math.pi * max(length, w) / step), 48) - t = np.arange(n) * (TAU / n) - xs, ys = length * np.cos(t), w * np.sin(t) - else: # a slot: round ends; a circle - n = max(int((4.0 * length + 2.3 * w) / step), 48) - xs, ys = rounded_rectangle(length, w, w, per=n) - self.tissue = (2.0 * length, 2.0 * w) - return np.stack([-ys, xs] if along_y else [xs, ys], axis=1) - - def _roof(self, zs, Os, kb): - """The row a closed top's ceiling is on: as high as leaves the point above it as thick as the floor, - where the wall still has room inside it.""" - p = self.p - rmin = np.hypot(Os[..., 0], Os[..., 1]).min(axis=1) - top = self.H - max(p.bottom, 2.0 * p.layer_height) - ok = np.nonzero((rmin >= self.wall + 0.6) & (zs <= top + 1e-9) & (np.arange(len(zs)) > (kb or 0) + 1))[0] - if not len(ok): - self.notes.append("The vase is too small to close over at the top: it is left open.") - return None - return int(ok[-1]) - - def _set_shell(self, verts, faces, kinds=None): - """The shell to show, and how far each face leans: a hole's side only counts where it leans out over - the hole (its sill faces up, and prints as it is); the bridge at a hole's very top is as steep as allowed.""" - lean = lean_angles(verts, faces) - if kinds is not None: - over = np.degrees(np.arcsin(np.clip(-face_normals(verts, faces)[:, 2], 0.0, 1.0))) - lean = np.where(kinds == hollow.SIDE, over, lean) - lean = np.where(kinds == hollow.TIP, np.minimum(over, self.p.max_overhang), lean) - lean = np.where(kinds == hollow.CAP, -1.0, lean) - self.shell_verts, self.shell_faces, self.shell_kind, self.shell_lean = verts, faces, kinds, lean - - def _holes(self, O, zb): - """The hole pattern on the grid: how far inside a hole each point is (mm, below nought in the wall), - with pointed tops that print without support.""" - p = self.p - K, N = O.shape[:2] - f = np.full((K, N), -1.0) - if not self.normal: - return f - seg, along, L = hollow.row_lengths(O) - slope = 0.85 * math.tan(math.radians(p.max_overhang)) - face = None - if face_holes(p) or p.around < 360.0: - face = hollow.openings(hollow.from_front(O, seg, along, L), self.z, L, p) - if (face > 0).any(): - face = hollow.pointed_tops(face, self.z, O, slope) - f = face - bridge = None - if p.holes != NO_HOLES: - # a strut's width of wall round the face and along the edges - room = None if face is None else -face - max(p.hole_strut, 2.0) - pattern, spans = self._pattern_holes(O, zb, seg, along, L, room) - self.pattern_open = bool((pattern > 0).any()) - f = pattern if face is None else np.maximum(face, pattern) - if spans is not None: # Voronoi ovals: their round tops close over a short bridge - bridge = np.where(pattern > 0, spans, 0.0) - self.bridge_allowed = float(bridge.max()) - if not (f > 0).any(): - return f - return hollow.pointed_tops(f, self.z, O, slope, bridge) - - def _pattern_holes(self, O, zb, seg, along, L, room=None): - """The pattern of holes, Voronoi, round, diamonds or slots, as a hole field - only where room is above - nought, if given.""" - p = self.p - K, N = O.shape[:2] - # the holes turn with the twist - but no faster than a wall may lean, so their sides stay printable - turn = np.zeros(K) - if p.texture_twist: - r = np.maximum(L / TAU, 1.0) - turn = limit_slope(self.tau.astype(float), 0.6 * math.tan(math.radians(p.max_overhang)) - * np.diff(self.z) / np.minimum(r[1:], r[:-1])) - # measured round each row from the point at that angle - ang = np.arctan2(O[..., 1], O[..., 0]) - c0 = np.argmin(np.abs(np.mod(ang - turn[:, None] + math.pi, TAU) - math.pi), axis=1) - start = along[np.arange(K), c0] - t = np.mod((along - start[:, None]) / L[:, None], 1.0) - z_lo = zb + max(p.hole_foot, 0.0) - z_hi = (self.dome_from if p.closed_top else self.H) - max(p.hole_rim, LEDGE_CLEAR if p.box else 0.0) - f, spans = hollow.hole_field(p.holes, t, self.z, L, p.hole_size, p.hole_strut, p.hole_jitter, p.hole_seed, - z_lo, z_hi, room, 0.85 * math.tan(math.radians(p.max_overhang)), - min(VORONOI_BRIDGE * p.hole_size, VORONOI_LONGEST)) - if not (f > 0).any(): - self.notes.append("There is no room for the holes between the plain foot and rim: make the vase " - "taller, the holes smaller, or the plain foot and rim less.") - return f, spans - - def print_mesh(self): - """The vase as one closed mesh for the slicer: the hollow pot for a normal print, the solid for - spiral vase mode.""" - if self.normal: - return self.shell_verts, self.shell_faces - return self.verts, self.faces - - def export_mesh(self): - """The mesh to save for the slicer: print_mesh, simplified (see ``lighter``).""" - return lighter(*self.print_mesh()) - - def export_parts(self): - """The parts to save for the slicer: print_parts, each simplified (see ``lighter``).""" - return [(fil, *lighter(v, f)) for fil, v, f in self.print_parts()] - - def print_parts(self): - """The vase for the slicer as [(filament, verts, faces)]: printed normally, the hollow pot, a part for - each band of colour; seamless, the solid.""" - if not self.normal: - return self.band_parts() - if self.pot_rows is None: - return self._loft_parts() - zs, Os, Is, fs, kb, kr, swollen = self.pot_rows - floor = kb is not None - # bands start on top of the floor, and the solid point of a closed top is one with the band below it - lowest = (zs[kb] if floor else 0.0) + 0.5 * self.p.layer_height - highest = self.H if kr is None else zs[kr] - 0.5 * self.p.layer_height - tops = sorted({max(z, lowest) for z, _ in self.bands[1:] if z < min(self.H - 1e-6, highest)}) - z2, (O2, I2, f2, s2), at = hollow.insert_rows(zs, [Os, Is, fs, swollen], tops) - kb2 = int(np.argmin(np.abs(z2 - zs[kb]))) if floor else None - kr2 = None if kr is None else int(np.argmin(np.abs(z2 - zs[kr]))) - bounds = [0] + [at[t] for t in tops] + [len(z2) - 1] - fils = [fil for _, fil in self.bands][:len(bounds) - 1] - parts = [] - for fil, a, b in zip(fils, bounds[:-1], bounds[1:]): - rows = slice(a, b + 1) - verts, faces, _ = hollow.pot(z2[rows], O2[rows], I2[rows], f2[rows], - floor_row=kb2 if (floor and a == 0) else None, - roof_row=kr2 - a if (kr2 is not None and b == len(z2) - 1) else None, - swell=s2[rows], floor_hole=self.floor_hole if a == 0 else None) - parts.append((fil, verts, faces)) - return parts - - def _loft_parts(self): - """Low-poly, printed normally: the facets' hollow shell, a part for each band of colour.""" - w, zb = self.wall, self.zb - tops = [zs for zs, _ in self.bands[1:]] + [self.H] - parts = [] - for (lo, fil), hi in zip(self.bands, tops): - lo = max(lo, zb + 0.5 * self.p.layer_height) if lo > 0 else 0.0 - rings = [self._ring_at(lo)] + [r for r in self.rows if lo + 1e-6 < r[0] < hi - 1e-6] + [self._ring_at(hi)] - if lo == 0.0: - inner = [self._ring_at(zb)] + [r for r in rings if r[0] > zb + 1e-6] - else: - inner = rings - inner = [(zz, a, inset(a, r, w)) for zz, a, r in inner] - o_v, o_f, _, o_first, o_last = loft_side(rings) - i_v, i_f, _, i_first, i_last = loft_side(inner) - no, ni = len(o_v), len(i_v) - verts = np.concatenate([o_v, i_v, [[0.0, 0.0, 0.0], [0.0, 0.0, zb]]]) - faces = [o_f, i_f[:, ::-1] + no, annulus(o_last, i_last + no)] - if lo == 0.0: - faces += [fan(no + ni, o_first, up=False), fan(no + ni + 1, i_first + no, up=True)] - else: - faces.append(annulus(o_first, i_first + no)[:, ::-1]) - parts.append((fil, verts, np.concatenate(faces))) - return parts - - def _shell(self): - """What the printer really makes: the wall over a solid bottom, for the 3D view.""" - p = self.p - if needs_normal(p) or p.closed_top: - self.notes.append("Holes, a face, open ends and a closed top work on smooth shapes, not low-poly " - "facets.") - w = self.wall - zb = min(max(p.bottom, p.layer_height), 0.5 * self.H) - self.zb = zb - if self.rows is None: - z, R = self.z, self.R - keep = z > zb + 0.3 * self.dz - fj = zb / self.dz - j0 = min(int(fj), self.M - 1) - first = R[j0] + (R[j0 + 1] - R[j0]) * (fj - j0) - zi = np.concatenate([[zb], z[keep]]) - Ri = inset(self.theta, np.vstack([first, R[keep]]), w) - o_verts, o_faces, _, o_first, o_last = grid_side(z, self.theta, R) - i_verts, i_faces, _, i_first, i_last = grid_side(zi, self.theta, Ri) - self.inner_rows = [(zz, self.theta, rr) for zz, rr in zip(zi, Ri)] - else: - rows = self.rows - inner = [(zb, rows[0][1], inset(rows[0][1], rows[0][2], w))] - inner += [(zz, a, inset(a, r, w)) for zz, a, r in rows[1:] if zz > zb + 0.3] - o_verts, o_faces, _, o_first, o_last = loft_side(rows) - i_verts, i_faces, _, i_first, i_last = loft_side(inner) - self.inner_rows = inner - no, ni = len(o_verts), len(i_verts) - verts = np.concatenate([o_verts, i_verts, [[0.0, 0.0, 0.0], [0.0, 0.0, zb]]]) - cb, cf = no + ni, no + ni + 1 - walls = np.concatenate([o_faces, i_faces[:, ::-1] + no]) - caps = np.concatenate([fan(cb, o_first, up=False), fan(cf, i_first + no, up=True), - annulus(o_last, i_last + no)]) - self.shell_verts = verts - self.shell_faces = np.concatenate([walls, caps]) - self.shell_kind = None - self.shell_lean = np.concatenate([lean_angles(verts, walls), np.full(len(caps), -1.0)]) - if self.normal: # what is exported, and what the panel counts - self.triangles = len(self.shell_faces) - - # ======================================================= drawn outlines - def _outline(self, k: int): - """Cross-section k (1 at the bottom, 2 at the top) as a loop of points, widest at radius 1.""" - p = self.p - kind = getattr(p, f"shape{k}") - if kind == DRAWN: - pts = getattr(p, f"drawn{k}") - loop = sections.spline(pts, getattr(p, f"repeat{k}")) - hits = sections.piece_crossings(loop, 48 * len(pts)) - if hits: - x, y = hits[0] - where = "bottom" if k == 1 else "top" - self.errors.append(f"The drawn {where} outline crosses itself (near {x:.2f}, {y:.2f} in the " - "drawing): move its points apart so it makes one simple loop.") - else: - loop = sections.from_table(shape_table(kind, getattr(p, f"count{k}"), getattr(p, f"depth{k}"), - getattr(p, f"sharp{k}"))) - loop = sections.normalised(loop) - a = math.radians(getattr(p, f"turn{k}")) - c, s = math.cos(a), math.sin(a) - return loop @ np.array([[c, s], [-s, c]]) - - def _free_surface(self): - """The designed surface of a vase with a drawn outline: rows of points (M + 1, N, 2).""" - p = self.p - self.H = H = float(p.height) - loops = [self._outline(1)] + ([self._outline(2)] if p.shape2 != SAME else []) - if self.errors: - return - sil = np.asarray(p.silhouette, dtype=float) - tex = ((abs(p.rib_depth) if p.rib_count > 0 else 0.0) + (abs(p.ring_depth) if p.ring_count > 0 else 0.0) - + (abs(p.bump_depth) if p.bump_around > 0 else 0.0) + 1.2 * max(p.organic_depth, 0.0)) - self.tex_depth = tex - perimeter = max(float(np.linalg.norm(np.roll(L, -1, axis=0) - L, axis=1).sum()) for L in loops) - step = max(p.facet_size, 0.15) * (2.6 if self.draft else 1.0) - per = 10 if self.draft else 22 - reps = max([getattr(p, f"repeat{k}") for k in (1, 2) if getattr(p, f"shape{k}") == DRAWN] + [1]) - N = max(math.ceil(perimeter * (0.5 * sil[:, 1].max() + tex) / step), - per * max(p.rib_count * (2 if p.rib_cross else 1), p.bump_around, reps), 160) - M = max(math.ceil(H / step), (8 if self.draft else 16) * max(p.ring_count, 1), 24) - if p.bump_around > 0: - M = max(M, (8 if self.draft else 16) * p.bump_up) - if p.organic_depth > 0: - fine = max(p.organic_scale, 2.0) / (5 if self.draft else 10) - N = max(N, math.ceil(perimeter * 0.5 * sil[:, 1].max() / fine)) - M = max(M, math.ceil(H / fine)) - if self.fn is not None: - N = max(N, 180 if self.draft else 480) - limit = 140_000 if self.draft else 1_200_000 - if N * M > limit: - f = math.sqrt(limit / (N * M)) - N, M = max(int(N * f), 64, 8 * reps), max(int(M * f), 16) # each repeat keeps a few points - self.N, self.M = (N + 3) // 4 * 4, M - self.z = z = np.linspace(0.0, H, M + 1) - self.dz = H / M - h = z / H - self.C1 = sections.resample(loops[0], self.N) - self.C2 = sections.resample(loops[1], self.N) if len(loops) > 1 else None - self.R0_d, q = self._dome(z, 0.5 * pchip(sil[:, 0], sil[:, 1], h)) - self.tau = self.tau_d = self.twist_at(h) - self.mix_d = (np.clip(change(p.morph, h, p.morph_curve, p.morph_swings), 0.0, 1.0) if self.C2 is not None - else np.zeros_like(h)) - self.env_d = self._envelope(z) * q # the patterns shrink with a closed top's point - if p.lattice and (p.twist_style == OWN or abs(p.twist) > 1e-9): - self.notes.append("Both ways (lattice) works on the built-in shapes only: a drawn outline twists one way.") - if p.facets: - self.notes.append("Low-poly facets work on the built-in shapes only: a drawn outline stays smooth.") - self.P_design = self._free_points(self.R0_d, self.tau_d, self.mix_d, self.env_d) - # a blend of two outlines, or deep patterns in a tight corner, can make a layer cross itself - for j in np.linspace(0, M, 7).astype(int): - if sections.crossings(self.P_design[j], limit=max(700, 12 * reps)): - self.warnings.append(f"The outline crosses itself at about {z[j]:.0f} mm up (where the shapes " - "blend, or patterns meet in a tight corner): the slicer may fill it wrongly " - "there. Ease the change of shape or the patterns.") - break - - def _free_points(self, R0, tau, mix, env): - """The surface's points: the outline blended, sized, turned, then patterned square to the wall.""" - p = self.p - base = (self.C1[None] if self.C2 is None - else (1.0 - mix)[:, None, None] * self.C1 + mix[:, None, None] * self.C2) - P = base * R0[:, None, None] - c, s = np.cos(tau)[:, None], np.sin(tau)[:, None] - x, y = P[..., 0], P[..., 1] - P = np.stack([x * c - y * s, x * s + y * c], -1) - # patterns run round the outline (turning with it), or round the axis if they shouldn't turn - along = np.arange(self.N) * (TAU / self.N) - frame = (np.broadcast_to(along, P.shape[:2]) if p.texture_twist - else np.mod(np.arctan2(P[..., 1], P[..., 0]), TAU)) - out = sections.normals(P) - tex = env[:, None] * self._texture(frame, self.z, float(R0.mean())) - if self.fn is not None: - tex = tex + self.fn(theta=np.mod(np.arctan2(P[..., 1], P[..., 0]), TAU), phi=frame, - z=self.z[:, None], h=(self.z / self.H)[:, None], r=np.hypot(P[..., 0], P[..., 1]), - H=np.float64(self.H)) - if p.nose == RAISED_NOSE: - s = (np.mod(np.arctan2(P[..., 1], P[..., 0]) + 0.5 * math.pi + math.pi, TAU) - math.pi) * R0[:, None] - tex = tex + hollow.nose(s, self.z, p, 0.9 * math.tan(math.radians(p.max_overhang))) - return P + tex[..., None] * out - - def _free_solid(self, P): - """The closed solid from rows of outline points: (verts, faces, band of each wall face, wall face count).""" - verts, faces, band, first, last = grid_side_xy(self.z, P) - side = len(faces) - bottom = first[sections.triangulate(P[0])][:, ::-1] # facing down, onto the bed - top = last[sections.triangulate(P[-1])] - return verts, np.concatenate([faces, bottom, top]), band, side - - def _free_printable(self): - """Soften a drawn vase until no face leans past the limit. - - A drawn outline itself stands upright; only the changes up the vase - lean it - the outline widening or narrowing, the twist, the change of - shape and the patterns. Where faces lean too far, those changes are - slowed at that height (the same envelopes as for the columns of a - radial vase) and the patterns eased, until every face is within it. - """ - p = self.p - R0, tau, mix, env = self.R0_d.copy(), self.tau_d.copy(), self.mix_d.copy(), self.env_d.copy() - aim = math.tan(math.radians(max(p.max_overhang - 0.4, 1.0))) - # patterns fade in and out over a few layers: a sudden change of depth would lean the wall itself - fade = 0.1 * aim * self.dz / max(self.tex_depth, 1e-6) - P = self.P_design - for attempt in range(24 if p.soften else 1): - if attempt: - P = self._free_points(R0, tau, mix, env) - verts, faces, band, side = self._free_solid(P) - lean = lean_angles(verts, faces[:side]) - worst = float(lean.max()) - if worst <= p.max_overhang + TOLERANCE or not p.soften: - break - over = np.zeros(self.M) - np.maximum.at(over, band, lean) - f = np.where(over > p.max_overhang, aim / np.tan(np.radians(np.minimum(over, 89.0))), 1.0) - f = np.clip(f, 0.1, 1.0) - pad = np.concatenate([[f[0]] * 2, f, [f[-1]] * 2]) - f = np.minimum.reduce([pad[k:k + self.M] for k in range(5)]) # a little room each side - R0 = limit_slope(R0, np.abs(np.diff(R0)) * f) - tau = limit_slope(tau, np.abs(np.diff(tau)) * f) - mix = limit_slope(mix, np.abs(np.diff(mix)) * f) - env = fall_gently(env * np.minimum(np.concatenate([[f[0]], f]), np.concatenate([f, [f[-1]]])), fade) - self.P, self.tau, self.rows = P, tau, None - # what softening changed, in the design's own terms - with np.errstate(divide="ignore", invalid="ignore"): - kept = np.where(self.env_d > 1e-6, env / self.env_d, 1.0) - self.eased = dict(twist=float(np.degrees(np.abs(tau - self.tau_d)).max()), - width=float(2.0 * np.abs(R0 - self.R0_d).max()), - change=float(np.abs(mix - self.mix_d).max()), patterns=float(np.min(kept))) - self.verts, self.faces, self.side = verts, faces, side - self.band, self.lean, self.worst = band, lean, worst - moved = np.linalg.norm(P - self.P_design, axis=-1) - self.row_moved = moved.max(axis=1) - self.softened = float(moved.max()) if p.soften else 0.0 - if not self.draft: - self.closed = edge_check(faces)[0] - self.band_lean = np.zeros(self.M) - np.maximum.at(self.band_lean, band, lean) - self.triangles = len(faces) - self.size = verts.max(0) - verts.min(0) - self.top = self.H - - def _P_at(self, zq): - """The outline's points at height zq, between the rows.""" - fj = min(max(zq / self.dz, 0.0), self.M) - j = min(int(fj), self.M - 1) - return self.P[j] + (fj - j) * (self.P[j + 1] - self.P[j]) - - # ---------------------------------------------------------- the sums - def _ring_stats(self, rows): - """Perimeter and enclosed area of each ring.""" - per, area = [], [] - for _, a, r in rows: - x, y = r * np.cos(a), r * np.sin(a) - x1, y1 = np.roll(x, -1), np.roll(y, -1) - per.append(float(np.hypot(x1 - x, y1 - y).sum())) - area.append(float(0.5 * (x * y1 - x1 * y).sum())) - return np.array(per), np.array(area) - - def _measure(self): - p = self.p - w, lh = self.wall, p.layer_height - loops = max(int(p.walls), 1) if p.vase_mode == FAKE_VASE else 1 # lines round each layer - if self.outer_rows is not None: - outer = self.outer_rows - elif self.rows is not None: - outer = self.rows - else: - outer = [(zz, self.theta, rr) for zz, rr in zip(self.z, self.R)] - oz = np.array([o[0] for o in outer]) - o_per, o_area = self._ring_stats(outer) - iz = np.array([i[0] for i in self.inner_rows]) - _, i_area = self._ring_stats(self.inner_rows) - zb = self.zb - above = oz >= zb - zs = np.concatenate([[zb], oz[above]]) - ps = np.concatenate([[np.interp(zb, oz, o_per)], o_per[above]]) - wall_area = float(np.sum(0.5 * (ps[1:] + ps[:-1]) * np.diff(zs))) # mm² of wall, unrolled - bottom_vol = float(np.interp(0.0, oz, o_area)) * zb - self.capacity = float(np.sum(0.5 * (i_area[1:] + i_area[:-1]) * np.diff(iz))) / 1000.0 # ml - if self.holes_open or p.open_bottom or p.closed_top or p.box: - self.capacity = 0.0 - fil_area = math.pi * (p.filament_d / 2.0) ** 2 - vol = mesh_volume(self.shell_verts, self.shell_faces) # the plastic, holes and all - wall_area *= 1.0 - self.hole_share - self.grams = vol / 1000.0 * p.density - self.metres = vol / fil_area / 1000.0 - v = min(p.speed, p.max_flow / max(p.line_width * lh, 1e-6)) - layers_bottom = 0 if p.open_bottom else max(1, math.ceil(zb / lh - 1e-6)) - path = wall_area * loops / lh + layers_bottom * float(np.interp(0.0, oz, o_area)) / p.line_width - self.seconds = path / max(v, 0.1) + 120.0 - self.layers = int(round(self.H / lh)) - self.bottom_layers = layers_bottom - # filament used up to each height, for a gradient spool's colours - cum = np.concatenate([[0.0], np.cumsum(0.5 * (o_per[1:] + o_per[:-1]) * np.diff(oz))]) * w - cum += np.minimum(oz, zb) * float(np.interp(0.0, oz, o_area)) - self.filament_z, self.filament_at = oz, cum / fil_area # mm of filament - self.solid_volume = mesh_volume(self.verts, self.faces) / 1000.0 # cm³ - r0, rt = outer[0][2], self.inner_rows[-1][2] - self.base_d = (2 * float(r0.min()), 2 * float(r0.max())) - self.top_d = (2 * float(rt.min()), 2 * float(rt.max())) - self.widest = 2 * float(max(o[2].max() for o in outer)) - # for the profile picture: the widest and narrowest radius at each height - self.profile_z = oz - self.profile_max = np.array([o[2].max() for o in outer]) - self.profile_min = np.array([o[2].min() for o in outer]) - - # ----------------------------------------------------------- colours - def _colours(self): - """The filament for each height: [(from z, filament)], snapped to layers.""" - p = self.p - lh, H = p.layer_height, self.H - used = max(1, min(int(p.colours_used), 4)) - bands = [(0.0, 1)] - if colour_style_of(p) == BANDS: - k = max(int(p.band_count), 1) - gamma = 2.0 ** (1.3 * p.band_spacing) - bands = [(H * (i / k) ** gamma, 1 + i % used) for i in range(k)] - elif colour_style_of(p) == STRIPES: - stripe = max(p.stripe_height, lh) - n = math.ceil(H / stripe - 1e-9) - if n > MAX_BANDS: - stripe = H / MAX_BANDS - n = MAX_BANDS - self.warnings.append(f"Stripes that thin would be more than {MAX_BANDS} colour changes: " - f"they are made {stripe:.1f} mm tall.") - bands = [(i * stripe, 1 + i % used) for i in range(n)] - snapped = [] - for i, (zs, fil) in enumerate(bands): - zs = 0.0 if i == 0 else round(zs / lh) * lh - if zs >= H - 0.5 * lh: # starts at the very top: never printed - continue - if snapped and zs <= snapped[-1][0] + 0.5 * lh: - snapped[-1] = (snapped[-1][0], fil) # the band before has no layers: this one takes over - else: - snapped.append((zs, fil)) - merged = [] - for zs, fil in snapped: - if not merged or merged[-1][1] != fil: - merged.append((zs, fil)) - self.bands = merged or [(0.0, 1)] - self.gradient = colour_style_of(p) == GRADIENT - self.gradient_fils = list(range(1, used + 1)) - - def filament_of(self, k: int) -> str: - return getattr(self.p, f"color{k}").upper() - - def band_parts(self): - """The vase as one closed solid per colour band: [(filament, verts, faces)], bottom to top. - - Neighbouring bands meet on a shared flat face at the height where the - colour changes, so together they fill the vase exactly once - the way - a slicer wants the parts of a multi-colour object. - """ - tops = [zs for zs, _ in self.bands[1:]] + [self.H] - parts = [] - for (lo, fil), hi in zip(self.bands, tops): - if self.free: - inside = (self.z > lo + 1e-6) & (self.z < hi - 1e-6) - zs = np.concatenate([[lo], self.z[inside], [hi]]) - Ps = np.concatenate([self._P_at(lo)[None], self.P[inside], self._P_at(hi)[None]]) - verts, faces, _, first, last = grid_side_xy(zs, Ps) - faces = np.concatenate([faces, first[sections.triangulate(Ps[0])][:, ::-1], - last[sections.triangulate(Ps[-1])]]) - parts.append((fil, verts, faces)) - continue - if self.rows is None: - inside = (self.z > lo + 1e-6) & (self.z < hi - 1e-6) - zs = np.concatenate([[lo], self.z[inside], [hi]]) - Rs = np.vstack([self._row_at(lo), self.R[inside], self._row_at(hi)]) - verts, faces, _, first, last = grid_side(zs, self.theta, Rs) - else: - rings = [self._ring_at(lo)] + [r for r in self.rows if lo + 1e-6 < r[0] < hi - 1e-6] + [self._ring_at(hi)] - verts, faces, _, first, last = loft_side(rings) - n = len(verts) - verts = np.concatenate([verts, [[0.0, 0.0, lo], [0.0, 0.0, hi]]]) - faces = np.concatenate([faces, fan(n, first, up=False), fan(n + 1, last, up=True)]) - parts.append((fil, verts, faces)) - return parts - - def _row_at(self, zq): - """The surface's radii at height zq, between the grid's rows (straight up each column).""" - fj = min(max(zq / self.dz, 0.0), self.M) - j = min(int(fj), self.M - 1) - return self.R[j] + (fj - j) * (self.R[j + 1] - self.R[j]) - - def _ring_at(self, zq): - """The low-poly wall's cross-section at height zq, as a ring (z, angles, radii).""" - for row in self.rows: - if abs(row[0] - zq) < 1e-6: - return row - j = max(k for k, row in enumerate(self.rows) if row[0] < zq) - verts, faces, *_ = loft_side(self.rows[j:j + 2]) - tri = verts[faces] - d = tri[:, :, 2] - zq - pts = [] - for a, b in ((0, 1), (1, 2), (2, 0)): - hit = d[:, a] * d[:, b] < 0 - t = d[hit, a] / (d[hit, a] - d[hit, b]) - pts.append(tri[hit, a] + (tri[hit, b] - tri[hit, a]) * t[:, None]) - pts = np.unique(np.round(np.concatenate(pts)[:, :2], 6), axis=0) - ang = np.mod(np.arctan2(pts[:, 1], pts[:, 0]), TAU) - order = np.argsort(ang) - ang, rad = ang[order], np.hypot(pts[order, 0], pts[order, 1]) - keep = np.concatenate([[True], np.diff(ang) > 1e-7]) - return (zq, ang[keep], rad[keep]) - - def colour_changes(self, first_layer: float | None = None): - """[(layer number, height of that layer's top, filament)] for each change after the first. - - first_layer: the slicer's first layer height, if it differs from the rest. - """ - lh = self.p.layer_height - return [(*layer_top(zs, lh, first_layer), fil) for zs, fil in self.bands[1:]] - - def colour_phase(self, z): - """Colour cycles of a gradient spool used by each height.""" - length = max(self.p.gradient_length, 0.05) * 1000.0 - return np.interp(z, self.filament_z, self.filament_at) / length - - def colour_plan(self, first_layer: float | None = None) -> str: - p = self.p - lines = [f"Vaseinator: {p.name} - {'a normal print' if self.normal else 'spiral vase mode'}, " - f"{p.layer_height:g} mm layers"] - if self.gradient: - lines.append(f"Print with one gradient spool (one colour cycle every {p.gradient_length:g} m).") - return "\n".join(lines) - lines.append(f"Start with filament {self.bands[0][1]} ({self.filament_of(self.bands[0][1])})") - for layer, ztop, fil in self.colour_changes(first_layer): - lines.append(f"Layer {layer} ({ztop:.2f} mm): change to filament {fil} ({self.filament_of(fil)})") - return "\n".join(lines) - - # ------------------------------------------------------------ advice - def _advice(self): - p = self.p - if self.worst > p.max_overhang + TOLERANCE: - if p.soften: - self.warnings.append( - f"Some of the wall still leans {self.worst:.0f}° (the limit is {p.max_overhang:g}°). " - "With low-poly facets, more facets round the vase or fewer up it lean less.") - else: - self.warnings.append( - f"The wall leans up to {self.worst:.0f}°, over the {p.max_overhang:g}° limit: it would " - "sag or fail there (red in the Overhangs view). Turn on Soften to fit, or make the " - "shape gentler.") - elif self.softened > 0.05 and self.free: - e = self.eased - what = [f"the twist by up to {e['twist']:.0f}°" if e["twist"] > 0.5 else "", - f"the outline's width by up to {e['width']:.1f} mm" if e["width"] > 0.05 else "", - f"the change of shape by up to {100 * e['change']:.0f}%" if e["change"] > 0.005 else "", - f"the patterns to {100 * e['patterns']:.0f}% at the steepest place" if e["patterns"] < 0.995 else ""] - what = [w for w in what if w] - self.notes.append("Overhangs softened, so no part of the wall leans more than " - f"{p.max_overhang:g}°: " + (", ".join(what) if what else "slightly") + ".") - elif self.softened > 0.05: - self.notes.append(f"Overhangs softened: the surface was moved by up to {self.softened:.1f} mm so " - f"no part of the wall leans more than {p.max_overhang:g}°.") - if self.H > p.printer_height: - self.warnings.append(f"The vase is {self.H:.0f} mm tall: taller than the printer ({p.printer_height:.0f} mm).") - sx, sy = self.size[0], self.size[1] - if sx > p.bed_x or sy > p.bed_y: - self.warnings.append(f"It is {sx:.0f} × {sy:.0f} mm across: bigger than the bed " - f"({p.bed_x:.0f} × {p.bed_y:.0f} mm).") - if self.H > 3.2 * self.base_d[1] and self.H > 60: - self.warnings.append(f"The base is narrow for the height (Ø {self.base_d[1]:.0f} mm under a " - f"{self.H:.0f} mm vase): it may topple, on the bed or with flowers in. " - "A brim helps it print.") - if self.top_d[0] < 8.0 and not p.closed_top and not p.box: - self.notes.append(f"The opening at the top is only {self.top_d[0]:.0f} mm across: a bud vase.") - changes = len(self.bands) - 1 - if changes > 20: - self.warnings.append(f"{changes} colour changes: each one takes time and leaves a small seam. " - "Fewer, taller bands print cleaner.") - if self.pattern_open and p.hole_strut < 2.0 * p.line_width: - self.warnings.append(f"The wall between the holes ({p.hole_strut:g} mm) is thinner than two lines " - f"({2 * p.line_width:g} mm): it may print thin or break.") - if p.line_width < 0.45: - self.notes.append("A wider line (0.6 to 0.8 mm on a 0.4 mm nozzle) makes a stronger, more surely " - "watertight wall.") - if self.closed is False: # pragma: no cover - the construction should never allow it - self.warnings.append("The mesh is not closed: please report this design.") - - # -------------------------------------------------------------- view - def info(self) -> dict: - p = self.p - lean = f"{self.worst:.1f}° from upright (limit {p.max_overhang:g}°)" - lean += " ✓" if self.worst <= p.max_overhang + TOLERANCE else " ✗" - mesh = f"{self.triangles:,} triangles" - if self.closed: - mesh += ", closed and watertight ✓" - d = { - "Height": f"{self.H:.1f} mm, {self.layers} layers of {p.layer_height:g} mm", - "Widest": f"Ø {self.widest:.1f} mm", - "Footprint": f"{self.size[0]:.1f} × {self.size[1]:.1f} mm", - "Base": f"Ø {self.base_d[0]:.0f}–{self.base_d[1]:.0f} mm", - **({"Top": ("closed in a point" + (f", over a ceiling {self.ceiling:.0f} mm across" if self.ceiling else "")) - if self.normal else "comes in to a point (spiral vase mode prints no top: a hole a few mm " - "across stays at the very top)"} - if p.closed_top else {"Opening at the top": f"Ø {self.top_d[0]:.0f}–{self.top_d[1]:.0f} mm"}), - **({"Goes round": f"{p.around:g}° of the way round"} if p.around < 360.0 else {}), - **({"Fits a head": f"up to {self.head_fit / 10.0:.1f} cm round (round the forehead, above the ears)"} - if p.worn and self.head_fit > 0 else {}), - **({"Fits over": f"a box of tissues {p.box_length / 10.0:g} × {p.box_width / 10.0:g} × " - f"{p.box_height / 10.0:g} cm, with {self.box_room:.1f} mm to spare each side and " - f"{BOX_TOP_ROOM:g} mm over it", - "Tissues come out of": (f"{TISSUE_WORDS[p.tissue_hole]} {self.tissue[0]:.0f}" - + ("" if p.tissue_hole == TISSUE_ROUND else f" × {self.tissue[1]:.0f}") - + " mm, through its bottom layers") if self.tissue else "nowhere: no hole", - "Printed": "upside down, as it is shown - the hole on the bed, the open top for the box to go " - "in: nothing needs supports. Turn it over to put it on."} if p.box else {}), - "Holds": ("nothing: it goes only part way round" if p.around < 360.0 else - "nothing: it is open at the bottom" if p.open_bottom else - "nothing: it has holes" if self.holes_open else - "nothing: it is closed at the top" if p.closed_top else - f"about {self.capacity:.0f} ml of water"), - "Wall": (f"one {p.line_width:g} mm spiral line" if p.vase_mode != FAKE_VASE else - f"{max(int(p.walls), 1)} walls of {p.line_width:g} mm (about {self.wall:.1f} mm)") - + (", open at the bottom" if p.open_bottom else - f", on a {self.zb:g} mm solid bottom ({self.bottom_layers} layers)"), - **({"Holes": self._hole_words()} if self.pattern_open else {}), - **({"Face": self._face_words()} if self._face_words() else {}), - "Steepest overhang": lean, - "Filament": f"{self.grams:.0f} g, {self.metres:.1f} m", - "Print time": f"about {duration(self.seconds)}", - "Mesh": mesh, - } - if p.around < 360.0: # widths round the whole oval it is cut from would mislead - for key in ("Widest", "Base", "Opening at the top"): - d.pop(key, None) - if p.box: # a box isn't round, and holds tissues, not water - for key in ("Widest", "Base", "Opening at the top", "Holds"): - d.pop(key, None) - d["Wall"] = d["Wall"].replace("solid bottom", "solid top") - return d - - def _hole_words(self) -> str: - p = self.p - if p.holes == VORONOI: - text = (f"Voronoi ovals, {p.hole_size:g} mm apart with at least {p.hole_strut:g} mm between; round tops" - + (f", bridging at most {self.bridge:.1f} mm" if self.bridge > 0 else "")) - else: - text = (f"{HOLE_WORDS[p.holes]}, {p.hole_size:g} mm apart with {p.hole_strut:g} mm between; pointed tops" - + (f" ({self.bridge:.1f} mm bridges at their points)" if self.bridge > 0 else "")) - if p.hole_swell > 0: - text += f"; edges rounded, the wall swelling {p.hole_swell:g} mm each side round them" - return text - - def _face_words(self) -> str: - p = self.p - words = [{ALMOND_EYES: "almond eyes"}.get(p.eyes, "round eyes") if p.eyes != NO_EYES else "", - "a raised nose" if p.nose == RAISED_NOSE else "", - {OPEN_MOUTH: "an open mouth", GRILLE_MOUTH: f"a grille of {p.mouth_slots} slots for a mouth", - CHIN_MOUTH: "open down to the chin"}.get(p.mouth, ""), - "slots for a strap" if p.straps else ""] - words = [w for w in words if w] - if not words: - return "" - text = ", ".join(words) - if face_holes(p) and not self.pattern_open: - text += "; pointed tops" + (f" ({self.bridge:.1f} mm bridges at their points)" if self.bridge > 0 else "") - return text[:1].upper() + text[1:] - - def contour(self, zc: float): - """Where the wall crosses height zc: line segments (K, 2, 3), for the print animation.""" - if not hasattr(self, "_tri"): - if self.holes_open or self.floor_hole is not None: # the pot's wall: each piece between holes a loop - keep = np.isin(self.shell_kind, (hollow.OUTER, hollow.INNER, hollow.SIDE, hollow.TIP)) - tri = self.shell_verts[self.shell_faces[keep]] - else: - tri = self.verts[self.faces[:self.side]] - self._tri = tri - self._tz = (tri[:, :, 2].min(1), tri[:, :, 2].max(1)) - zc = float(zc) + 1e-7 - sel = self._tri[(self._tz[0] < zc) & (self._tz[1] > zc)] - if not len(sel): - return np.zeros((0, 2, 3)) - pts, crosses = [], [] - for a, b in ((0, 1), (1, 2), (2, 0)): - za, zb = sel[:, a, 2], sel[:, b, 2] - cross = (za - zc) * (zb - zc) < 0 - t = np.where(cross, (zc - za) / np.where(zb == za, 1.0, zb - za), 0.0) - pts.append(sel[:, a] + (sel[:, b] - sel[:, a]) * t[:, None]) - crosses.append(cross) - P, C = np.stack(pts, 1), np.stack(crosses, 1) - first_two = np.argsort(~C, axis=1, kind="stable")[:, :2] # the two edges each face crosses - seg = np.take_along_axis(P, first_two[:, :, None], axis=1) - return seg[C.sum(1) >= 2] - - -# ================================================================ a tissue box cover -def box_room(p: VaseParams) -> float: - """How much bigger a tissue box cover is inside than the box of tissues, each side (mm): BOX_ROOM, more where - rounded corners need it to clear a square-cornered box, and more for patterns and rounded hole edges that - reach in.""" - wall = p.line_width * max(int(p.walls), 1) - inward = ((max(-p.rib_depth, 0.0) if p.rib_count > 0 else 0.0) + (max(-p.ring_depth, 0.0) if p.ring_count > 0 - else 0.0) + (max(-p.bump_depth, 0.0) if p.bump_around > 0 else 0.0) + 1.2 * max(p.organic_depth, 0.0) - + (p.hole_swell if p.holes != NO_HOLES else 0.0)) - corner = max(p.box_corner - wall, 1.0) # inside - return max(BOX_ROOM, corner - (corner - BOX_ROOM) / math.sqrt(2.0)) + inward - - -def box_shape(p: VaseParams) -> VaseParams: - """A tissue box cover's own shape, from the box of tissues it goes over: a rectangle seen from above with - rounded corners, straight up. It is made upside down, as it prints - the top, with the hole for the tissues, - is the floor on the bed, and the rim is the open bottom the box goes in by - so nothing needs supports.""" - q = p.copy() - wall = q.line_width * max(int(q.walls), 1) - room = box_room(q) - a = 0.5 * max(q.box_length, q.box_width) + room + BOX_END_ROOM + wall # half its length and width, outside - b = 0.5 * min(q.box_length, q.box_width) + room + wall - c = min(max(q.box_corner, wall + 1.0), b) - reach = math.hypot(a - c, b - c) + c # to the furthest point, round a corner - q.shape1, q.depth1, q.sharp1, q.shape2 = RECTANGLE, min(1.0 - b / a, 0.9), c / b, SAME - q.turn1 = 90.0 if q.box_width > q.box_length else 0.0 - q.height = round(q.box_height + BOX_TOP_ROOM + max(q.bottom, q.layer_height), 3) - q.profile, q.silhouette = 1, [[0.0, round(2.0 * reach, 3)], [1.0, round(2.0 * reach, 3)]] - q.twist, q.twist_style, q.lattice, q.facets = 0.0, EVENLY, False, False - q.open_bottom, q.closed_top, q.around, q.worn = False, False, 360.0, False - q.eyes, q.nose, q.mouth, q.straps = NO_EYES, NO_NOSE, NO_MOUTH, False - q.vase_mode = FAKE_VASE - return q - - -def ledge_lean(p: VaseParams) -> float: - """How far a cover's ledges' steep sides lean from upright (degrees): LEDGE_LEAN, or less, as the wall may.""" - return max(min(LEDGE_LEAN, p.max_overhang - 10.0), 10.0) - - -def ledge_heights(p: VaseParams): - """Where a cover's ledges change, measured up from the rim with the cover on: the foot of the ramp, the top - of the ramp and the end of the flat, both LEDGE_DEPTH in, and the top of the steep side.""" - h0 = LEDGE_FROM - h1 = h0 + LEDGE_RAMP - h2 = h1 + LEDGE_FLAT - return h0, h1, h2, h2 + LEDGE_DEPTH / math.tan(math.radians(ledge_lean(p))) - - -def ledges(z, I, H: float, heights): - """The inside of a cover's wall I (K, N, 2) at heights z, with a ledge across each end - the straight part of - the end, on the long way - standing in from the wall near the rim at H, for its lid's tabs to click in - behind. As the cover prints, upside down, the ramp faces up and the steep side down, no steeper than a wall - may lean, so nothing needs supports.""" - h0, h1, h2, h3 = heights - h = H - np.asarray(z, dtype=float) - depth = LEDGE_DEPTH * np.interp(h, [h0, h1, h2, h3], [0.0, 1.0, 1.0, 0.0], left=0.0, right=0.0) - rim = I[-1] - X, Y = np.abs(rim).max(axis=0) - U = np.array([1.0, 0.0]) if X >= Y else np.array([0.0, 1.0]) - V = np.array([-U[1], U[0]]) - u, v = rim @ U, rim @ V - flat = v[np.abs(u) >= np.abs(u).max() - 0.05] - half = max(0.5 * float(flat.max() - flat.min()) - 1.0, 12.0) # the straight part of each end, or its middle - across = np.clip((half - np.abs(I @ V)) / 3.0, 0.0, 1.0) # each ledge tapers off at its ends ... - on_end = np.abs(I @ U) > 0.5 * np.abs(u).max() # ... and is only on the ends - off = depth[:, None] * across * on_end - return I - sections.normals(I) * off[..., None] - - -# ================================================================ sizing for a head -def scaled(p: VaseParams, k: float) -> VaseParams: - """The design k times the size: its height, outline and face (patterns and holes stay as they are).""" - q = p.copy() - q.silhouette = [[h, round(d * k, 2)] for h, d in p.silhouette] - for key in HEAD_SCALED: - setattr(q, key, round(getattr(p, key) * k, 2)) - return q - - -def fit_head(p: VaseParams, head: float) -> VaseParams: - """The design sized to fit a head ``head`` mm round (round the forehead, above the ears), with a little - room: the inside measured where the head goes in, the whole design scaled to fit.""" - q = p.copy() - for _ in range(4): - d = VaseDesign(q, draft=True) - if d.errors or d.head_fit <= 0: - return q - if abs(d.head_fit - head) < 0.5: - break - extra = HEAD_ROOM + TAU * d.wall # the inside is this much less round than the outside - q = scaled(q, (head + extra) / (d.head_fit + extra)) - return q +"""The vase itself: its surface, made printable, as closed watertight meshes. + +The surface is a radial height map r(theta, z) on a grid of heights and +angles. Because every height has exactly one radius in each direction, +each layer is one simple closed loop round the axis - just what a spiral +vase slicer needs - and the mesh can never cross itself: every triangle +lies in its own wedge of angle between two heights. + +Overhangs are measured on the real surface normal. A wall leaning out *or +in* overhangs the single printed line beneath it, so both count. Where the +wall leans too far, the radius is limited column by column (a Lipschitz +envelope from above and below, averaged), with the allowed change per layer +worked out from how the wall faces there - a flank that faces sideways may +move a long way sideways. The finished triangles are then checked, and the +limit tightened until every one of them is within it. +""" + +from __future__ import annotations + +import math + +import numpy as np + +from .expr import ExprError, compile_expression +from . import hollow, sections +from .params import (ALMOND_EYES, BANDS, CHIN_MOUTH, DIAMOND, DRAWN, EVENLY, FAKE_VASE, GRADIENT, GRILLE_MOUTH, + MAX_BANDS, NO_EYES, NO_HOLES, NO_MOUTH, NO_NOSE, NO_TISSUE, ONE_COLOUR, OPEN_MOUTH, OWN, + RAISED_NOSE, REAL_VASE, RECTANGLE, ROUND, SAME, SLOTS, STRIPES, TISSUE_OVAL, TISSUE_ROUND, + VORONOI, VaseParams, colour_style_of, face_holes, needs_normal) +from .shapes import (TABLE, TAU, blur, change, lobes_needed, organic, pchip, progress, rounded_rectangle, sample, + shape_table, smooth_max, smoothstep, strength) + +TOLERANCE = 0.3 # degrees over the overhang limit still counted as within it +HOLE_WORDS = {VORONOI: "Voronoi", ROUND: "round holes", DIAMOND: "diamonds", SLOTS: "slots"} +VORONOI_BRIDGE = 0.7 # a Voronoi oval's round top is bridged once it is this share of the cells' size across +VORONOI_LONGEST = 14.0 # ... and never more than this (mm): a round top that size bridges on most printers +HEAD_ROOM = 10.0 # mm more round the inside of a mask or helmet than round the head it fits +SIMPLIFY_ERROR = 1e-3 # how far saving may simplify the mesh: pyfqmr's lossless error, a few hundredths of a mm +SIMPLIFY_MM = 0.02 # ... and in the web version, meshoptimizer's (mm) +BOX_ROOM = 2.0 # mm to spare each side between a tissue box cover and the box of tissues inside it +BOX_TOP_ROOM = 3.0 # ... and over the box's top +BOX_END_ROOM = 2.0 # ... and more inside each end, for the lid's tabs +LEDGE_DEPTH = 1.2 # a cover's ledges, inside each end at the rim, that its lid clicks in behind (mm) +LEDGE_FROM = 4.0 # ... how far in from the rim each starts, the cover on +LEDGE_RAMP = 3.5 # ... the gentle slope the lid's tabs ride over, going on +LEDGE_FLAT = 0.4 # ... its flat +LEDGE_LEAN = 35.0 # ... and the steep side they catch on, from upright: an overhang as the cover prints, + # with room for a pattern's own lean +LEDGE_CLEAR = 13.0 # a cover's holes keep this far from its rim, clear of the lid's tabs (mm) +TISSUE_MARGIN = 8.0 # the tissue hole keeps at least this much of the cover's top round it (mm) +TISSUE_WORDS = {1: "a slot", 2: "an oval", 3: "a round hole"} +# what grows with a design sized for a head: its height and the face, in mm +HEAD_SCALED = ("height", "eye_width", "eye_height", "eye_apart", "eye_up", "nose_width", "nose_length", + "nose_depth", "nose_up", "mouth_width", "mouth_height", "mouth_up", "mouth_smile", "strap_up") + + +def require_exportable(design): + """One export gate for desktop and browser, including lids.""" + if design.errors: + raise ValueError("; ".join(design.errors)) + if not design.closed: + raise ValueError("The mesh is not closed. Please report this design before printing it.") + return design + + +# ===================================================================== meshes +def grid_side(z, theta, R): + """The wall from a grid: rows of radii at shared angles. + + Returns (verts, faces, band of each face, first ring, last ring). Each + cell is split along the diagonal whose ends are closer in radius, so + ridges that spiral up the vase stay smooth. + """ + K, N = R.shape + c, s = np.cos(theta)[None, :], np.sin(theta)[None, :] + verts = np.stack([R * c, R * s, np.broadcast_to(z[:, None], R.shape)], -1).reshape(-1, 3) + idx = np.arange(K * N).reshape(K, N) + nxt = np.roll(idx, -1, axis=1) + v00, v01, v10, v11 = idx[:-1], nxt[:-1], idx[1:], nxt[1:] + Rn = np.roll(R, -1, axis=1) + along = (np.abs(R[:-1] - Rn[1:]) <= np.abs(Rn[:-1] - R[1:]))[..., None] + t1 = np.where(along, np.stack([v00, v01, v11], -1), np.stack([v00, v01, v10], -1)) + t2 = np.where(along, np.stack([v00, v11, v10], -1), np.stack([v01, v11, v10], -1)) + faces = np.concatenate([t1.reshape(-1, 3), t2.reshape(-1, 3)]) + band = np.tile(np.repeat(np.arange(K - 1), N), 2) + return verts, faces, band, idx[0].copy(), idx[-1].copy() + + +def loft_side(rows): + """The wall from rings with angles of their own: (z, angles ascending, radii) each. + + Neighbouring rings are joined by walking round both at once, always + stepping on along the one whose next point comes first. + """ + verts, starts, o = [], [], 0 + for z, a, r in rows: + verts.append(np.stack([r * np.cos(a), r * np.sin(a), np.full(len(a), z)], 1)) + starts.append(o) + o += len(a) + faces, band = [], [] + for k in range(len(rows) - 1): + A, B = rows[k][1], rows[k + 1][1] + na, nb, sa, sb = len(A), len(B), starts[k], starts[k + 1] + d = np.mod(B - A[0] + math.pi, TAU) - math.pi + j0 = int(np.argmin(np.abs(d))) + b0 = A[0] + d[j0] + au = A[0] + np.append(np.mod(A - A[0], TAU), TAU) + bu = b0 + np.append(np.mod(np.roll(B, -j0) - B[j0], TAU), TAU) + i = j = 0 + while i < na or j < nb: + if j >= nb or (i < na and au[i + 1] <= bu[j + 1]): + faces.append((sa + i % na, sa + (i + 1) % na, sb + (j0 + j) % nb)) + i += 1 + else: + faces.append((sa + i % na, sb + (j0 + j + 1) % nb, sb + (j0 + j) % nb)) + j += 1 + band.append(k) + verts = np.concatenate(verts) + first = np.arange(starts[0], starts[0] + len(rows[0][1])) + last = np.arange(starts[-1], starts[-1] + len(rows[-1][1])) + return verts, np.array(faces, dtype=np.int64), np.array(band), first, last + + +def grid_side_xy(z, P): + """The wall from rows of outline points (K, N, 2), point i of each row joined to point i of the next. + + Returns (verts, faces, band of each face, first ring, last ring). Each cell + is split along its shorter diagonal. + """ + K, N = P.shape[:2] + verts = np.concatenate([P, np.broadcast_to(z[:, None, None], (K, N, 1))], axis=2).reshape(-1, 3) + idx = np.arange(K * N).reshape(K, N) + nxt = np.roll(idx, -1, axis=1) + v00, v01, v10, v11 = idx[:-1], nxt[:-1], idx[1:], nxt[1:] + d1 = np.linalg.norm(verts[v00] - verts[v11], axis=-1) + d2 = np.linalg.norm(verts[v01] - verts[v10], axis=-1) + along = (d1 <= d2)[..., None] + t1 = np.where(along, np.stack([v00, v01, v11], -1), np.stack([v00, v01, v10], -1)) + t2 = np.where(along, np.stack([v00, v11, v10], -1), np.stack([v01, v11, v10], -1)) + faces = np.concatenate([t1.reshape(-1, 3), t2.reshape(-1, 3)]) + band = np.tile(np.repeat(np.arange(K - 1), N), 2) + return verts, faces, band, idx[0].copy(), idx[-1].copy() + + +def limit_slope(v, step): + """A 1D curve kept within a most change per step (step[i] between v[i] and v[i+1]). + + The largest curve under it and the smallest over it that keep within the + limit, averaged - as the columns of a radial vase are softened. + """ + lo, hi = v.copy(), v.copy() + for i in range(1, len(v)): + lo[i] = min(lo[i], lo[i - 1] + step[i - 1]) + hi[i] = max(hi[i], hi[i - 1] - step[i - 1]) + for i in range(len(v) - 2, -1, -1): + lo[i] = min(lo[i], lo[i + 1] + step[i]) + hi[i] = max(hi[i], hi[i + 1] - step[i]) + return 0.5 * (lo + hi) + + +def fall_gently(v, step: float): + """The largest curve under v that changes by at most step from one value to the next.""" + lo = v.copy() + for i in range(1, len(v)): + lo[i] = min(lo[i], lo[i - 1] + step) + for i in range(len(v) - 2, -1, -1): + lo[i] = min(lo[i], lo[i + 1] + step) + return lo + + +def fan(center: int, ring, up: bool): + """A flat cap: triangles from the centre to each side of a ring.""" + ring = np.asarray(ring) + nxt = np.roll(ring, -1) + c = np.full(len(ring), center) + return np.stack([c, ring, nxt] if up else [c, nxt, ring], 1) + + +def annulus(outer, inner): + """A flat, upward-facing ring between two rings of the same angles.""" + o, i = np.asarray(outer), np.asarray(inner) + o1, i1 = np.roll(o, -1), np.roll(i, -1) + return np.concatenate([np.stack([o, o1, i1], 1), np.stack([o, i1, i], 1)]) + + +def lighter(verts, faces): + """The mesh with its triangles merged wherever that moves the surface by no more than a few hundredths of a + millimetre - the slicer's Simplify model, done before saving, so a fine mesh makes a file the slicer opens + quickly: with pyfqmr in the desktop app, meshoptimizer in the web version. As it was if neither is there, or + if the result wouldn't be closed.""" + try: + import pyfqmr + except ImportError: + pyfqmr = None + for k in range(3): # each time a little more careful, if it didn't stay closed + if pyfqmr is not None: + s = pyfqmr.Simplify() + s.setMesh(np.asarray(verts, dtype=np.float64), np.asarray(faces, dtype=np.int32)) + s.simplify_mesh_lossless(epsilon=SIMPLIFY_ERROR / 4 ** k, verbose=False) + v, f, _ = s.getMesh() + else: + try: + import meshopt # the web version: given to its Python by the page + except ImportError: + return verts, faces + kept = meshopt.simplify(np.asarray(verts, dtype=np.float32).tobytes(), + np.asarray(faces, dtype=np.uint32).tobytes(), SIMPLIFY_MM / 2 ** k) + f = np.frombuffer(bytes(kept.to_py()), dtype=np.uint32).reshape(-1, 3).astype(np.int64) + used = np.unique(f) # only the corners still in use + v, f = np.asarray(verts)[used], np.searchsorted(used, f) + # a triangle and its twin facing the other way are a fin with nothing inside: neither is needed + f = np.asarray(f, dtype=np.int64) + _, which, count = np.unique(np.sort(f, axis=1), axis=0, return_inverse=True, return_counts=True) + f = f[count[which.ravel()] == 1] + if len(f) and edge_check(f)[0]: + return np.asarray(v, dtype=np.float64), f + return verts, faces + + +def face_normals(verts, faces): + tri = verts[faces] + n = np.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0]) + ln = np.linalg.norm(n, axis=1, keepdims=True) + ln[ln == 0] = 1.0 + return n / ln + + +def lean_angles(verts, faces): + """How far each face leans from upright (degrees), out or in alike.""" + n = face_normals(verts, faces) + return np.degrees(np.arcsin(np.clip(np.abs(n[:, 2]), 0.0, 1.0))) + + +def mesh_volume(verts, faces) -> float: + tri = verts[faces] + return float(np.einsum("ij,ij->i", tri[:, 0], np.cross(tri[:, 1], tri[:, 2])).sum() / 6.0) + + +def edge_check(faces): + """Whether a mesh is closed: every edge used once each way, by exactly two faces. + + Returns (closed, number of bad edges). + """ + f = np.asarray(faces, dtype=np.int64) + a = f.ravel() + b = np.roll(f, -1, axis=1).ravel() + n = int(f.max()) + 1 + fwd = np.sort(a * n + b) + rev = np.sort(b * n + a) + twice = int((np.diff(fwd) == 0).sum()) # an edge used twice the same way + unmatched = int((fwd != rev).sum()) # an edge with no face the other way + return twice == 0 and unmatched == 0, twice + unmatched + + +def inset(angles, radii, width): + """Radii of a ring moved in by ``width`` square to the wall (as a radial map).""" + a = np.asarray(angles) + r = np.asarray(radii) + da = np.mod(np.roll(a, -1, axis=-1) - np.roll(a, 1, axis=-1), TAU) + da[da == 0] = 1e-9 + rth = (np.roll(r, -1, axis=-1) - np.roll(r, 1, axis=-1)) / da + out = r - width * np.sqrt(r * r + rth * rth) / np.maximum(r, 1e-6) + return np.maximum(out, np.maximum(0.15 * r, 0.2)) + + +def soften(R, dth, dz, angle_deg, scale=None, rounds=4): + """Limit the radius so that no part of the wall leans more than angle_deg from upright. + + Column by column, the largest surface under R and the smallest one over + it that keep within the lean are found and averaged: peaks are cut back + and hollows filled in by the same amount. ``scale`` tightens the limit + point by point where the finished triangles showed it was needed. + """ + t = math.tan(math.radians(max(angle_deg, 1.0))) + R = R.copy() + for _ in range(rounds): + # how far the wall faces sideways there - smoothed round the vase, so that neighbouring + # columns are allowed much the same and a sharp tip passing across them doesn't streak + rth = (np.roll(R, -1, axis=1) - np.roll(R, 1, axis=1)) / (2.0 * dth) + k = np.sqrt(R * R + rth * rth) / np.maximum(R, 1e-6) + k = t * np.exp(blur_around(np.log(k), 1.5)) + if scale is not None: + k = k * scale + step = np.minimum(k[:-1], k[1:]) * dz # most each row may differ from the next + lo, hi = R.copy(), R.copy() + for j in range(1, len(R)): + np.minimum(lo[j], lo[j - 1] + step[j - 1], out=lo[j]) + np.maximum(hi[j], hi[j - 1] - step[j - 1], out=hi[j]) + for j in range(len(R) - 2, -1, -1): + np.minimum(lo[j], lo[j + 1] + step[j], out=lo[j]) + np.maximum(hi[j], hi[j + 1] - step[j], out=hi[j]) + new = 0.5 * (lo + hi) + moved = float(np.abs(new - R).max()) + R = new + if moved < 1e-4: + break + return R + + +def ease_twist(R, dth, omega, angle_deg, rounds=3): + """Soften the flanks of a fast-twisting shape round the vase, layer by layer. + + A flank that faces sideways and is carried sideways by the twist leans by + tan(lean) = r·ω·|r_θ| / sqrt(r² + r_θ²). Where r·ω is more than the limit + allows, only a gentler flank can keep within it, so |r_θ| is limited round + each layer (envelopes from above and below, averaged, as for the columns). + Every layer of a twisted shape is softened alike, so it stays one clean + twisted shape. A little is left in hand for the outline's own slope. + """ + t = math.tan(math.radians(max(angle_deg, 1.0))) * 0.9 + N = R.shape[1] + for _ in range(rounds): + rw = R * omega[:, None] + if not (rw > t).any(): + break + bound = np.where(rw > t, R * t / np.sqrt(np.maximum(rw * rw - t * t, 1e-12)), np.inf) + step = np.minimum(bound, np.roll(bound, -1, axis=1)) * dth # between column i and i + 1 + lo, hi = R.copy(), R.copy() + for _ in range(2): # twice round, for the wrap + for i in range(N): + np.minimum(lo[:, i], lo[:, i - 1] + step[:, i - 1], out=lo[:, i]) + np.maximum(hi[:, i], hi[:, i - 1] - step[:, i - 1], out=hi[:, i]) + for i in range(N - 1, -1, -1): + np.minimum(lo[:, i], lo[:, (i + 1) % N] + step[:, i], out=lo[:, i]) + np.maximum(hi[:, i], hi[:, (i + 1) % N] - step[:, i], out=hi[:, i]) + R = 0.5 * (lo + hi) + return R + + +def blur_around(a, sigma_cols): + """Smooth a grid round the vase (each row on its own), sigma in columns.""" + N = a.shape[1] + k = np.fft.rfftfreq(N, d=1.0 / N) + gain = np.exp(-0.5 * (k * sigma_cols * TAU / N) ** 2) + return np.fft.irfft(np.fft.rfft(a, axis=1) * gain[None, :], n=N, axis=1) + + +def layer_top(z: float, layer: float, first: float | None = None) -> tuple[int, float]: + """The first layer that starts at height z or above: (its number from 1, the height of its top). + + The first layer may be thicker than the rest, as slicers often make it. + """ + first = layer if first is None else first + if z <= first * 0.5: + return 1, first + k = max(math.ceil((z - first) / layer - 0.5), 0) # whole layers above the first one, below z + return k + 2, round(first + (k + 1) * layer, 4) + + +def duration(seconds: float) -> str: + m = int(round(seconds / 60.0)) + return f"{m // 60} h {m % 60:02d} min" if m >= 60 else f"{max(m, 1)} min" + + +# ===================================================================== design +class VaseDesign: + """A vase built from its parameters: the solid to export, the shell to show, and its sums.""" + + def __init__(self, p: VaseParams, draft: bool = False): + self.errors: list[str] = [] + self.warnings: list[str] = [] + self.notes: list[str] = [] + self.asked = p.copy() # the settings as given: p below is as built, a box's shape and all + self.box_room = 0.0 + if p.box: # a tissue box cover: its shape comes from the box it fits + self.box_room = box_room(p) + p = box_shape(p) + if p.vase_mode == REAL_VASE and needs_normal(p): + p = p.copy() + p.vase_mode = FAKE_VASE + self.notes.append("Holes, a face, a tissue box cover, an open bottom and a wall part way round need a " + "normal print, with walls (spiral vase mode prints one closed line a layer): it is " + "printed normally.") + if p.closed_top and p.around < 360.0: + p = p.copy() + p.closed_top = False + self.notes.append("A closed top needs the wall all the way round: the top is left open.") + self.p = p + self.draft = draft + self.pot_rows = None + self.bridge = 0.0 + self.holes_open = False + self.pattern_open = False + self.bridge_allowed = 0.0 # how wide a hole's top may be bridged (mm): Voronoi ovals + self.hole_share = 0.0 + self.head_fit = 0.0 + self.ceiling = 0.0 + self.floor_hole = None # a tissue box cover's hole through its top, on the bed + self.tissue = None # ... its length and width (mm) + self.fn = None + self.closed = None + self.free = DRAWN in (self.p.shape1, self.p.shape2) # a drawn outline: built point by point + self.outer_rows = None + try: + if self._check(): + if self.free: + self._free_surface() + if self.errors: + return + self._free_printable() + inner = np.stack([sections.inset(row, self.wall) for row in self.P]) + self._pot(self.P, inner) + self.outer_rows = [(zz, np.arctan2(r[:, 1], r[:, 0]), np.hypot(r[:, 0], r[:, 1])) + for zz, r in zip(self.z, self.P)] + else: + self._grid_size() + self._surface() + self._printable() + if self.rows is None: + c, s = np.cos(self.theta), np.sin(self.theta) + Ri = inset(self.theta, self.R, self.wall) + self._pot(np.stack([self.R * c, self.R * s], -1), np.stack([Ri * c, Ri * s], -1)) + else: + self._shell() # low-poly: the facets' own shell + self._measure() + self._colours() + self._advice() + except ExprError as exc: + self.errors.append(f"Your own function: {exc}") + + # ------------------------------------------------------------ checks + def _check(self) -> bool: + p = self.p + if p.height < 5.0: + self.errors.append("The vase must be at least 5 mm tall.") + if len(p.silhouette) < 2 or min(d for _, d in p.silhouette) <= 0: + self.errors.append("The outline needs at least two points, every one wider than nothing.") + for key, label in (("line_width", "Line width"), ("layer_height", "Layer height"), + ("filament_d", "Filament diameter"), ("speed", "Print speed"), + ("max_flow", "Max flow"), ("density", "Filament density")): + if getattr(p, key) <= 0: + self.errors.append(f"{label} must be more than zero.") + if p.facet_size <= 0: + self.errors.append("Mesh detail must be more than zero.") + try: + self.fn = compile_expression(p.expression) + except ExprError as exc: + self.errors.append(f"Your own function: {exc}") + return not self.errors + + def _grid_size(self): + p = self.p + self.H = H = float(p.height) + sil = np.asarray(p.silhouette, dtype=float) + tex = ((abs(p.rib_depth) if p.rib_count > 0 else 0.0) + (abs(p.ring_depth) if p.ring_count > 0 else 0.0) + + (abs(p.bump_depth) if p.bump_around > 0 else 0.0) + 1.2 * max(p.organic_depth, 0.0)) + r_est = 0.5 * sil[:, 1].max() + tex + step = max(p.facet_size, 0.15) * (2.6 if self.draft else 1.0) + per = 10 if self.draft else 22 # points round each lobe or rib + lobes = [lobes_needed(p.shape1, p.count1)] + if p.shape2 != SAME: + lobes.append(lobes_needed(p.shape2, p.count2)) + if p.rib_count > 0: + lobes.append(p.rib_count * (2 if p.rib_cross else 1)) + if p.bump_around > 0: + lobes.append(p.bump_around) + N = max(math.ceil(TAU * r_est / step), per * max(lobes), 96) + M = max(math.ceil(H / step), (8 if self.draft else 16) * max(p.ring_count, 1), 24) + if p.bump_around > 0: + M = max(M, (8 if self.draft else 16) * p.bump_up) + if p.organic_depth > 0: # a few points across every lump + fine = max(p.organic_scale, 2.0) / (5 if self.draft else 10) + N = max(N, math.ceil(TAU * r_est / fine)) + M = max(M, math.ceil(H / fine)) + if self.fn is not None: + N = max(N, 180 if self.draft else 480) + limit = 140_000 if self.draft else 1_200_000 + if N * M > limit: + f = math.sqrt(limit / (N * M)) + N, M = max(int(N * f), 64), max(int(M * f), 16) + spacing = step / f + if not self.draft and spacing > 0.4: # finer than that, nobody would see it + self.notes.append(f"The surface detail was capped at about {spacing:.1f} mm to keep the mesh a " + "manageable size.") + self.N = (N + 3) // 4 * 4 + self.M = M + + # ----------------------------------------------------------- surface + def twist_at(self, h): + """How far the shape has turned at height h (radians).""" + p = self.p + if p.twist_style == OWN: + deg = progress(OWN, h, p.twist_curve) + else: + deg = p.twist * progress(p.twist_style, h, swings=p.swings) + return np.radians(deg) + + def _texture(self, frame, z, mean_r: float): + """The surface patterns in mm, out (+) or in, at angles ``frame`` (rows, columns) and heights z.""" + p = self.p + h = z / self.H + tex = np.zeros(np.broadcast(frame, z[:, None]).shape) + if p.rib_count > 0 and p.rib_depth != 0: + k = 1.0 + 7.0 * p.rib_sharp ** 2 + lean = math.radians(p.rib_lean) * h[:, None] + rib = (0.5 * (1.0 + np.cos(p.rib_count * (frame - lean)))) ** k + if p.rib_cross: + rib = np.maximum(rib, (0.5 * (1.0 + np.cos(p.rib_count * (frame + lean)))) ** k) + tex += p.rib_depth * rib + if p.ring_count > 0 and p.ring_depth != 0: + k = 1.0 + 7.0 * p.ring_sharp ** 2 + tex += p.ring_depth * ((0.5 * (1.0 - np.cos(TAU * p.ring_count * h))) ** k)[:, None] + if p.bump_around > 0 and p.bump_depth != 0: + tex += p.bump_depth * (0.5 * (1.0 + np.cos(p.bump_around * frame) + * np.cos(TAU * max(p.bump_up, 1) * h)[:, None])) ** 1.5 + if p.organic_depth > 0: + tex += p.organic_depth * organic(frame, z[:, None], p.organic_scale, mean_r, p.organic_seed) + return tex + + def _envelope(self, z): + """How strong the surface patterns are at each height: the strength curve, faded at foot and rim.""" + p = self.p + env = strength(p.strength, z / self.H, p.strength_curve) + if p.fade_foot > 0: + env = env * smoothstep(z / p.fade_foot) + if p.fade_rim > 0: + env = env * smoothstep((self.H - z) / p.fade_rim) + return env + + def _surface(self): + p, H, N, M = self.p, self.H, self.N, self.M + self.z = z = np.linspace(0.0, H, M + 1) + self.theta = theta = np.arange(N) * (TAU / N) + self.dz, self.dth = H / M, TAU / N + h = z / H + sil = np.asarray(p.silhouette, dtype=float) + R0, q = self._dome(z, 0.5 * pchip(sil[:, 0], sil[:, 1], h)) + self.tau = tau = self.twist_at(h) + TH, TA = theta[None, :], tau[:, None] + + tab1 = blur(shape_table(p.shape1, p.count1, p.depth1, p.sharp1), self.dth) + if p.shape2 != SAME: + tab2 = blur(shape_table(p.shape2, p.count2, p.depth2, p.sharp2), self.dth) + mix = change(p.morph, h, p.morph_curve, p.morph_swings)[:, None] + mix = np.clip(mix, 0.0, 1.0) + a1, a2 = math.radians(p.turn1), math.radians(p.turn2) + + def section(angle): + s = sample(tab1, angle - a1) + if p.shape2 != SAME: + s = (1.0 - mix) * s + mix * sample(tab2, angle - a2) + return s + + s = section(TH - TA) + if p.lattice: + # round the crossing ridges over a couple of mesh columns, so they don't alias + steep = float(np.abs(np.diff(tab1)).max()) * TABLE / TAU + s = smooth_max(s, section(TH + TA), 3.0 * self.dth * steep) + r = R0[:, None] * s + + frame = TH - TA if p.texture_twist else np.broadcast_to(TH, r.shape) + if p.even_spacing: + frame = self._round_the_wall(r, tau if p.texture_twist else np.zeros_like(tau)) + r = r + (q * self._envelope(z))[:, None] * self._texture(frame, z, float(R0.mean())) + if p.nose == RAISED_NOSE: + # along the surface from the front (-y: the column a quarter turn before the start) + front = r[:, 3 * N // 4] + s = (np.mod(TH - 1.5 * math.pi + math.pi, TAU) - math.pi) * front[:, None] + r = r + hollow.nose(s, z, p, 0.9 * math.tan(math.radians(p.max_overhang))) + + if self.fn is not None: + env_vars = dict(theta=np.broadcast_to(TH, r.shape), phi=frame, z=z[:, None], h=h[:, None], r=r, + H=np.float64(H)) + add = self.fn(**env_vars) + # does the function meet itself round the back? Try it a whole turn on. + wrap = self.fn(theta=np.full((M + 1, 1), TAU), phi=frame[:, :1] + TAU, z=z[:, None], + h=h[:, None], r=r[:, :1], H=np.float64(H)) + if float(np.abs(wrap[:, 0] - add[:, 0]).max()) > 0.05: + self.warnings.append( + "Your own function doesn't meet itself round the vase, so there is a step " + "up the back. Use whole numbers with the angle, e.g. sin(5*theta), not sin(2.5*theta).") + r = r + add + if p.closed_top: # nothing sticks out past the point, patterns and all + r = np.minimum(r, self._cone(z)[:, None]) + + floor_r = max(1.5, 2.0 * self.wall) + near = r < floor_r + if p.closed_top: # a closed top's point comes in to it, as it should + near &= (self._cone(z) > 2.0 * floor_r)[:, None] + if near.any(): + self.notes.append(f"Where the shape came near the middle it is kept {floor_r:.1f} mm out from it.") + self.R_design = np.maximum(r, floor_r) + + def _round_the_wall(self, r, start): + """How far round the wall each grid point is, as an angle - 2 pi all the way round - measured along the + wall from the direction ``start`` (each row's), rather than as the angle from the middle: patterns laid + out by it are as close together on a box's long sides as round its ends.""" + N = self.N + c, s = np.cos(self.theta), np.sin(self.theta) + x, y = r * c, r * s + step = np.hypot(np.roll(x, -1, axis=1) - x, np.roll(y, -1, axis=1) - y) + along = np.concatenate([np.zeros((len(r), 1)), np.cumsum(step, axis=1)], axis=1) # (rows, N + 1) + share = along / along[:, -1:] * TAU + f = np.mod(start, TAU) / self.dth # where each row starts, between two columns + i0 = np.floor(f).astype(np.intp) % N + t = f - np.floor(f) + rows = np.arange(len(r)) + first = share[rows, i0] * (1.0 - t) + share[rows, i0 + 1] * t + return share[:, :N] - first[:, None] + + def _cone(self, z): + """A closed top's point: the widest the vase may be at each height, leaning in at a little under the + overhang limit up to a point the width of the narrowest the wall may come to the axis.""" + tip = max(1.5, 2.0 * self.wall) + return tip + (self.H - z) * 0.92 * math.tan(math.radians(self.p.max_overhang)) + + def _dome(self, z, R0): + """The outline's radius at each height, closed over in a point if the top is closed; and how far each + height was narrowed (1: not at all), for the patterns to shrink with it.""" + if not self.p.closed_top: + return R0, np.ones_like(R0) + # the lesser of the outline and the point, rounded where they meet (never leaning more than either) + cone = self._cone(z) + k = 0.12 * float(R0.max()) + capped = np.minimum(R0, cone) - k * np.log1p(np.exp(-np.abs(R0 - cone) / k)) + q = capped / np.maximum(R0, 1e-9) + narrowed = np.nonzero(q < 0.999)[0] + self.dome_from = float(z[narrowed[0]]) if len(narrowed) else self.H + return capped, q + + # ----------------------------------------------------- printable solid + def _rows(self, R): + """The low-poly rings: (z, angles, radii) for each row of facets.""" + p, H = self.p, self.H + rng = np.random.default_rng(int(p.facet_seed)) + Nf, Mf = max(int(p.facets_around), 3), max(int(p.facets_up), 1) + rows = [] + for j in range(Mf + 1): + zj = H * j / Mf + a = TAU * np.arange(Nf) / Nf + (j % 2) * math.pi / Nf + float(self.twist_at(np.array([zj / H]))[0]) + if p.facet_rough > 0: + a = a + rng.uniform(-0.35, 0.35, Nf) * p.facet_rough * TAU / Nf + r = self._sample(R, zj, a) + if p.facet_rough > 0: + r = r * (1.0 + rng.uniform(-0.05, 0.05, Nf) * p.facet_rough) + rows.append((zj, a, r)) + return rows + + def _sample(self, R, zq, aq): + """The radius between the grid points (bilinear, round the back too).""" + fj = min(max(zq / self.dz, 0.0), self.M) + j0 = min(int(fj), self.M - 1) + tj = fj - j0 + fi = np.mod(aq, TAU) / self.dth + i0 = np.floor(fi).astype(np.int64) + ti = fi - i0 + i0 %= self.N + i1 = (i0 + 1) % self.N + lo = R[j0, i0] * (1 - ti) + R[j0, i1] * ti + hi = R[j0 + 1, i0] * (1 - ti) + R[j0 + 1, i1] * ti + return lo * (1 - tj) + hi * tj + + def _solid(self, R): + """The solid to export from a surface: (verts, faces, band of each wall face, wall face count, rows).""" + rows = self._rows(R) if self.p.facets else None + if rows is None: + verts, faces, band, first, last = grid_side(self.z, self.theta, R) + else: + verts, faces, band, first, last = loft_side(rows) + nv = len(verts) + verts = np.concatenate([verts, [[0.0, 0.0, 0.0], [0.0, 0.0, self.H]]]) + side = len(faces) + faces = np.concatenate([faces, fan(nv, first, up=False), fan(nv + 1, last, up=True)]) + return verts, faces, band, side, rows + + def _soften(self, target, scale): + """The design softened, with the correction smoothed round the vase and softened again. + + Each column is softened on its own, so on its own the correction can + differ a little from column to column and leave fine streaks; smoothing + it sideways and softening once more keeps the surface clean. + """ + base = self.R_base + R = soften(base, self.dth, self.dz, target, scale) + for _ in range(2): + fix = blur_around(R - base, 2.0) + R = soften(base + fix, self.dth, self.dz, target, scale) + return R + + def _printable(self): + p = self.p + target = p.max_overhang + scale = np.ones_like(self.R_design) # where the limit has to be tighter than it looks + R = self.R_design + self.R_base = self.R_design + if p.soften: # a fast twist first: gentler flanks, layer by layer + omega = np.abs(np.gradient(self.tau, self.dz)) + self.R_base = ease_twist(self.R_design, self.dth, omega, p.max_overhang) + for attempt in range(14 if p.soften else 1): + R = self._soften(target, scale) if p.soften else self.R_design + verts, faces, band, side, rows = self._solid(R) + lean = lean_angles(verts, faces[:side]) + worst = float(lean.max()) + if worst <= p.max_overhang + TOLERANCE or not p.soften: + break + if rows is None: + # tighten only round the faces that still lean too far + # by as much as each face is over, spread a little and feathered over a few columns + # and rows, so the tightening itself leaves no crease + over = lean > p.max_overhang + 0.5 * TOLERANCE + cut = 1.0 - math.tan(math.radians(p.max_overhang - 0.25)) / np.tan(np.radians(lean[over])) + cut = np.clip(cut + 0.03, 0.03, 0.6) + corner = faces[:side][over].ravel() + keep = corner < (self.M + 1) * self.N + j, i = np.divmod(corner[keep], self.N) + tight = np.zeros_like(scale) + np.maximum.at(tight, (j, i), np.repeat(cut, 3)[keep]) + for axis in (0, 1): + tight = np.maximum(tight, np.maximum(np.roll(tight, 1, axis), np.roll(tight, -1, axis))) + tight = blur_around(tight, 1.5) + scale *= 1.0 - np.clip(tight, 0.0, 0.6) + if attempt >= 6: # still not there: ask a little less everywhere too + target -= max(1.0, worst - p.max_overhang) + else: + # low-poly: the facets cut across the softened surface, so ask for less everywhere + target -= max(0.75, worst - p.max_overhang) + if target < 2.0: + break + self.R, self.rows = R, rows + self.verts, self.faces, self.side = verts, faces, side + self.band, self.lean = band, lean + self.worst = worst + self.softened = float(np.abs(R - self.R_design).max()) if p.soften else 0.0 + self.row_moved = np.abs(R - self.R_design).max(axis=1) if rows is None else None + if not self.draft: + self.closed = edge_check(faces)[0] + nb = int(band.max()) + 1 + self.band_lean = np.zeros(nb) + np.maximum.at(self.band_lean, band, lean) + self.triangles = len(faces) + lo, hi = verts.min(0), verts.max(0) + self.size = hi - lo + self.top = self.H + + # --------------------------------------------------------- shown shell + @property + def wall(self) -> float: + """How thick the printed wall is: one spiral line, or a fake vase's walls side by side.""" + p = self.p + return p.line_width * (max(int(p.walls), 1) if p.vase_mode == FAKE_VASE else 1) + + @property + def normal(self) -> bool: + """Printed normally, with walls - rather than seamless, in spiral vase mode.""" + return self.p.vase_mode == FAKE_VASE + + def _pot(self, O, I): + """What the printer makes: the hollow pot - the outer face O and inner face I (K, N, 2) at the grid's + heights, the floor or an open bottom, the rim - with any holes cut right through. For a normal print it + is what is exported; seamless, it is what the single line makes, for the 3D view.""" + p = self.p + zb = 0.0 if p.open_bottom else min(max(p.bottom, p.layer_height), 0.5 * self.H) + self.zb = zb + f = self._holes(O, zb) + if p.open_bottom: + zs, (Os, Is, fs), kb = self.z, (O, I, f), None + else: + zs, (Os, Is, fs), at = hollow.insert_rows(self.z, [O, I, f], [zb]) + kb = at[zb] + if p.box: # the ledges a lid clicks in behind + heights = ledge_heights(p) + zs, (Os, Is, fs), _ = hollow.insert_rows(zs, [Os, Is, fs], [self.H - h for h in heights]) + Is = ledges(zs, Is, self.H, heights) + b = None + if p.hole_swell > 0 and (fs > 0).any(): + # rounded edges: the wall swells out and in round every hole, curving from outside to in + far = max(6.0, 0.5 * p.hole_size) if p.holes != NO_HOLES else 6.0 + b = hollow.swell(fs, zs, p.hole_swell, far, 0.6 * math.tan(math.radians(p.max_overhang))) + kr = self._roof(zs, Os, kb) if p.closed_top else None + if p.box and p.tissue_hole != NO_TISSUE and kb is not None: + self.floor_hole = self._tissue_hole(Is[kb]) + if not self.draft: + # leave out the rows and columns that lie in line with their neighbours: far fewer triangles where the + # wall is flat or straight, every one kept where a pattern, a hole or a curve needs it + b = np.zeros_like(fs) if b is None else b + zs, Os, Is, fs, b, rk = hollow.simplify(zs, Os, Is, fs, b, [k for k in (kb, kr) if k is not None]) + kb = None if kb is None else int(np.searchsorted(rk, kb)) + kr = None if kr is None else int(np.searchsorted(rk, kr)) + verts, faces, kinds = hollow.pot(zs, Os, Is, fs, floor_row=kb, roof_row=kr, swell=b, + floor_hole=self.floor_hole) + kinds, self.bridge, side_worst = hollow.bridges(verts, faces, kinds, p.max_overhang, + longest=max(8.0, self.bridge_allowed + 1.0)) + self.pot_rows = (zs, Os, Is, fs, kb, kr, np.zeros_like(fs) if b is None else b) + lo, hi = (0 if kb is None else kb), (len(zs) - 1 if kr is None else kr) + if kr is not None: + self.ceiling = hollow.bridge_span(Is[kr]) + fs = np.where(np.arange(len(zs))[:, None] >= kr, np.minimum(fs, -1e-9), fs) + # the head it fits: round the inside, where the head goes in - the narrowest place below the + # widest; a mask, the widest place of the head it is shaped round + per = hollow.row_lengths(Is[lo:hi + 1])[2] + widest = int(np.argmax(per)) + fit = float(per.max() if p.around < 360.0 else per[:widest + 1].min()) + self.head_fit = max(fit - HEAD_ROOM, 0.0) + self.holes_open = bool((fs > 0).any()) + self.hole_share = float((fs > 0).mean()) if self.holes_open else 0.0 + self._set_shell(verts, faces, kinds) + self.worst = max(self.worst, side_worst) + if self.normal: # what is exported, and what the panel counts + self.triangles = len(faces) + self.size = verts.max(0) - verts.min(0) + if not self.draft: + self.closed = bool(self.closed) and edge_check(faces)[0] + self.inner_rows = [(zz, np.arctan2(r[:, 1], r[:, 0]), np.hypot(r[:, 0], r[:, 1])) + for zz, r in zip(zs[lo:hi + 1], Is[lo:hi + 1])] + + def _tissue_hole(self, inside): + """The hole in a tissue box cover's top for the tissues to come out of, as a loop round the middle, + anticlockwise - kept TISSUE_MARGIN clear of the walls inside. None if there is no room for it.""" + p = self.p + along_y = p.turn1 % 180.0 > 45.0 # the box's length runs along y + half = np.abs(np.asarray(inside, dtype=float)).max(axis=0) - TISSUE_MARGIN + long_room, short_room = (half[1], half[0]) if along_y else (half[0], half[1]) + w = min(0.5 * p.tissue_width, short_room) + length = w if p.tissue_hole == TISSUE_ROUND else max(min(0.5 * p.tissue_length, long_room), w) + if p.tissue_hole == TISSUE_ROUND: + w = length = min(w, long_room) + if w < 3.0: + self.notes.append("There is no room in the top for the tissue hole.") + return None + if w < 0.5 * p.tissue_width - 0.05 or (p.tissue_hole != TISSUE_ROUND and length < 0.5 * p.tissue_length - 0.05): + self.notes.append(f"The tissue hole was made smaller, to keep {TISSUE_MARGIN:g} mm of the top round it.") + step = max(p.facet_size, 0.3) * (2.6 if self.draft else 1.0) + if p.tissue_hole == TISSUE_OVAL: + n = max(int(2.0 * math.pi * max(length, w) / step), 48) + t = np.arange(n) * (TAU / n) + xs, ys = length * np.cos(t), w * np.sin(t) + else: # a slot: round ends; a circle + n = max(int((4.0 * length + 2.3 * w) / step), 48) + xs, ys = rounded_rectangle(length, w, w, per=n) + self.tissue = (2.0 * length, 2.0 * w) + return np.stack([-ys, xs] if along_y else [xs, ys], axis=1) + + def _roof(self, zs, Os, kb): + """The row a closed top's ceiling is on: as high as leaves the point above it as thick as the floor, + where the wall still has room inside it.""" + p = self.p + rmin = np.hypot(Os[..., 0], Os[..., 1]).min(axis=1) + top = self.H - max(p.bottom, 2.0 * p.layer_height) + ok = np.nonzero((rmin >= self.wall + 0.6) & (zs <= top + 1e-9) & (np.arange(len(zs)) > (kb or 0) + 1))[0] + if not len(ok): + self.notes.append("The vase is too small to close over at the top: it is left open.") + return None + return int(ok[-1]) + + def _set_shell(self, verts, faces, kinds=None): + """The shell to show, and how far each face leans: a hole's side only counts where it leans out over + the hole (its sill faces up, and prints as it is); the bridge at a hole's very top is as steep as allowed.""" + lean = lean_angles(verts, faces) + if kinds is not None: + over = np.degrees(np.arcsin(np.clip(-face_normals(verts, faces)[:, 2], 0.0, 1.0))) + lean = np.where(kinds == hollow.SIDE, over, lean) + lean = np.where(kinds == hollow.TIP, np.minimum(over, self.p.max_overhang), lean) + lean = np.where(kinds == hollow.CAP, -1.0, lean) + self.shell_verts, self.shell_faces, self.shell_kind, self.shell_lean = verts, faces, kinds, lean + + def _holes(self, O, zb): + """The hole pattern on the grid: how far inside a hole each point is (mm, below nought in the wall), + with pointed tops that print without support.""" + p = self.p + K, N = O.shape[:2] + f = np.full((K, N), -1.0) + if not self.normal: + return f + seg, along, L = hollow.row_lengths(O) + slope = 0.85 * math.tan(math.radians(p.max_overhang)) + face = None + if face_holes(p) or p.around < 360.0: + face = hollow.openings(hollow.from_front(O, seg, along, L), self.z, L, p) + if (face > 0).any(): + face = hollow.pointed_tops(face, self.z, O, slope) + f = face + bridge = None + if p.holes != NO_HOLES: + # a strut's width of wall round the face and along the edges + room = None if face is None else -face - max(p.hole_strut, 2.0) + pattern, spans = self._pattern_holes(O, zb, seg, along, L, room) + self.pattern_open = bool((pattern > 0).any()) + f = pattern if face is None else np.maximum(face, pattern) + if spans is not None: # Voronoi ovals: their round tops close over a short bridge + bridge = np.where(pattern > 0, spans, 0.0) + self.bridge_allowed = float(bridge.max()) + if not (f > 0).any(): + return f + return hollow.pointed_tops(f, self.z, O, slope, bridge) + + def _pattern_holes(self, O, zb, seg, along, L, room=None): + """The pattern of holes, Voronoi, round, diamonds or slots, as a hole field - only where room is above + nought, if given.""" + p = self.p + K, N = O.shape[:2] + # the holes turn with the twist - but no faster than a wall may lean, so their sides stay printable + turn = np.zeros(K) + if p.texture_twist: + r = np.maximum(L / TAU, 1.0) + turn = limit_slope(self.tau.astype(float), 0.6 * math.tan(math.radians(p.max_overhang)) + * np.diff(self.z) / np.minimum(r[1:], r[:-1])) + # measured round each row from the point at that angle + ang = np.arctan2(O[..., 1], O[..., 0]) + c0 = np.argmin(np.abs(np.mod(ang - turn[:, None] + math.pi, TAU) - math.pi), axis=1) + start = along[np.arange(K), c0] + t = np.mod((along - start[:, None]) / L[:, None], 1.0) + z_lo = zb + max(p.hole_foot, 0.0) + z_hi = (self.dome_from if p.closed_top else self.H) - max(p.hole_rim, LEDGE_CLEAR if p.box else 0.0) + f, spans = hollow.hole_field(p.holes, t, self.z, L, p.hole_size, p.hole_strut, p.hole_jitter, p.hole_seed, + z_lo, z_hi, room, 0.85 * math.tan(math.radians(p.max_overhang)), + min(VORONOI_BRIDGE * p.hole_size, VORONOI_LONGEST)) + if not (f > 0).any(): + self.notes.append("There is no room for the holes between the plain foot and rim: make the vase " + "taller, the holes smaller, or the plain foot and rim less.") + return f, spans + + def print_mesh(self): + """The vase as one closed mesh for the slicer: the hollow pot for a normal print, the solid for + spiral vase mode.""" + if self.normal: + return self.shell_verts, self.shell_faces + return self.verts, self.faces + + def export_mesh(self): + """The mesh to save for the slicer: print_mesh, simplified (see ``lighter``).""" + return lighter(*self.print_mesh()) + + def export_parts(self): + """The parts to save for the slicer: print_parts, each simplified (see ``lighter``).""" + return [(fil, *lighter(v, f)) for fil, v, f in self.print_parts()] + + def print_parts(self): + """The vase for the slicer as [(filament, verts, faces)]: printed normally, the hollow pot, a part for + each band of colour; seamless, the solid.""" + if not self.normal: + return self.band_parts() + if self.pot_rows is None: + return self._loft_parts() + zs, Os, Is, fs, kb, kr, swollen = self.pot_rows + floor = kb is not None + # bands start on top of the floor, and the solid point of a closed top is one with the band below it + lowest = (zs[kb] if floor else 0.0) + 0.5 * self.p.layer_height + highest = self.H if kr is None else zs[kr] - 0.5 * self.p.layer_height + tops = sorted({max(z, lowest) for z, _ in self.bands[1:] if z < min(self.H - 1e-6, highest)}) + z2, (O2, I2, f2, s2), at = hollow.insert_rows(zs, [Os, Is, fs, swollen], tops) + kb2 = int(np.argmin(np.abs(z2 - zs[kb]))) if floor else None + kr2 = None if kr is None else int(np.argmin(np.abs(z2 - zs[kr]))) + bounds = [0] + [at[t] for t in tops] + [len(z2) - 1] + fils = [fil for _, fil in self.bands][:len(bounds) - 1] + parts = [] + for fil, a, b in zip(fils, bounds[:-1], bounds[1:]): + rows = slice(a, b + 1) + verts, faces, _ = hollow.pot(z2[rows], O2[rows], I2[rows], f2[rows], + floor_row=kb2 if (floor and a == 0) else None, + roof_row=kr2 - a if (kr2 is not None and b == len(z2) - 1) else None, + swell=s2[rows], floor_hole=self.floor_hole if a == 0 else None) + parts.append((fil, verts, faces)) + return parts + + def _loft_parts(self): + """Low-poly, printed normally: the facets' hollow shell, a part for each band of colour.""" + w, zb = self.wall, self.zb + tops = [zs for zs, _ in self.bands[1:]] + [self.H] + parts = [] + for (lo, fil), hi in zip(self.bands, tops): + lo = max(lo, zb + 0.5 * self.p.layer_height) if lo > 0 else 0.0 + rings = [self._ring_at(lo)] + [r for r in self.rows if lo + 1e-6 < r[0] < hi - 1e-6] + [self._ring_at(hi)] + if lo == 0.0: + inner = [self._ring_at(zb)] + [r for r in rings if r[0] > zb + 1e-6] + else: + inner = rings + inner = [(zz, a, inset(a, r, w)) for zz, a, r in inner] + o_v, o_f, _, o_first, o_last = loft_side(rings) + i_v, i_f, _, i_first, i_last = loft_side(inner) + no, ni = len(o_v), len(i_v) + verts = np.concatenate([o_v, i_v, [[0.0, 0.0, 0.0], [0.0, 0.0, zb]]]) + faces = [o_f, i_f[:, ::-1] + no, annulus(o_last, i_last + no)] + if lo == 0.0: + faces += [fan(no + ni, o_first, up=False), fan(no + ni + 1, i_first + no, up=True)] + else: + faces.append(annulus(o_first, i_first + no)[:, ::-1]) + parts.append((fil, verts, np.concatenate(faces))) + return parts + + def _shell(self): + """What the printer really makes: the wall over a solid bottom, for the 3D view.""" + p = self.p + if needs_normal(p) or p.closed_top: + self.notes.append("Holes, a face, open ends and a closed top work on smooth shapes, not low-poly " + "facets.") + w = self.wall + zb = min(max(p.bottom, p.layer_height), 0.5 * self.H) + self.zb = zb + if self.rows is None: + z, R = self.z, self.R + keep = z > zb + 0.3 * self.dz + fj = zb / self.dz + j0 = min(int(fj), self.M - 1) + first = R[j0] + (R[j0 + 1] - R[j0]) * (fj - j0) + zi = np.concatenate([[zb], z[keep]]) + Ri = inset(self.theta, np.vstack([first, R[keep]]), w) + o_verts, o_faces, _, o_first, o_last = grid_side(z, self.theta, R) + i_verts, i_faces, _, i_first, i_last = grid_side(zi, self.theta, Ri) + self.inner_rows = [(zz, self.theta, rr) for zz, rr in zip(zi, Ri)] + else: + rows = self.rows + inner = [(zb, rows[0][1], inset(rows[0][1], rows[0][2], w))] + inner += [(zz, a, inset(a, r, w)) for zz, a, r in rows[1:] if zz > zb + 0.3] + o_verts, o_faces, _, o_first, o_last = loft_side(rows) + i_verts, i_faces, _, i_first, i_last = loft_side(inner) + self.inner_rows = inner + no, ni = len(o_verts), len(i_verts) + verts = np.concatenate([o_verts, i_verts, [[0.0, 0.0, 0.0], [0.0, 0.0, zb]]]) + cb, cf = no + ni, no + ni + 1 + walls = np.concatenate([o_faces, i_faces[:, ::-1] + no]) + caps = np.concatenate([fan(cb, o_first, up=False), fan(cf, i_first + no, up=True), + annulus(o_last, i_last + no)]) + self.shell_verts = verts + self.shell_faces = np.concatenate([walls, caps]) + self.shell_kind = None + self.shell_lean = np.concatenate([lean_angles(verts, walls), np.full(len(caps), -1.0)]) + if self.normal: # what is exported, and what the panel counts + self.triangles = len(self.shell_faces) + + # ======================================================= drawn outlines + def _outline(self, k: int): + """Cross-section k (1 at the bottom, 2 at the top) as a loop of points, widest at radius 1.""" + p = self.p + kind = getattr(p, f"shape{k}") + if kind == DRAWN: + pts = getattr(p, f"drawn{k}") + loop = sections.spline(pts, getattr(p, f"repeat{k}")) + hits = sections.piece_crossings(loop, 48 * len(pts)) + if hits: + x, y = hits[0] + where = "bottom" if k == 1 else "top" + self.errors.append(f"The drawn {where} outline crosses itself (near {x:.2f}, {y:.2f} in the " + "drawing): move its points apart so it makes one simple loop.") + else: + loop = sections.from_table(shape_table(kind, getattr(p, f"count{k}"), getattr(p, f"depth{k}"), + getattr(p, f"sharp{k}"))) + loop = sections.normalised(loop) + a = math.radians(getattr(p, f"turn{k}")) + c, s = math.cos(a), math.sin(a) + return loop @ np.array([[c, s], [-s, c]]) + + def _free_surface(self): + """The designed surface of a vase with a drawn outline: rows of points (M + 1, N, 2).""" + p = self.p + self.H = H = float(p.height) + loops = [self._outline(1)] + ([self._outline(2)] if p.shape2 != SAME else []) + if self.errors: + return + sil = np.asarray(p.silhouette, dtype=float) + tex = ((abs(p.rib_depth) if p.rib_count > 0 else 0.0) + (abs(p.ring_depth) if p.ring_count > 0 else 0.0) + + (abs(p.bump_depth) if p.bump_around > 0 else 0.0) + 1.2 * max(p.organic_depth, 0.0)) + self.tex_depth = tex + perimeter = max(float(np.linalg.norm(np.roll(L, -1, axis=0) - L, axis=1).sum()) for L in loops) + step = max(p.facet_size, 0.15) * (2.6 if self.draft else 1.0) + per = 10 if self.draft else 22 + reps = max([getattr(p, f"repeat{k}") for k in (1, 2) if getattr(p, f"shape{k}") == DRAWN] + [1]) + N = max(math.ceil(perimeter * (0.5 * sil[:, 1].max() + tex) / step), + per * max(p.rib_count * (2 if p.rib_cross else 1), p.bump_around, reps), 160) + M = max(math.ceil(H / step), (8 if self.draft else 16) * max(p.ring_count, 1), 24) + if p.bump_around > 0: + M = max(M, (8 if self.draft else 16) * p.bump_up) + if p.organic_depth > 0: + fine = max(p.organic_scale, 2.0) / (5 if self.draft else 10) + N = max(N, math.ceil(perimeter * 0.5 * sil[:, 1].max() / fine)) + M = max(M, math.ceil(H / fine)) + if self.fn is not None: + N = max(N, 180 if self.draft else 480) + limit = 140_000 if self.draft else 1_200_000 + if N * M > limit: + f = math.sqrt(limit / (N * M)) + N, M = max(int(N * f), 64, 8 * reps), max(int(M * f), 16) # each repeat keeps a few points + self.N, self.M = (N + 3) // 4 * 4, M + self.z = z = np.linspace(0.0, H, M + 1) + self.dz = H / M + h = z / H + self.C1 = sections.resample(loops[0], self.N) + self.C2 = sections.resample(loops[1], self.N) if len(loops) > 1 else None + self.R0_d, q = self._dome(z, 0.5 * pchip(sil[:, 0], sil[:, 1], h)) + self.tau = self.tau_d = self.twist_at(h) + self.mix_d = (np.clip(change(p.morph, h, p.morph_curve, p.morph_swings), 0.0, 1.0) if self.C2 is not None + else np.zeros_like(h)) + self.env_d = self._envelope(z) * q # the patterns shrink with a closed top's point + if p.lattice and (p.twist_style == OWN or abs(p.twist) > 1e-9): + self.notes.append("Both ways (lattice) works on the built-in shapes only: a drawn outline twists one way.") + if p.facets: + self.notes.append("Low-poly facets work on the built-in shapes only: a drawn outline stays smooth.") + self.P_design = self._free_points(self.R0_d, self.tau_d, self.mix_d, self.env_d) + # a blend of two outlines, or deep patterns in a tight corner, can make a layer cross itself + for j in np.linspace(0, M, 7).astype(int): + if sections.crossings(self.P_design[j], limit=max(700, 12 * reps)): + self.warnings.append(f"The outline crosses itself at about {z[j]:.0f} mm up (where the shapes " + "blend, or patterns meet in a tight corner): the slicer may fill it wrongly " + "there. Ease the change of shape or the patterns.") + break + + def _free_points(self, R0, tau, mix, env): + """The surface's points: the outline blended, sized, turned, then patterned square to the wall.""" + p = self.p + base = (self.C1[None] if self.C2 is None + else (1.0 - mix)[:, None, None] * self.C1 + mix[:, None, None] * self.C2) + P = base * R0[:, None, None] + c, s = np.cos(tau)[:, None], np.sin(tau)[:, None] + x, y = P[..., 0], P[..., 1] + P = np.stack([x * c - y * s, x * s + y * c], -1) + # patterns run round the outline (turning with it), or round the axis if they shouldn't turn + along = np.arange(self.N) * (TAU / self.N) + frame = (np.broadcast_to(along, P.shape[:2]) if p.texture_twist + else np.mod(np.arctan2(P[..., 1], P[..., 0]), TAU)) + out = sections.normals(P) + tex = env[:, None] * self._texture(frame, self.z, float(R0.mean())) + if self.fn is not None: + tex = tex + self.fn(theta=np.mod(np.arctan2(P[..., 1], P[..., 0]), TAU), phi=frame, + z=self.z[:, None], h=(self.z / self.H)[:, None], r=np.hypot(P[..., 0], P[..., 1]), + H=np.float64(self.H)) + if p.nose == RAISED_NOSE: + s = (np.mod(np.arctan2(P[..., 1], P[..., 0]) + 0.5 * math.pi + math.pi, TAU) - math.pi) * R0[:, None] + tex = tex + hollow.nose(s, self.z, p, 0.9 * math.tan(math.radians(p.max_overhang))) + return P + tex[..., None] * out + + def _free_solid(self, P): + """The closed solid from rows of outline points: (verts, faces, band of each wall face, wall face count).""" + verts, faces, band, first, last = grid_side_xy(self.z, P) + side = len(faces) + bottom = first[sections.triangulate(P[0])][:, ::-1] # facing down, onto the bed + top = last[sections.triangulate(P[-1])] + return verts, np.concatenate([faces, bottom, top]), band, side + + def _free_printable(self): + """Soften a drawn vase until no face leans past the limit. + + A drawn outline itself stands upright; only the changes up the vase + lean it - the outline widening or narrowing, the twist, the change of + shape and the patterns. Where faces lean too far, those changes are + slowed at that height (the same envelopes as for the columns of a + radial vase) and the patterns eased, until every face is within it. + """ + p = self.p + R0, tau, mix, env = self.R0_d.copy(), self.tau_d.copy(), self.mix_d.copy(), self.env_d.copy() + aim = math.tan(math.radians(max(p.max_overhang - 0.4, 1.0))) + # patterns fade in and out over a few layers: a sudden change of depth would lean the wall itself + fade = 0.1 * aim * self.dz / max(self.tex_depth, 1e-6) + P = self.P_design + for attempt in range(24 if p.soften else 1): + if attempt: + P = self._free_points(R0, tau, mix, env) + verts, faces, band, side = self._free_solid(P) + lean = lean_angles(verts, faces[:side]) + worst = float(lean.max()) + if worst <= p.max_overhang + TOLERANCE or not p.soften: + break + over = np.zeros(self.M) + np.maximum.at(over, band, lean) + f = np.where(over > p.max_overhang, aim / np.tan(np.radians(np.minimum(over, 89.0))), 1.0) + f = np.clip(f, 0.1, 1.0) + pad = np.concatenate([[f[0]] * 2, f, [f[-1]] * 2]) + f = np.minimum.reduce([pad[k:k + self.M] for k in range(5)]) # a little room each side + R0 = limit_slope(R0, np.abs(np.diff(R0)) * f) + tau = limit_slope(tau, np.abs(np.diff(tau)) * f) + mix = limit_slope(mix, np.abs(np.diff(mix)) * f) + env = fall_gently(env * np.minimum(np.concatenate([[f[0]], f]), np.concatenate([f, [f[-1]]])), fade) + self.P, self.tau, self.rows = P, tau, None + # what softening changed, in the design's own terms + with np.errstate(divide="ignore", invalid="ignore"): + kept = np.where(self.env_d > 1e-6, env / self.env_d, 1.0) + self.eased = dict(twist=float(np.degrees(np.abs(tau - self.tau_d)).max()), + width=float(2.0 * np.abs(R0 - self.R0_d).max()), + change=float(np.abs(mix - self.mix_d).max()), patterns=float(np.min(kept))) + self.verts, self.faces, self.side = verts, faces, side + self.band, self.lean, self.worst = band, lean, worst + moved = np.linalg.norm(P - self.P_design, axis=-1) + self.row_moved = moved.max(axis=1) + self.softened = float(moved.max()) if p.soften else 0.0 + if not self.draft: + self.closed = edge_check(faces)[0] + self.band_lean = np.zeros(self.M) + np.maximum.at(self.band_lean, band, lean) + self.triangles = len(faces) + self.size = verts.max(0) - verts.min(0) + self.top = self.H + + def _P_at(self, zq): + """The outline's points at height zq, between the rows.""" + fj = min(max(zq / self.dz, 0.0), self.M) + j = min(int(fj), self.M - 1) + return self.P[j] + (fj - j) * (self.P[j + 1] - self.P[j]) + + # ---------------------------------------------------------- the sums + def _ring_stats(self, rows): + """Perimeter and enclosed area of each ring.""" + per, area = [], [] + for _, a, r in rows: + x, y = r * np.cos(a), r * np.sin(a) + x1, y1 = np.roll(x, -1), np.roll(y, -1) + per.append(float(np.hypot(x1 - x, y1 - y).sum())) + area.append(float(0.5 * (x * y1 - x1 * y).sum())) + return np.array(per), np.array(area) + + def _measure(self): + p = self.p + w, lh = self.wall, p.layer_height + loops = max(int(p.walls), 1) if p.vase_mode == FAKE_VASE else 1 # lines round each layer + if self.outer_rows is not None: + outer = self.outer_rows + elif self.rows is not None: + outer = self.rows + else: + outer = [(zz, self.theta, rr) for zz, rr in zip(self.z, self.R)] + oz = np.array([o[0] for o in outer]) + o_per, o_area = self._ring_stats(outer) + iz = np.array([i[0] for i in self.inner_rows]) + _, i_area = self._ring_stats(self.inner_rows) + zb = self.zb + above = oz >= zb + zs = np.concatenate([[zb], oz[above]]) + ps = np.concatenate([[np.interp(zb, oz, o_per)], o_per[above]]) + wall_area = float(np.sum(0.5 * (ps[1:] + ps[:-1]) * np.diff(zs))) # mm² of wall, unrolled + bottom_vol = float(np.interp(0.0, oz, o_area)) * zb + self.capacity = float(np.sum(0.5 * (i_area[1:] + i_area[:-1]) * np.diff(iz))) / 1000.0 # ml + if self.holes_open or p.open_bottom or p.closed_top or p.box: + self.capacity = 0.0 + fil_area = math.pi * (p.filament_d / 2.0) ** 2 + vol = mesh_volume(self.shell_verts, self.shell_faces) # the plastic, holes and all + wall_area *= 1.0 - self.hole_share + self.grams = vol / 1000.0 * p.density + self.metres = vol / fil_area / 1000.0 + v = min(p.speed, p.max_flow / max(p.line_width * lh, 1e-6)) + layers_bottom = 0 if p.open_bottom else max(1, math.ceil(zb / lh - 1e-6)) + path = wall_area * loops / lh + layers_bottom * float(np.interp(0.0, oz, o_area)) / p.line_width + self.seconds = path / max(v, 0.1) + 120.0 + self.layers = int(round(self.H / lh)) + self.bottom_layers = layers_bottom + # filament used up to each height, for a gradient spool's colours + cum = np.concatenate([[0.0], np.cumsum(0.5 * (o_per[1:] + o_per[:-1]) * np.diff(oz))]) * w + cum += np.minimum(oz, zb) * float(np.interp(0.0, oz, o_area)) + self.filament_z, self.filament_at = oz, cum / fil_area # mm of filament + self.solid_volume = mesh_volume(self.verts, self.faces) / 1000.0 # cm³ + r0, rt = outer[0][2], self.inner_rows[-1][2] + self.base_d = (2 * float(r0.min()), 2 * float(r0.max())) + self.top_d = (2 * float(rt.min()), 2 * float(rt.max())) + self.widest = 2 * float(max(o[2].max() for o in outer)) + # for the profile picture: the widest and narrowest radius at each height + self.profile_z = oz + self.profile_max = np.array([o[2].max() for o in outer]) + self.profile_min = np.array([o[2].min() for o in outer]) + + # ----------------------------------------------------------- colours + def _colours(self): + """The filament for each height: [(from z, filament)], snapped to layers.""" + p = self.p + lh, H = p.layer_height, self.H + used = max(1, min(int(p.colours_used), 4)) + bands = [(0.0, 1)] + if colour_style_of(p) == BANDS: + k = max(int(p.band_count), 1) + gamma = 2.0 ** (1.3 * p.band_spacing) + bands = [(H * (i / k) ** gamma, 1 + i % used) for i in range(k)] + elif colour_style_of(p) == STRIPES: + stripe = max(p.stripe_height, lh) + n = math.ceil(H / stripe - 1e-9) + if n > MAX_BANDS: + stripe = H / MAX_BANDS + n = MAX_BANDS + self.warnings.append(f"Stripes that thin would be more than {MAX_BANDS} colour changes: " + f"they are made {stripe:.1f} mm tall.") + bands = [(i * stripe, 1 + i % used) for i in range(n)] + snapped = [] + for i, (zs, fil) in enumerate(bands): + zs = 0.0 if i == 0 else round(zs / lh) * lh + if zs >= H - 0.5 * lh: # starts at the very top: never printed + continue + if snapped and zs <= snapped[-1][0] + 0.5 * lh: + snapped[-1] = (snapped[-1][0], fil) # the band before has no layers: this one takes over + else: + snapped.append((zs, fil)) + merged = [] + for zs, fil in snapped: + if not merged or merged[-1][1] != fil: + merged.append((zs, fil)) + self.bands = merged or [(0.0, 1)] + self.gradient = colour_style_of(p) == GRADIENT + self.gradient_fils = list(range(1, used + 1)) + + def filament_of(self, k: int) -> str: + return getattr(self.p, f"color{k}").upper() + + def band_parts(self): + """The vase as one closed solid per colour band: [(filament, verts, faces)], bottom to top. + + Neighbouring bands meet on a shared flat face at the height where the + colour changes, so together they fill the vase exactly once - the way + a slicer wants the parts of a multi-colour object. + """ + tops = [zs for zs, _ in self.bands[1:]] + [self.H] + parts = [] + for (lo, fil), hi in zip(self.bands, tops): + if self.free: + inside = (self.z > lo + 1e-6) & (self.z < hi - 1e-6) + zs = np.concatenate([[lo], self.z[inside], [hi]]) + Ps = np.concatenate([self._P_at(lo)[None], self.P[inside], self._P_at(hi)[None]]) + verts, faces, _, first, last = grid_side_xy(zs, Ps) + faces = np.concatenate([faces, first[sections.triangulate(Ps[0])][:, ::-1], + last[sections.triangulate(Ps[-1])]]) + parts.append((fil, verts, faces)) + continue + if self.rows is None: + inside = (self.z > lo + 1e-6) & (self.z < hi - 1e-6) + zs = np.concatenate([[lo], self.z[inside], [hi]]) + Rs = np.vstack([self._row_at(lo), self.R[inside], self._row_at(hi)]) + verts, faces, _, first, last = grid_side(zs, self.theta, Rs) + else: + rings = [self._ring_at(lo)] + [r for r in self.rows if lo + 1e-6 < r[0] < hi - 1e-6] + [self._ring_at(hi)] + verts, faces, _, first, last = loft_side(rings) + n = len(verts) + verts = np.concatenate([verts, [[0.0, 0.0, lo], [0.0, 0.0, hi]]]) + faces = np.concatenate([faces, fan(n, first, up=False), fan(n + 1, last, up=True)]) + parts.append((fil, verts, faces)) + return parts + + def _row_at(self, zq): + """The surface's radii at height zq, between the grid's rows (straight up each column).""" + fj = min(max(zq / self.dz, 0.0), self.M) + j = min(int(fj), self.M - 1) + return self.R[j] + (fj - j) * (self.R[j + 1] - self.R[j]) + + def _ring_at(self, zq): + """The low-poly wall's cross-section at height zq, as a ring (z, angles, radii).""" + for row in self.rows: + if abs(row[0] - zq) < 1e-6: + return row + j = max(k for k, row in enumerate(self.rows) if row[0] < zq) + verts, faces, *_ = loft_side(self.rows[j:j + 2]) + tri = verts[faces] + d = tri[:, :, 2] - zq + pts = [] + for a, b in ((0, 1), (1, 2), (2, 0)): + hit = d[:, a] * d[:, b] < 0 + t = d[hit, a] / (d[hit, a] - d[hit, b]) + pts.append(tri[hit, a] + (tri[hit, b] - tri[hit, a]) * t[:, None]) + pts = np.unique(np.round(np.concatenate(pts)[:, :2], 6), axis=0) + ang = np.mod(np.arctan2(pts[:, 1], pts[:, 0]), TAU) + order = np.argsort(ang) + ang, rad = ang[order], np.hypot(pts[order, 0], pts[order, 1]) + keep = np.concatenate([[True], np.diff(ang) > 1e-7]) + return (zq, ang[keep], rad[keep]) + + def colour_changes(self, first_layer: float | None = None): + """[(layer number, height of that layer's top, filament)] for each change after the first. + + first_layer: the slicer's first layer height, if it differs from the rest. + """ + lh = self.p.layer_height + return [(*layer_top(zs, lh, first_layer), fil) for zs, fil in self.bands[1:]] + + def colour_phase(self, z): + """Colour cycles of a gradient spool used by each height.""" + length = max(self.p.gradient_length, 0.05) * 1000.0 + return np.interp(z, self.filament_z, self.filament_at) / length + + def colour_plan(self, first_layer: float | None = None) -> str: + p = self.p + lines = [f"Vaseinator: {p.name} - {'a normal print' if self.normal else 'spiral vase mode'}, " + f"{p.layer_height:g} mm layers"] + if self.gradient: + lines.append(f"Print with one gradient spool (one colour cycle every {p.gradient_length:g} m).") + return "\n".join(lines) + lines.append(f"Start with filament {self.bands[0][1]} ({self.filament_of(self.bands[0][1])})") + for layer, ztop, fil in self.colour_changes(first_layer): + lines.append(f"Layer {layer} ({ztop:.2f} mm): change to filament {fil} ({self.filament_of(fil)})") + return "\n".join(lines) + + # ------------------------------------------------------------ advice + def _advice(self): + p = self.p + if self.worst > p.max_overhang + TOLERANCE: + if p.soften: + self.warnings.append( + f"Some of the wall still leans {self.worst:.0f}° (the limit is {p.max_overhang:g}°). " + "With low-poly facets, more facets round the vase or fewer up it lean less.") + else: + self.warnings.append( + f"The wall leans up to {self.worst:.0f}°, over the {p.max_overhang:g}° limit: it would " + "sag or fail there (red in the Overhangs view). Turn on Soften to fit, or make the " + "shape gentler.") + elif self.softened > 0.05 and self.free: + e = self.eased + what = [f"the twist by up to {e['twist']:.0f}°" if e["twist"] > 0.5 else "", + f"the outline's width by up to {e['width']:.1f} mm" if e["width"] > 0.05 else "", + f"the change of shape by up to {100 * e['change']:.0f}%" if e["change"] > 0.005 else "", + f"the patterns to {100 * e['patterns']:.0f}% at the steepest place" if e["patterns"] < 0.995 else ""] + what = [w for w in what if w] + self.notes.append("Overhangs softened, so no part of the wall leans more than " + f"{p.max_overhang:g}°: " + (", ".join(what) if what else "slightly") + ".") + elif self.softened > 0.05: + self.notes.append(f"Overhangs softened: the surface was moved by up to {self.softened:.1f} mm so " + f"no part of the wall leans more than {p.max_overhang:g}°.") + if self.H > p.printer_height: + self.warnings.append(f"The vase is {self.H:.0f} mm tall: taller than the printer ({p.printer_height:.0f} mm).") + sx, sy = self.size[0], self.size[1] + if sx > p.bed_x or sy > p.bed_y: + self.warnings.append(f"It is {sx:.0f} × {sy:.0f} mm across: bigger than the bed " + f"({p.bed_x:.0f} × {p.bed_y:.0f} mm).") + if self.H > 3.2 * self.base_d[1] and self.H > 60: + self.warnings.append(f"The base is narrow for the height (Ø {self.base_d[1]:.0f} mm under a " + f"{self.H:.0f} mm vase): it may topple, on the bed or with flowers in. " + "A brim helps it print.") + if self.top_d[0] < 8.0 and not p.closed_top and not p.box: + self.notes.append(f"The opening at the top is only {self.top_d[0]:.0f} mm across: a bud vase.") + changes = len(self.bands) - 1 + if changes > 20: + self.warnings.append(f"{changes} colour changes: each one takes time and leaves a small seam. " + "Fewer, taller bands print cleaner.") + if self.pattern_open and p.hole_strut < 2.0 * p.line_width: + self.warnings.append(f"The wall between the holes ({p.hole_strut:g} mm) is thinner than two lines " + f"({2 * p.line_width:g} mm): it may print thin or break.") + if p.line_width < 0.45: + self.notes.append("A wider line (0.6 to 0.8 mm on a 0.4 mm nozzle) makes a stronger, more surely " + "watertight wall.") + if self.closed is False: # pragma: no cover - the construction should never allow it + self.warnings.append("The mesh is not closed: please report this design.") + + # -------------------------------------------------------------- view + def info(self) -> dict: + p = self.p + lean = f"{self.worst:.1f}° from upright (limit {p.max_overhang:g}°)" + lean += " ✓" if self.worst <= p.max_overhang + TOLERANCE else " ✗" + mesh = f"{self.triangles:,} triangles" + if self.closed: + mesh += ", closed and watertight ✓" + d = { + "Height": f"{self.H:.1f} mm, {self.layers} layers of {p.layer_height:g} mm", + "Widest": f"Ø {self.widest:.1f} mm", + "Footprint": f"{self.size[0]:.1f} × {self.size[1]:.1f} mm", + "Base": f"Ø {self.base_d[0]:.0f}–{self.base_d[1]:.0f} mm", + **({"Top": ("closed in a point" + (f", over a ceiling {self.ceiling:.0f} mm across" if self.ceiling else "")) + if self.normal else "comes in to a point (spiral vase mode prints no top: a hole a few mm " + "across stays at the very top)"} + if p.closed_top else {"Opening at the top": f"Ø {self.top_d[0]:.0f}–{self.top_d[1]:.0f} mm"}), + **({"Goes round": f"{p.around:g}° of the way round"} if p.around < 360.0 else {}), + **({"Fits a head": f"up to {self.head_fit / 10.0:.1f} cm round (round the forehead, above the ears)"} + if p.worn and self.head_fit > 0 else {}), + **({"Fits over": f"a box of tissues {p.box_length / 10.0:g} × {p.box_width / 10.0:g} × " + f"{p.box_height / 10.0:g} cm, with {self.box_room:.1f} mm to spare each side and " + f"{BOX_TOP_ROOM:g} mm over it", + "Tissues come out of": (f"{TISSUE_WORDS[p.tissue_hole]} {self.tissue[0]:.0f}" + + ("" if p.tissue_hole == TISSUE_ROUND else f" × {self.tissue[1]:.0f}") + + " mm, through its bottom layers") if self.tissue else "nowhere: no hole", + "Printed": "upside down, as it is shown - the hole on the bed, the open top for the box to go " + "in: nothing needs supports. Turn it over to put it on."} if p.box else {}), + "Holds": ("nothing: it goes only part way round" if p.around < 360.0 else + "nothing: it is open at the bottom" if p.open_bottom else + "nothing: it has holes" if self.holes_open else + "nothing: it is closed at the top" if p.closed_top else + f"about {self.capacity:.0f} ml of water"), + "Wall": (f"one {p.line_width:g} mm spiral line" if p.vase_mode != FAKE_VASE else + f"{max(int(p.walls), 1)} walls of {p.line_width:g} mm (about {self.wall:.1f} mm)") + + (", open at the bottom" if p.open_bottom else + f", on a {self.zb:g} mm solid bottom ({self.bottom_layers} layers)"), + **({"Holes": self._hole_words()} if self.pattern_open else {}), + **({"Face": self._face_words()} if self._face_words() else {}), + "Steepest overhang": lean, + "Filament": f"{self.grams:.0f} g, {self.metres:.1f} m", + "Print time": f"about {duration(self.seconds)}", + "Mesh": mesh, + } + if p.around < 360.0: # widths round the whole oval it is cut from would mislead + for key in ("Widest", "Base", "Opening at the top"): + d.pop(key, None) + if p.box: # a box isn't round, and holds tissues, not water + for key in ("Widest", "Base", "Opening at the top", "Holds"): + d.pop(key, None) + d["Wall"] = d["Wall"].replace("solid bottom", "solid top") + return d + + def _hole_words(self) -> str: + p = self.p + if p.holes == VORONOI: + text = (f"Voronoi ovals, {p.hole_size:g} mm apart with at least {p.hole_strut:g} mm between; round tops" + + (f", bridging at most {self.bridge:.1f} mm" if self.bridge > 0 else "")) + else: + text = (f"{HOLE_WORDS[p.holes]}, {p.hole_size:g} mm apart with {p.hole_strut:g} mm between; pointed tops" + + (f" ({self.bridge:.1f} mm bridges at their points)" if self.bridge > 0 else "")) + if p.hole_swell > 0: + text += f"; edges rounded, the wall swelling {p.hole_swell:g} mm each side round them" + return text + + def _face_words(self) -> str: + p = self.p + words = [{ALMOND_EYES: "almond eyes"}.get(p.eyes, "round eyes") if p.eyes != NO_EYES else "", + "a raised nose" if p.nose == RAISED_NOSE else "", + {OPEN_MOUTH: "an open mouth", GRILLE_MOUTH: f"a grille of {p.mouth_slots} slots for a mouth", + CHIN_MOUTH: "open down to the chin"}.get(p.mouth, ""), + "slots for a strap" if p.straps else ""] + words = [w for w in words if w] + if not words: + return "" + text = ", ".join(words) + if face_holes(p) and not self.pattern_open: + text += "; pointed tops" + (f" ({self.bridge:.1f} mm bridges at their points)" if self.bridge > 0 else "") + return text[:1].upper() + text[1:] + + def contour(self, zc: float): + """Where the wall crosses height zc: line segments (K, 2, 3), for the print animation.""" + if not hasattr(self, "_tri"): + if self.holes_open or self.floor_hole is not None: # the pot's wall: each piece between holes a loop + keep = np.isin(self.shell_kind, (hollow.OUTER, hollow.INNER, hollow.SIDE, hollow.TIP)) + tri = self.shell_verts[self.shell_faces[keep]] + else: + tri = self.verts[self.faces[:self.side]] + self._tri = tri + self._tz = (tri[:, :, 2].min(1), tri[:, :, 2].max(1)) + zc = float(zc) + 1e-7 + sel = self._tri[(self._tz[0] < zc) & (self._tz[1] > zc)] + if not len(sel): + return np.zeros((0, 2, 3)) + pts, crosses = [], [] + for a, b in ((0, 1), (1, 2), (2, 0)): + za, zb = sel[:, a, 2], sel[:, b, 2] + cross = (za - zc) * (zb - zc) < 0 + t = np.where(cross, (zc - za) / np.where(zb == za, 1.0, zb - za), 0.0) + pts.append(sel[:, a] + (sel[:, b] - sel[:, a]) * t[:, None]) + crosses.append(cross) + P, C = np.stack(pts, 1), np.stack(crosses, 1) + first_two = np.argsort(~C, axis=1, kind="stable")[:, :2] # the two edges each face crosses + seg = np.take_along_axis(P, first_two[:, :, None], axis=1) + return seg[C.sum(1) >= 2] + + +# ================================================================ a tissue box cover +def box_room(p: VaseParams) -> float: + """How much bigger a tissue box cover is inside than the box of tissues, each side (mm): BOX_ROOM, more where + rounded corners need it to clear a square-cornered box, and more for patterns and rounded hole edges that + reach in.""" + wall = p.line_width * max(int(p.walls), 1) + inward = ((max(-p.rib_depth, 0.0) if p.rib_count > 0 else 0.0) + (max(-p.ring_depth, 0.0) if p.ring_count > 0 + else 0.0) + (max(-p.bump_depth, 0.0) if p.bump_around > 0 else 0.0) + 1.2 * max(p.organic_depth, 0.0) + + (p.hole_swell if p.holes != NO_HOLES else 0.0)) + corner = max(p.box_corner - wall, 1.0) # inside + return max(BOX_ROOM, corner - (corner - BOX_ROOM) / math.sqrt(2.0)) + inward + + +def box_shape(p: VaseParams) -> VaseParams: + """A tissue box cover's own shape, from the box of tissues it goes over: a rectangle seen from above with + rounded corners, straight up. It is made upside down, as it prints - the top, with the hole for the tissues, + is the floor on the bed, and the rim is the open bottom the box goes in by - so nothing needs supports.""" + q = p.copy() + wall = q.line_width * max(int(q.walls), 1) + room = box_room(q) + a = 0.5 * max(q.box_length, q.box_width) + room + BOX_END_ROOM + wall # half its length and width, outside + b = 0.5 * min(q.box_length, q.box_width) + room + wall + c = min(max(q.box_corner, wall + 1.0), b) + reach = math.hypot(a - c, b - c) + c # to the furthest point, round a corner + q.shape1, q.depth1, q.sharp1, q.shape2 = RECTANGLE, min(1.0 - b / a, 0.9), c / b, SAME + q.turn1 = 90.0 if q.box_width > q.box_length else 0.0 + q.height = round(q.box_height + BOX_TOP_ROOM + max(q.bottom, q.layer_height), 3) + q.profile, q.silhouette = 1, [[0.0, round(2.0 * reach, 3)], [1.0, round(2.0 * reach, 3)]] + q.twist, q.twist_style, q.lattice, q.facets = 0.0, EVENLY, False, False + q.open_bottom, q.closed_top, q.around, q.worn = False, False, 360.0, False + q.eyes, q.nose, q.mouth, q.straps = NO_EYES, NO_NOSE, NO_MOUTH, False + q.vase_mode = FAKE_VASE + return q + + +def ledge_lean(p: VaseParams) -> float: + """How far a cover's ledges' steep sides lean from upright (degrees): LEDGE_LEAN, or less, as the wall may.""" + return max(min(LEDGE_LEAN, p.max_overhang - 10.0), 10.0) + + +def ledge_heights(p: VaseParams): + """Where a cover's ledges change, measured up from the rim with the cover on: the foot of the ramp, the top + of the ramp and the end of the flat, both LEDGE_DEPTH in, and the top of the steep side.""" + h0 = LEDGE_FROM + h1 = h0 + LEDGE_RAMP + h2 = h1 + LEDGE_FLAT + return h0, h1, h2, h2 + LEDGE_DEPTH / math.tan(math.radians(ledge_lean(p))) + + +def ledges(z, I, H: float, heights): + """The inside of a cover's wall I (K, N, 2) at heights z, with a ledge across each end - the straight part of + the end, on the long way - standing in from the wall near the rim at H, for its lid's tabs to click in + behind. As the cover prints, upside down, the ramp faces up and the steep side down, no steeper than a wall + may lean, so nothing needs supports.""" + h0, h1, h2, h3 = heights + h = H - np.asarray(z, dtype=float) + depth = LEDGE_DEPTH * np.interp(h, [h0, h1, h2, h3], [0.0, 1.0, 1.0, 0.0], left=0.0, right=0.0) + rim = I[-1] + X, Y = np.abs(rim).max(axis=0) + U = np.array([1.0, 0.0]) if X >= Y else np.array([0.0, 1.0]) + V = np.array([-U[1], U[0]]) + u, v = rim @ U, rim @ V + flat = v[np.abs(u) >= np.abs(u).max() - 0.05] + half = max(0.5 * float(flat.max() - flat.min()) - 1.0, 12.0) # the straight part of each end, or its middle + across = np.clip((half - np.abs(I @ V)) / 3.0, 0.0, 1.0) # each ledge tapers off at its ends ... + on_end = np.abs(I @ U) > 0.5 * np.abs(u).max() # ... and is only on the ends + off = depth[:, None] * across * on_end + return I - sections.normals(I) * off[..., None] + + +# ================================================================ sizing for a head +def scaled(p: VaseParams, k: float) -> VaseParams: + """The design k times the size: its height, outline and face (patterns and holes stay as they are).""" + q = p.copy() + q.silhouette = [[h, round(d * k, 2)] for h, d in p.silhouette] + for key in HEAD_SCALED: + setattr(q, key, round(getattr(p, key) * k, 2)) + return q + + +def fit_head(p: VaseParams, head: float) -> VaseParams: + """The design sized to fit a head ``head`` mm round (round the forehead, above the ears), with a little + room: the inside measured where the head goes in, the whole design scaled to fit.""" + q = p.copy() + for _ in range(4): + d = VaseDesign(q, draft=True) + if d.errors or d.head_fit <= 0: + return q + if abs(d.head_fit - head) < 0.5: + break + extra = HEAD_ROOM + TAU * d.wall # the inside is this much less round than the outside + q = scaled(q, (head + extra) / (d.head_fit + extra)) + return q diff --git a/vaseinator/params.py b/vaseinator/params.py index 6fcf32b..e46e816 100644 --- a/vaseinator/params.py +++ b/vaseinator/params.py @@ -1,346 +1,346 @@ -"""Design parameters for a spiral vase, with JSON persistence.""" - -from __future__ import annotations - -import json -import math -from dataclasses import asdict, dataclass, field, fields - -FILE_FORMAT = "vaseinator" -FILE_VERSION = 1 -FILAMENTS = (1, 2, 3, 4) - -# cross-section shapes; SAME (for the top shape) keeps the bottom one all the way up -SAME, CIRCLE, POLYGON, STAR, FLOWER, GEAR, ELLIPSE, PEBBLE, DRAWN, RECTANGLE = -1, 0, 1, 2, 3, 4, 5, 6, 7, 8 -SHAPES = (CIRCLE, POLYGON, RECTANGLE, STAR, FLOWER, GEAR, ELLIPSE, PEBBLE, DRAWN) # DRAWN: any outline, from points - -# how something changes on the way up (the twist, the change of shape, the texture strength) -EVENLY, EASE, SPEED_UP, SLOW_DOWN, SWING, OWN = 0, 1, 2, 3, 4, 5 -EVERYWHERE, GROWS, FADES, MIDDLE = 0, 1, 2, 3 # texture strength (OWN = own curve) -OWN_PROFILE = -1 # the outline has been drawn by hand - -ONE_COLOUR, BANDS, STRIPES, GRADIENT = 0, 1, 2, 3 # how the colours go up the vase -REAL_VASE, FAKE_VASE = 0, 1 # spiral vase mode, or an ordinary print with walls -NO_HOLES, VORONOI, ROUND, DIAMOND, SLOTS = 0, 1, 2, 3, 4 # holes right through the wall -NO_EYES, ALMOND_EYES, ROUND_EYES = 0, 1, 2 # a face: its eyes ... -NO_NOSE, RAISED_NOSE = 0, 1 # ... its nose ... -NO_MOUTH, OPEN_MOUTH, GRILLE_MOUTH, CHIN_MOUTH = 0, 1, 2, 3 # ... and its mouth (CHIN: open down to the chin) -NO_TISSUE, TISSUE_SLOT, TISSUE_OVAL, TISSUE_ROUND = 0, 1, 2, 3 # the hole in a tissue box cover's top -LID_GENTLE, LID_SNUG, LID_FIRM = 0, 1, 2 # how hard a cover's lid holds on -MATTE, SILK, GLOSSY, TRANSLUCENT = 0, 1, 2, 3 # how the filament looks in the 3D view - -MAX_REPEAT = 100 # most copies of a drawn piece round the axis -MAX_BANDS = 64 # colour bands the 3D view can show - - -def _pts(*pairs): - return field(default_factory=lambda: [list(p) for p in pairs]) - - -# one lobe, from neck to neck: repeated five times round the axis it makes a five-lobed flower -FIVE_LOBES = ((0.42, -0.12, True), (0.62, -0.38, False), (0.95, -0.22, False), (0.95, 0.22, False), - (0.62, 0.38, False), (0.42, 0.12, True)) - - -@dataclass -class VaseParams: - name: str = "lagoon_twist" - - # --- size --------------------------------------------------------------- - height: float = 180.0 - profile: int = 0 # outline preset (shapes.PROFILES), or OWN_PROFILE once drawn by hand - silhouette: list = _pts((0.0, 66.0), (0.36, 104.0), (0.8, 62.0), (1.0, 74.0)) # (height 0..1, - # diameter mm) points, joined by a smooth curve that never overshoots - - # --- cross-section ------------------------------------------------------ - shape1: int = FLOWER # the shape at the bottom - count1: int = 7 # sides, points, petals, teeth (or the pebble's number) - depth1: float = 0.16 # how far in the shape cuts, as a share of the radius - sharp1: float = 0.45 # sharpness / corner rounding / petal shape / tooth width (0..1) - turn1: float = 0.0 # turned round the axis (degrees) - drawn1: list = field(default_factory=lambda: [list(p) for p in FIVE_LOBES]) # a drawn outline's - # control points [x, y, cusp]; the axis is the origin - repeat1: int = 5 # ... repeated this many times round the axis - shape2: int = SAME # the shape it turns into at the top - count2: int = 5 - depth2: float = 0.0 - sharp2: float = 0.5 - turn2: float = 0.0 - drawn2: list = field(default_factory=lambda: [list(p) for p in FIVE_LOBES]) - repeat2: int = 5 - morph: int = EVENLY # how the change of shape happens on the way up - morph_swings: int = 3 # back and forth: how many changes (3: bottom, top, bottom, top shape) - morph_curve: list = _pts((0.0, 0.0), (1.0, 1.0)) - - # --- spin --------------------------------------------------------------- - twist: float = 150.0 # degrees the shape turns from bottom to top - twist_style: int = EASE - swings: int = 3 # half-waves of a back-and-forth twist - twist_curve: list = _pts((0.0, 0.0), (1.0, 150.0)) # (height, degrees) for an own twist - lattice: bool = False # twist both ways at once: a lattice of crossing ridges - - # --- surface ------------------------------------------------------------ - rib_count: int = 0 # ribs running up the vase (0 = none) - rib_depth: float = 1.2 # mm out (+) or grooves in (-) - rib_sharp: float = 0.3 # 0 smooth flutes ... 1 narrow ridges - rib_lean: float = 0.0 # the ribs spiral this far round on top of the twist (degrees) - rib_cross: bool = False # a second set spirals the other way: diamond knurling - ring_count: int = 0 # rings round the vase (0 = none) - ring_depth: float = 1.0 - ring_sharp: float = 0.3 - bump_around: int = 0 # bumps round the vase (0 = none) - bump_up: int = 8 # ... and up it - bump_depth: float = 1.5 - organic_depth: float = 0.0 # smooth random lumps, in and out (mm, 0 = none) - organic_scale: float = 22.0 # size of one lump (mm) - organic_seed: int = 7 - texture_twist: bool = True # the surface patterns turn with the twist - even_spacing: bool = False # ... spaced by distance round the wall, not by angle from the middle - strength: int = EVERYWHERE # how strong the patterns are on the way up - strength_curve: list = _pts((0.0, 1.0), (1.0, 1.0)) - fade_foot: float = 3.0 # patterns fade out over this much of the bottom (mm) - fade_rim: float = 4.0 # ... and of the top - - # --- low-poly ----------------------------------------------------------- - facets: bool = False # flat facets, like a cut gem - facets_around: int = 14 - facets_up: int = 10 - facet_rough: float = 0.0 # 0 regular ... 1 rough cut - facet_seed: int = 1 - - # --- your own function -------------------------------------------------- - expression: str = "" # mm added to the radius: f(theta, phi, z, h, r, H) - - # --- colours ------------------------------------------------------------ - color1: str = "#0E7C86" - color2: str = "#3FC1C9" - color3: str = "#F5F5F5" - color4: str = "#FC5185" - colour_style: int = BANDS - colours_used: int = 3 # filaments the bands, stripes or gradient go through - band_count: int = 3 - band_spacing: float = 0.0 # -1 bands thicker at the bottom ... 0 even ... 1 thicker at the top - stripe_height: float = 12.0 # mm - gradient_length: float = 8.0 # metres of filament for one colour cycle of a gradient spool - look: int = SILK - - # --- holes and an open bottom (a normal print) --------------------------- - holes: int = NO_HOLES # holes right through the wall: Voronoi, round, diamonds, slots - hole_size: float = 14.0 # from one hole to the next (mm) - hole_strut: float = 3.0 # the wall left between holes (mm) - hole_jitter: float = 0.7 # Voronoi: 0 a regular honeycomb ... 1 quite irregular - hole_seed: int = 5 - hole_foot: float = 18.0 # solid wall kept at the foot, above the floor (mm) - hole_rim: float = 12.0 # ... and at the rim - hole_swell: float = 0.0 # the wall swells this much each side round the holes, rounding their edges (mm) - open_bottom: bool = False # no floor: open at both ends (a lampshade, a sleeve) - closed_top: bool = False # the top closes over in a point no steeper than the overhang limit - around: float = 360.0 # how far round the wall goes (degrees): less for a mask - - # --- masks and helmets: a face at the front (-y), sizes and heights in mm -------------------- - worn: bool = False # made for a head: sized by the head it fits - eyes: int = NO_EYES - eye_width: float = 34.0 - eye_height: float = 18.0 - eye_apart: float = 64.0 # between the eyes' middles, along the surface - eye_up: float = 112.0 # the eyes' middles, from the bottom - eye_tilt: float = 10.0 # the outer corners raised (degrees) - nose: int = NO_NOSE - nose_width: float = 30.0 - nose_length: float = 46.0 # from its tip up to between the eyes - nose_depth: float = 14.0 # how far its tip stands out - nose_up: float = 70.0 # its tip, from the bottom - mouth: int = NO_MOUTH - mouth_width: float = 44.0 - mouth_height: float = 14.0 - mouth_up: float = 40.0 # its middle (the top of an opening down to the chin), from the bottom - mouth_slots: int = 5 # a grille's slots - mouth_smile: float = 0.0 # ... its outer slots this much higher (mm): a smile - straps: bool = False # a slot each side for an elastic strap - strap_up: float = 112.0 - - # --- a tissue box cover: sized by the box of tissues it goes over, printed upside down, on its top --- - box: bool = False # a box open at the bottom with a hole in its top, to go over a box of tissues - box_length: float = 220.0 # the box of tissues it goes over (mm) - box_width: float = 120.0 - box_height: float = 90.0 - box_corner: float = 10.0 # how round the cover's corners are seen from above: their radius outside (mm) - tissue_hole: int = TISSUE_SLOT # the hole in its top the tissues come out of - tissue_length: float = 130.0 # ... along the box - tissue_width: float = 36.0 # ... and across it (a round hole: its diameter) - lid_grip: int = LID_SNUG # the lid for its open end, printed on its own: how far its latches reach - lid_open: bool = True # ... a frame, open in the middle - or a solid plate - - # --- printing ----------------------------------------------------------- - vase_mode: int = FAKE_VASE # real: spiral vase mode, one solid, one colour (slicers won't change - # filament in it); fake: an ordinary print with walls, the colour - # bands as parts of their own filaments - walls: int = 2 # fake vase: walls round the vase - max_overhang: float = 45.0 # steepest lean of the wall from upright, either way (degrees) - soften: bool = True # soften anything steeper until it prints without supports - line_width: float = 0.6 # the slicer's spiral wall line width: the printed wall - layer_height: float = 0.2 - bottom: float = 1.2 # solid bottom the slicer prints first (mm) - printer_height: float = 250.0 - bed_x: float = 256.0 - bed_y: float = 256.0 - speed: float = 40.0 # spiral wall speed (mm/s) - max_flow: float = 12.0 # the hot end's flow limit (mm³/s) - filament_d: float = 1.75 - density: float = 1.24 # g/cm³ (PLA 1.24, PETG 1.27) - facet_size: float = 0.2 # mesh detail: spacing of the points on the surface (mm) - - # ---------------------------------------------------------------- utils - def copy(self) -> "VaseParams": - return VaseParams.from_dict(self.to_dict()) - - def to_dict(self) -> dict: - return asdict(self) - - def colors(self) -> dict: - """Filament number -> (r, g, b) in 0..1.""" - return {k: hex_to_rgb(getattr(self, f"color{k}")) for k in FILAMENTS} - - @classmethod - def from_dict(cls, d: dict) -> "VaseParams": - if not isinstance(d, dict): - raise ValueError("Design settings must be an object.") - p = cls() - fresh = cls() - for f in fields(cls): - if f.name not in d: - continue - cur, v = getattr(p, f.name), d[f.name] - if isinstance(v, float) and not math.isfinite(v): - raise ValueError(f"{f.name} must be a finite number.") - try: - if isinstance(cur, bool): - v = bool(v) - elif isinstance(cur, int): - v = int(v) - elif isinstance(cur, float): - v = float(v) - elif isinstance(cur, list): - v = clean_outline(v) if f.name in ("drawn1", "drawn2") else clean_curve(v) - if v is None: - continue - else: - v = str(v) - except (TypeError, ValueError, OverflowError): - continue - if isinstance(v, float) and not math.isfinite(v): - raise ValueError(f"{f.name} must be a finite number.") - setattr(p, f.name, v) - allowed = {"shape1": SHAPES, "shape2": (SAME,) + SHAPES, - "morph": (EVENLY, EASE, SPEED_UP, SLOW_DOWN, SWING, OWN), - "twist_style": (EVENLY, EASE, SPEED_UP, SLOW_DOWN, SWING, OWN), - "strength": (EVERYWHERE, GROWS, FADES, MIDDLE, OWN), - "colour_style": (ONE_COLOUR, BANDS, STRIPES, GRADIENT), "colours_used": (1, 2, 3, 4), - "look": (MATTE, SILK, GLOSSY, TRANSLUCENT), "vase_mode": (REAL_VASE, FAKE_VASE), - "holes": (NO_HOLES, VORONOI, ROUND, DIAMOND, SLOTS), - "eyes": (NO_EYES, ALMOND_EYES, ROUND_EYES), "nose": (NO_NOSE, RAISED_NOSE), - "mouth": (NO_MOUTH, OPEN_MOUTH, GRILLE_MOUTH, CHIN_MOUTH), - "tissue_hole": (NO_TISSUE, TISSUE_SLOT, TISSUE_OVAL, TISSUE_ROUND), - "lid_grip": (LID_GENTLE, LID_SNUG, LID_FIRM)} - for key, values in allowed.items(): - if getattr(p, key) not in values: - setattr(p, key, getattr(fresh, key)) - if any(d <= 0 for _, d in p.silhouette): - p.silhouette = fresh.silhouette - p.walls = min(max(p.walls, 1), 10) - p.repeat1, p.repeat2 = (min(max(r, 1), MAX_REPEAT) for r in (p.repeat1, p.repeat2)) - p.mouth_slots = min(max(p.mouth_slots, 1), 40) - for key, lo, hi in (("depth1", 0.0, 0.9), ("depth2", 0.0, 0.9), ("sharp1", 0.0, 1.0), - ("sharp2", 0.0, 1.0), ("rib_sharp", 0.0, 1.0), ("ring_sharp", 0.0, 1.0), - ("facet_rough", 0.0, 1.0), ("band_spacing", -1.0, 1.0), ("hole_jitter", 0.0, 1.0), - ("hole_size", 3.0, 200.0), ("hole_strut", 0.8, 50.0), ("hole_foot", 0.0, 2000.0), - ("hole_rim", 0.0, 2000.0), ("hole_swell", 0.0, 10.0), ("around", 30.0, 360.0), ("eye_width", 2.0, 400.0), - ("eye_height", 2.0, 400.0), ("eye_apart", 0.0, 2000.0), ("eye_tilt", -80.0, 80.0), - ("nose_width", 2.0, 400.0), ("nose_length", 2.0, 1000.0), ("nose_depth", 0.0, 100.0), - ("mouth_width", 2.0, 1000.0), ("mouth_height", 2.0, 400.0), - ("mouth_smile", -400.0, 400.0), ("box_length", 20.0, 1000.0), - ("box_width", 20.0, 1000.0), ("box_height", 10.0, 1000.0), ("box_corner", 0.0, 500.0), - ("tissue_length", 5.0, 1000.0), ("tissue_width", 5.0, 1000.0)): - setattr(p, key, min(max(getattr(p, key), lo), hi)) - for k in FILAMENTS: - try: - hex_to_rgb(getattr(p, f"color{k}")) - except ValueError: - setattr(p, f"color{k}", getattr(fresh, f"color{k}")) - return p - - -def face_holes(p: VaseParams) -> bool: - """Whether the face has holes of its own: eyes, a mouth or strap slots.""" - return p.eyes != NO_EYES or p.mouth != NO_MOUTH or p.straps - - -def needs_normal(p: VaseParams) -> bool: - """Whether the wall is cut open - holes, an open bottom, part way round, a tissue box cover - so it must be - printed normally: spiral vase mode prints one closed line a layer.""" - return p.holes != NO_HOLES or p.open_bottom or p.around < 360.0 or face_holes(p) or p.box - - -def colour_style_of(p: VaseParams) -> int: - """The colour style that really prints: a real spiral vase is one filament, so no bands or stripes.""" - if p.vase_mode == REAL_VASE and p.colour_style in (BANDS, STRIPES): - return ONE_COLOUR - return p.colour_style - - -def clean_outline(points): - """A drawn outline's control points made safe: [[x, y, cusp], ...], at least three; None if unusable.""" - try: - out = [[float(p[0]), float(p[1]), bool(p[2]) if len(p) > 2 else False] for p in points] - except (TypeError, ValueError, IndexError): - return None - out = [p for p in out if all(map(lambda c: abs(c) < 1e6, p[:2]))] - return out if len(out) >= 3 else None - - -def clean_curve(points): - """A curve's points made safe: [[h, v], ...] sorted, from h = 0 to h = 1, at least two. - - Returns None if there is nothing usable. - """ - try: - raw = [(float(h), float(v)) for h, v in points] - if not all(math.isfinite(h) and math.isfinite(v) for h, v in raw): - return None - pts = sorted((min(max(h, 0.0), 1.0), v) for h, v in raw) - except (TypeError, ValueError): - return None - out = [] - for h, v in pts: - if out and h - out[-1][0] < 0.005: # two points at one height: keep the later one - out[-1] = [out[-1][0], v] - else: - out.append([h, v]) - if len(out) < 2: - return None - out[0][0], out[-1][0] = 0.0, 1.0 - if out[-1][0] - out[-2][0] < 0.005 and len(out) > 2: - del out[-2] - return out - - -def hex_to_rgb(text: str): - """'#RRGGBB' -> (r, g, b) in 0..1.""" - t = text.strip().lstrip("#") - if len(t) != 6: - raise ValueError(f"not a colour: {text!r}") - return tuple(int(t[i:i + 2], 16) / 255.0 for i in (0, 2, 4)) - - -def save_design(path: str, params: VaseParams): - data = {"format": FILE_FORMAT, "version": FILE_VERSION, "params": params.to_dict()} - with open(path, "w", encoding="utf-8") as fh: - json.dump(data, fh, indent=2) - - -def load_design(path: str) -> VaseParams: - with open(path, "r", encoding="utf-8") as fh: - data = json.load(fh) - if data.get("format") != FILE_FORMAT: - raise ValueError("Not a Vaseinator design file") - return VaseParams.from_dict(data.get("params", {})) +"""Design parameters for a spiral vase, with JSON persistence.""" + +from __future__ import annotations + +import json +import math +from dataclasses import asdict, dataclass, field, fields + +FILE_FORMAT = "vaseinator" +FILE_VERSION = 1 +FILAMENTS = (1, 2, 3, 4) + +# cross-section shapes; SAME (for the top shape) keeps the bottom one all the way up +SAME, CIRCLE, POLYGON, STAR, FLOWER, GEAR, ELLIPSE, PEBBLE, DRAWN, RECTANGLE = -1, 0, 1, 2, 3, 4, 5, 6, 7, 8 +SHAPES = (CIRCLE, POLYGON, RECTANGLE, STAR, FLOWER, GEAR, ELLIPSE, PEBBLE, DRAWN) # DRAWN: any outline, from points + +# how something changes on the way up (the twist, the change of shape, the texture strength) +EVENLY, EASE, SPEED_UP, SLOW_DOWN, SWING, OWN = 0, 1, 2, 3, 4, 5 +EVERYWHERE, GROWS, FADES, MIDDLE = 0, 1, 2, 3 # texture strength (OWN = own curve) +OWN_PROFILE = -1 # the outline has been drawn by hand + +ONE_COLOUR, BANDS, STRIPES, GRADIENT = 0, 1, 2, 3 # how the colours go up the vase +REAL_VASE, FAKE_VASE = 0, 1 # spiral vase mode, or an ordinary print with walls +NO_HOLES, VORONOI, ROUND, DIAMOND, SLOTS = 0, 1, 2, 3, 4 # holes right through the wall +NO_EYES, ALMOND_EYES, ROUND_EYES = 0, 1, 2 # a face: its eyes ... +NO_NOSE, RAISED_NOSE = 0, 1 # ... its nose ... +NO_MOUTH, OPEN_MOUTH, GRILLE_MOUTH, CHIN_MOUTH = 0, 1, 2, 3 # ... and its mouth (CHIN: open down to the chin) +NO_TISSUE, TISSUE_SLOT, TISSUE_OVAL, TISSUE_ROUND = 0, 1, 2, 3 # the hole in a tissue box cover's top +LID_GENTLE, LID_SNUG, LID_FIRM = 0, 1, 2 # how hard a cover's lid holds on +MATTE, SILK, GLOSSY, TRANSLUCENT = 0, 1, 2, 3 # how the filament looks in the 3D view + +MAX_REPEAT = 100 # most copies of a drawn piece round the axis +MAX_BANDS = 64 # colour bands the 3D view can show + + +def _pts(*pairs): + return field(default_factory=lambda: [list(p) for p in pairs]) + + +# one lobe, from neck to neck: repeated five times round the axis it makes a five-lobed flower +FIVE_LOBES = ((0.42, -0.12, True), (0.62, -0.38, False), (0.95, -0.22, False), (0.95, 0.22, False), + (0.62, 0.38, False), (0.42, 0.12, True)) + + +@dataclass +class VaseParams: + name: str = "lagoon_twist" + + # --- size --------------------------------------------------------------- + height: float = 180.0 + profile: int = 0 # outline preset (shapes.PROFILES), or OWN_PROFILE once drawn by hand + silhouette: list = _pts((0.0, 66.0), (0.36, 104.0), (0.8, 62.0), (1.0, 74.0)) # (height 0..1, + # diameter mm) points, joined by a smooth curve that never overshoots + + # --- cross-section ------------------------------------------------------ + shape1: int = FLOWER # the shape at the bottom + count1: int = 7 # sides, points, petals, teeth (or the pebble's number) + depth1: float = 0.16 # how far in the shape cuts, as a share of the radius + sharp1: float = 0.45 # sharpness / corner rounding / petal shape / tooth width (0..1) + turn1: float = 0.0 # turned round the axis (degrees) + drawn1: list = field(default_factory=lambda: [list(p) for p in FIVE_LOBES]) # a drawn outline's + # control points [x, y, cusp]; the axis is the origin + repeat1: int = 5 # ... repeated this many times round the axis + shape2: int = SAME # the shape it turns into at the top + count2: int = 5 + depth2: float = 0.0 + sharp2: float = 0.5 + turn2: float = 0.0 + drawn2: list = field(default_factory=lambda: [list(p) for p in FIVE_LOBES]) + repeat2: int = 5 + morph: int = EVENLY # how the change of shape happens on the way up + morph_swings: int = 3 # back and forth: how many changes (3: bottom, top, bottom, top shape) + morph_curve: list = _pts((0.0, 0.0), (1.0, 1.0)) + + # --- spin --------------------------------------------------------------- + twist: float = 150.0 # degrees the shape turns from bottom to top + twist_style: int = EASE + swings: int = 3 # half-waves of a back-and-forth twist + twist_curve: list = _pts((0.0, 0.0), (1.0, 150.0)) # (height, degrees) for an own twist + lattice: bool = False # twist both ways at once: a lattice of crossing ridges + + # --- surface ------------------------------------------------------------ + rib_count: int = 0 # ribs running up the vase (0 = none) + rib_depth: float = 1.2 # mm out (+) or grooves in (-) + rib_sharp: float = 0.3 # 0 smooth flutes ... 1 narrow ridges + rib_lean: float = 0.0 # the ribs spiral this far round on top of the twist (degrees) + rib_cross: bool = False # a second set spirals the other way: diamond knurling + ring_count: int = 0 # rings round the vase (0 = none) + ring_depth: float = 1.0 + ring_sharp: float = 0.3 + bump_around: int = 0 # bumps round the vase (0 = none) + bump_up: int = 8 # ... and up it + bump_depth: float = 1.5 + organic_depth: float = 0.0 # smooth random lumps, in and out (mm, 0 = none) + organic_scale: float = 22.0 # size of one lump (mm) + organic_seed: int = 7 + texture_twist: bool = True # the surface patterns turn with the twist + even_spacing: bool = False # ... spaced by distance round the wall, not by angle from the middle + strength: int = EVERYWHERE # how strong the patterns are on the way up + strength_curve: list = _pts((0.0, 1.0), (1.0, 1.0)) + fade_foot: float = 3.0 # patterns fade out over this much of the bottom (mm) + fade_rim: float = 4.0 # ... and of the top + + # --- low-poly ----------------------------------------------------------- + facets: bool = False # flat facets, like a cut gem + facets_around: int = 14 + facets_up: int = 10 + facet_rough: float = 0.0 # 0 regular ... 1 rough cut + facet_seed: int = 1 + + # --- your own function -------------------------------------------------- + expression: str = "" # mm added to the radius: f(theta, phi, z, h, r, H) + + # --- colours ------------------------------------------------------------ + color1: str = "#0E7C86" + color2: str = "#3FC1C9" + color3: str = "#F5F5F5" + color4: str = "#FC5185" + colour_style: int = BANDS + colours_used: int = 3 # filaments the bands, stripes or gradient go through + band_count: int = 3 + band_spacing: float = 0.0 # -1 bands thicker at the bottom ... 0 even ... 1 thicker at the top + stripe_height: float = 12.0 # mm + gradient_length: float = 8.0 # metres of filament for one colour cycle of a gradient spool + look: int = SILK + + # --- holes and an open bottom (a normal print) --------------------------- + holes: int = NO_HOLES # holes right through the wall: Voronoi, round, diamonds, slots + hole_size: float = 14.0 # from one hole to the next (mm) + hole_strut: float = 3.0 # the wall left between holes (mm) + hole_jitter: float = 0.7 # Voronoi: 0 a regular honeycomb ... 1 quite irregular + hole_seed: int = 5 + hole_foot: float = 18.0 # solid wall kept at the foot, above the floor (mm) + hole_rim: float = 12.0 # ... and at the rim + hole_swell: float = 0.0 # the wall swells this much each side round the holes, rounding their edges (mm) + open_bottom: bool = False # no floor: open at both ends (a lampshade, a sleeve) + closed_top: bool = False # the top closes over in a point no steeper than the overhang limit + around: float = 360.0 # how far round the wall goes (degrees): less for a mask + + # --- masks and helmets: a face at the front (-y), sizes and heights in mm -------------------- + worn: bool = False # made for a head: sized by the head it fits + eyes: int = NO_EYES + eye_width: float = 34.0 + eye_height: float = 18.0 + eye_apart: float = 64.0 # between the eyes' middles, along the surface + eye_up: float = 112.0 # the eyes' middles, from the bottom + eye_tilt: float = 10.0 # the outer corners raised (degrees) + nose: int = NO_NOSE + nose_width: float = 30.0 + nose_length: float = 46.0 # from its tip up to between the eyes + nose_depth: float = 14.0 # how far its tip stands out + nose_up: float = 70.0 # its tip, from the bottom + mouth: int = NO_MOUTH + mouth_width: float = 44.0 + mouth_height: float = 14.0 + mouth_up: float = 40.0 # its middle (the top of an opening down to the chin), from the bottom + mouth_slots: int = 5 # a grille's slots + mouth_smile: float = 0.0 # ... its outer slots this much higher (mm): a smile + straps: bool = False # a slot each side for an elastic strap + strap_up: float = 112.0 + + # --- a tissue box cover: sized by the box of tissues it goes over, printed upside down, on its top --- + box: bool = False # a box open at the bottom with a hole in its top, to go over a box of tissues + box_length: float = 220.0 # the box of tissues it goes over (mm) + box_width: float = 120.0 + box_height: float = 90.0 + box_corner: float = 10.0 # how round the cover's corners are seen from above: their radius outside (mm) + tissue_hole: int = TISSUE_SLOT # the hole in its top the tissues come out of + tissue_length: float = 130.0 # ... along the box + tissue_width: float = 36.0 # ... and across it (a round hole: its diameter) + lid_grip: int = LID_SNUG # the lid for its open end, printed on its own: how far its latches reach + lid_open: bool = True # ... a frame, open in the middle - or a solid plate + + # --- printing ----------------------------------------------------------- + vase_mode: int = FAKE_VASE # real: spiral vase mode, one solid, one colour (slicers won't change + # filament in it); fake: an ordinary print with walls, the colour + # bands as parts of their own filaments + walls: int = 2 # fake vase: walls round the vase + max_overhang: float = 45.0 # steepest lean of the wall from upright, either way (degrees) + soften: bool = True # soften anything steeper until it prints without supports + line_width: float = 0.6 # the slicer's spiral wall line width: the printed wall + layer_height: float = 0.2 + bottom: float = 1.2 # solid bottom the slicer prints first (mm) + printer_height: float = 250.0 + bed_x: float = 256.0 + bed_y: float = 256.0 + speed: float = 40.0 # spiral wall speed (mm/s) + max_flow: float = 12.0 # the hot end's flow limit (mm³/s) + filament_d: float = 1.75 + density: float = 1.24 # g/cm³ (PLA 1.24, PETG 1.27) + facet_size: float = 0.2 # mesh detail: spacing of the points on the surface (mm) + + # ---------------------------------------------------------------- utils + def copy(self) -> "VaseParams": + return VaseParams.from_dict(self.to_dict()) + + def to_dict(self) -> dict: + return asdict(self) + + def colors(self) -> dict: + """Filament number -> (r, g, b) in 0..1.""" + return {k: hex_to_rgb(getattr(self, f"color{k}")) for k in FILAMENTS} + + @classmethod + def from_dict(cls, d: dict) -> "VaseParams": + if not isinstance(d, dict): + raise ValueError("Design settings must be an object.") + p = cls() + fresh = cls() + for f in fields(cls): + if f.name not in d: + continue + cur, v = getattr(p, f.name), d[f.name] + if isinstance(v, float) and not math.isfinite(v): + raise ValueError(f"{f.name} must be a finite number.") + try: + if isinstance(cur, bool): + v = bool(v) + elif isinstance(cur, int): + v = int(v) + elif isinstance(cur, float): + v = float(v) + elif isinstance(cur, list): + v = clean_outline(v) if f.name in ("drawn1", "drawn2") else clean_curve(v) + if v is None: + continue + else: + v = str(v) + except (TypeError, ValueError, OverflowError): + continue + if isinstance(v, float) and not math.isfinite(v): + raise ValueError(f"{f.name} must be a finite number.") + setattr(p, f.name, v) + allowed = {"shape1": SHAPES, "shape2": (SAME,) + SHAPES, + "morph": (EVENLY, EASE, SPEED_UP, SLOW_DOWN, SWING, OWN), + "twist_style": (EVENLY, EASE, SPEED_UP, SLOW_DOWN, SWING, OWN), + "strength": (EVERYWHERE, GROWS, FADES, MIDDLE, OWN), + "colour_style": (ONE_COLOUR, BANDS, STRIPES, GRADIENT), "colours_used": (1, 2, 3, 4), + "look": (MATTE, SILK, GLOSSY, TRANSLUCENT), "vase_mode": (REAL_VASE, FAKE_VASE), + "holes": (NO_HOLES, VORONOI, ROUND, DIAMOND, SLOTS), + "eyes": (NO_EYES, ALMOND_EYES, ROUND_EYES), "nose": (NO_NOSE, RAISED_NOSE), + "mouth": (NO_MOUTH, OPEN_MOUTH, GRILLE_MOUTH, CHIN_MOUTH), + "tissue_hole": (NO_TISSUE, TISSUE_SLOT, TISSUE_OVAL, TISSUE_ROUND), + "lid_grip": (LID_GENTLE, LID_SNUG, LID_FIRM)} + for key, values in allowed.items(): + if getattr(p, key) not in values: + setattr(p, key, getattr(fresh, key)) + if any(d <= 0 for _, d in p.silhouette): + p.silhouette = fresh.silhouette + p.walls = min(max(p.walls, 1), 10) + p.repeat1, p.repeat2 = (min(max(r, 1), MAX_REPEAT) for r in (p.repeat1, p.repeat2)) + p.mouth_slots = min(max(p.mouth_slots, 1), 40) + for key, lo, hi in (("depth1", 0.0, 0.9), ("depth2", 0.0, 0.9), ("sharp1", 0.0, 1.0), + ("sharp2", 0.0, 1.0), ("rib_sharp", 0.0, 1.0), ("ring_sharp", 0.0, 1.0), + ("facet_rough", 0.0, 1.0), ("band_spacing", -1.0, 1.0), ("hole_jitter", 0.0, 1.0), + ("hole_size", 3.0, 200.0), ("hole_strut", 0.8, 50.0), ("hole_foot", 0.0, 2000.0), + ("hole_rim", 0.0, 2000.0), ("hole_swell", 0.0, 10.0), ("around", 30.0, 360.0), ("eye_width", 2.0, 400.0), + ("eye_height", 2.0, 400.0), ("eye_apart", 0.0, 2000.0), ("eye_tilt", -80.0, 80.0), + ("nose_width", 2.0, 400.0), ("nose_length", 2.0, 1000.0), ("nose_depth", 0.0, 100.0), + ("mouth_width", 2.0, 1000.0), ("mouth_height", 2.0, 400.0), + ("mouth_smile", -400.0, 400.0), ("box_length", 20.0, 1000.0), + ("box_width", 20.0, 1000.0), ("box_height", 10.0, 1000.0), ("box_corner", 0.0, 500.0), + ("tissue_length", 5.0, 1000.0), ("tissue_width", 5.0, 1000.0)): + setattr(p, key, min(max(getattr(p, key), lo), hi)) + for k in FILAMENTS: + try: + hex_to_rgb(getattr(p, f"color{k}")) + except ValueError: + setattr(p, f"color{k}", getattr(fresh, f"color{k}")) + return p + + +def face_holes(p: VaseParams) -> bool: + """Whether the face has holes of its own: eyes, a mouth or strap slots.""" + return p.eyes != NO_EYES or p.mouth != NO_MOUTH or p.straps + + +def needs_normal(p: VaseParams) -> bool: + """Whether the wall is cut open - holes, an open bottom, part way round, a tissue box cover - so it must be + printed normally: spiral vase mode prints one closed line a layer.""" + return p.holes != NO_HOLES or p.open_bottom or p.around < 360.0 or face_holes(p) or p.box + + +def colour_style_of(p: VaseParams) -> int: + """The colour style that really prints: a real spiral vase is one filament, so no bands or stripes.""" + if p.vase_mode == REAL_VASE and p.colour_style in (BANDS, STRIPES): + return ONE_COLOUR + return p.colour_style + + +def clean_outline(points): + """A drawn outline's control points made safe: [[x, y, cusp], ...], at least three; None if unusable.""" + try: + out = [[float(p[0]), float(p[1]), bool(p[2]) if len(p) > 2 else False] for p in points] + except (TypeError, ValueError, IndexError): + return None + out = [p for p in out if all(map(lambda c: abs(c) < 1e6, p[:2]))] + return out if len(out) >= 3 else None + + +def clean_curve(points): + """A curve's points made safe: [[h, v], ...] sorted, from h = 0 to h = 1, at least two. + + Returns None if there is nothing usable. + """ + try: + raw = [(float(h), float(v)) for h, v in points] + if not all(math.isfinite(h) and math.isfinite(v) for h, v in raw): + return None + pts = sorted((min(max(h, 0.0), 1.0), v) for h, v in raw) + except (TypeError, ValueError): + return None + out = [] + for h, v in pts: + if out and h - out[-1][0] < 0.005: # two points at one height: keep the later one + out[-1] = [out[-1][0], v] + else: + out.append([h, v]) + if len(out) < 2: + return None + out[0][0], out[-1][0] = 0.0, 1.0 + if out[-1][0] - out[-2][0] < 0.005 and len(out) > 2: + del out[-2] + return out + + +def hex_to_rgb(text: str): + """'#RRGGBB' -> (r, g, b) in 0..1.""" + t = text.strip().lstrip("#") + if len(t) != 6: + raise ValueError(f"not a colour: {text!r}") + return tuple(int(t[i:i + 2], 16) / 255.0 for i in (0, 2, 4)) + + +def save_design(path: str, params: VaseParams): + data = {"format": FILE_FORMAT, "version": FILE_VERSION, "params": params.to_dict()} + with open(path, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2) + + +def load_design(path: str) -> VaseParams: + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + if data.get("format") != FILE_FORMAT: + raise ValueError("Not a Vaseinator design file") + return VaseParams.from_dict(data.get("params", {})) diff --git a/web/app.js b/web/app.js index 6b3eaf2..58f84b1 100644 --- a/web/app.js +++ b/web/app.js @@ -1,1186 +1,1186 @@ -// Vaseinator on the web: the desktop app, Simple and Advanced. The vase is built by the app's own Python (in -// worker.js, with Pyodide) - the settings, the rules for which are shown and what each change brings with it, -// and the report beside the view are the desktop app's own - and drawn here with three.js. - -import { CurveEditor } from "./curves.js"; -import { OutlineEditor } from "./outline.js"; -import { drawSide, drawTop } from "./plots.js"; -import { View } from "./view.js"; -import { libraryDialog } from "./library.js"; - -const $ = (id) => document.getElementById(id); - -// what this browser remembers: the design, the printer, the mode and the look - if it may -const store = { - get(key) { - try { return JSON.parse(localStorage.getItem("vaseinator." + key)); } catch { return null; } - }, - set(key, value) { - try { localStorage.setItem("vaseinator." + key, JSON.stringify(value)); } catch { /* private window */ } - }, -}; - -// ================================================================== Python, in two workers -// one builds the vase (which can take a few seconds); the other answers everything else straight away -class Python { - constructor() { - this.worker = new Worker("worker.js"); - this.waiting = new Map(); - this.next = 1; - this.worker.onmessage = (event) => { - const { id, ok, result, error } = event.data; - const w = this.waiting.get(id); - this.waiting.delete(id); - if (ok) w.resolve(result); - else w.reject(new Error(lastLine(error))); - }; - this.worker.onerror = (event) => { - for (const w of this.waiting.values()) w.reject(new Error(event.message || "Python stopped")); - this.waiting.clear(); - failed(event.message || "Python stopped"); - }; - } - - call(name, ...args) { - return new Promise((resolve, reject) => { - const id = this.next++; - this.waiting.set(id, { resolve, reject }); - this.worker.postMessage({ id, call: name, args }); - }); - } -} - -function lastLine(text) { - // a Python error comes with its whole traceback: its last line says what went wrong - const lines = String(text).trim().split("\n"); - return lines[lines.length - 1].replace(/^\w*Error: /, ""); -} - -const builder = new Python(); -const helper = new Python(); - -// ================================================================== the design -let C = null; // the settings, choices, constants and help, from Python -let K = null; // C.constants -let params = null; // the design's settings -let libraryState = store.get("library"); -let recoveryTimer = 0; -let meta = null; // what the last build says about it -let shown = new Set(); // the Advanced settings shown for it -const undoStack = []; -const redoStack = []; -let liveFrom = null; // the settings before a drag began, for Undo -let queue = Promise.resolve(); -let pendingText = null; -function flushText() { - if (pendingText) pendingText(); -} -function enqueue(work) { - const result = queue.then(work); - queue = result.catch((err) => showProblem(err.message)); - return queue; -} - -function pushUndo(snapshot) { - undoStack.push(snapshot); - if (undoStack.length > 100) undoStack.shift(); - redoStack.length = 0; - undoButtons(); -} - -function undoButtons() { - $("undo").disabled = !undoStack.length; - $("redo").disabled = !redoStack.length; -} - -function status(text) { - $("status").textContent = text || ""; -} - -function needsNormal(p) { - // holes, a face, an open bottom or a wall part way round: spiral vase mode prints one closed line a layer - return p.holes !== 0 || p.open_bottom || p.around < 360 || p.eyes !== 0 || p.mouth !== 0 || p.straps || p.box; -} - -function change(updates) { return enqueue(() => changeNow(updates)); } -function changeNow(updates) { - // several settings at once, as a picture in Simple mode sets them - if (!C) return; - pushUndo(JSON.stringify(params)); - Object.assign(params, typeof updates === "function" ? updates(params) : updates); - if (params.vase_mode === K.REAL_VASE && needsNormal(params)) params.vase_mode = K.FAKE_VASE; - settled({}); -} - -function commit(key, value, live = false) { return enqueue(() => commitNow(key, value, live)); } -async function commitNow(key, value, live = false) { - // one setting, as the desktop app's panel changes it: Python applies the app's own rules - if (!C) return Promise.resolve(); - if (live) { - if (liveFrom === null) liveFrom = JSON.stringify(params); - params[key] = value; - if (key === "silhouette") params.profile = K.OWN_PROFILE; - rebuild({ live: true }); - return Promise.resolve(); - } - { - const before = liveFrom !== null ? liveFrom : JSON.stringify(params); - liveFrom = null; - try { - const r = JSON.parse(await helper.call("apply", JSON.stringify(params), key, JSON.stringify(value))); - pushUndo(before); - params = r.params; - shown = new Set(r.shown); - status(r.message); - settled({ known: true }); - } catch (err) { - showProblem(err.message); - } - } -} - -function settled({ refit = false, known = false }) { - store.set("design", params); - rememberPrinter(); - clearTimeout(recoveryTimer); - const snapshot = JSON.stringify(params); - recoveryTimer = setTimeout(() => enqueue(() => libraryWork("recover", {}, snapshot)), 1500); - refreshSimple(); - refreshAdvanced(); - if (!known) { - const snapshot = JSON.stringify(params); - helper.call("shown", snapshot).then((keys) => { - if (JSON.stringify(params) !== snapshot) return; - shown = new Set(JSON.parse(keys)); - refreshAdvanced(); - }).catch(() => {}); - } - rebuild({ refit }); -} - -function replace(next, refit = true) { return enqueue(() => replaceNow(next, refit)); } -function replaceNow(next, refit = true) { - pushUndo(JSON.stringify(params)); - params = next; - settled({ refit }); -} - -function step(from, to) { return enqueue(() => stepNow(from, to)); } -function stepNow(from, to) { - if (!from.length) return; - to.push(JSON.stringify(params)); - params = JSON.parse(from.pop()); - undoButtons(); - settled({}); -} - -// ================================================================== building it -let seq = 0; // the newest design asked for -let wanted = null; // the next build to do -let busy = false; -let refit = true; -let refineTimer = 0; - -function rebuild({ refit: fit = false, live = false } = {}) { - seq += 1; - refit = refit || fit; - clearTimeout(refineTimer); - wanted = { seq, draft: true, live, text: JSON.stringify(params) }; - pump(); -} - -async function pump() { - if (busy || !wanted) return; - const job = wanted; - wanted = null; - busy = true; - showBusy(job.draft ? "Building…" : "Adding the fine detail…"); - let result = null; - try { - result = await builder.call("build", job.text, job.draft); - } catch (err) { - showProblem(err.message); - } - busy = false; - if (result && job.seq === seq) { - show(result); - if (job.draft && !job.live) { - // a quick draft first; then, if nothing changes for a moment, the vase in full detail - refineTimer = setTimeout(() => { - if (job.seq === seq) { - wanted = { seq: job.seq, draft: false, text: job.text }; - pump(); - } - }, 600); - } - } - if (wanted) pump(); - else showBusy(null); -} - -function showBusy(text) { - $("busy").hidden = !text; - if (text) $("busytext").textContent = text; -} - -// ================================================================== the view, the pictures, the report -const view = new View($("canvas"), $("viewer"), (text) => { $("hint").textContent = text; }); -window.vaseinator = { view, get params() { return params; }, get meta() { return meta; } }; // for checking it - -function show(result) { - const m = JSON.parse(result.meta); - if (m.errors && m.errors.length) { - $("report").innerHTML = m.report; - flagCrossing(m.errors); - return; - } - flagCrossing([]); - meta = m; - view.show(result, m, refit); - refit = false; - setNumber($("head"), m.head); - setNumber(widgetInput("head"), m.head); - lightTile($("pattern"), m.pattern); - $("report").innerHTML = m.report; - drawPlots(); -} - -function drawPlots() { - if (!meta) return; - drawSide($("sideplot"), meta.plots); - drawTop($("topplot"), meta.plots); -} -new ResizeObserver(drawPlots).observe($("plots")); - -function showProblem(text) { - const p = document.createElement("p"); - p.className = "e"; - p.textContent = `✗ ${text}`; - $("report").prepend(p); -} - -function flagCrossing(errors) { - // a drawn outline that crosses itself: the build says so; the editors show it red themselves - const crossing = errors.some((e) => e.includes("crosses itself")); - document.body.classList.toggle("crossing", crossing); -} - -// ================================================================== Simple: the pictures -const ACCENT = "#F0A356"; -const EDGE = "#966636"; -const DEEP = "#82582E"; -const svg = (body, size = 52) => ``; - -function aboveIcon(outline) { - const pts = outline.map(([x, y]) => `${(26 + 22.9 * x).toFixed(1)},${(26 - 22.9 * y).toFixed(1)}`).join(" "); - return svg(``); -} - -function sideIcon(widths) { - const n = widths.length; - const left = []; - const right = []; - widths.forEach((w, i) => { - const y = 48 - (44 * i) / (n - 1); - left.push(`${(26 - 18.7 * w).toFixed(1)},${y.toFixed(1)}`); - right.unshift(`${(26 + 18.7 * w).toFixed(1)},${y.toFixed(1)}`); - }); - return svg(``); -} - -function patternIcon(name) { - // a little vase body with the pattern drawn on it, as the desktop app draws them - const [l, t, r, b] = [10, 4, 42, 48]; - const lines = []; - const line = (x1, y1, x2, y2) => lines.push(``); - const dots = []; - const holes = []; - const range = (a, z, n) => Array.from({ length: n }, (_, i) => a + ((z - a) * i) / (n - 1)); - if (name === "Ribs") range(l + 4, r - 4, 6).forEach((x) => line(x, t, x, b)); - if (name === "Spiral ribs" || name === "Diamonds") { - range(l - 30, r + 4, 9).forEach((x) => { - line(x, b, x + 26, t); - if (name === "Diamonds") line(x, t, x + 26, b); - }); - } - if (name === "Rings") range(t + 5, b - 5, 6).forEach((y) => line(l, y, r, y)); - if (name === "Bumps") { - range(t + 6, b - 6, 6).forEach((y, row) => - range(l + 5 + (row % 2) * 3, r - 5, 4).forEach((x) => dots.push(``))); - } - if (name === "Lumpy") { - [[18, 12, 5], [31, 18, 6], [21, 30, 7], [34, 37, 5], [17, 44, 4]].forEach(([x, y, rr]) => - dots.push(``)); - } - if (name === "Gem") { - range(t, b, 5).forEach((y) => line(l, y, r, y)); - range(t, b, 5).slice(0, -1).forEach((y, k) => - range(l - 8 + 4 * (k % 2), r, 4).forEach((x) => line(x, y, x + 8, y + (b - t) / 4))); - } - if (name === "Round holes") { - range(t + 7, b - 7, 4).forEach((y, row) => - range(l + 6 + (row % 2) * 4, r - 6, 3).forEach((x) => holes.push(``))); - } - if (name === "Voronoi holes") { - [[[14, 8], [22, 6], [25, 13], [19, 18], [13, 15]], [[28, 9], [35, 7], [38, 15], [31, 19]], - [[13, 22], [21, 22], [23, 30], [15, 33]], [[27, 24], [36, 22], [38, 31], [29, 33]], - [[15, 38], [24, 37], [25, 45], [14, 46]], [[29, 38], [37, 37], [36, 46], [28, 45]]].forEach((cell) => - holes.push(``)); - } - const id = "p" + name.replace(/\W/g, ""); - return svg( - `` + - `${holes.join("")}` + - `` + - `${lines.join("")}${dots.join("")}`); -} - -function tissueIcon(kind) { - // a tissue box cover from above, with its hole for the tissues cut out, as the desktop app draws them - const hole = kind === C.constants.TISSUE_ROUND ? `` - : kind === C.constants.TISSUE_OVAL ? `` - : ``; - return svg(`${hole}` + - ``); -} - -function swatch(colours) { - const n = colours.length; - const id = "s" + colours.join("").replace(/#/g, ""); - const bands = colours.map((c, k) => - ``).join(""); - return ``; -} - -function tiles(box, items, onPick, cls = "tile") { - box.replaceChildren(); - items.forEach(({ label, icon, tip }, k) => { - const b = document.createElement("button"); - b.className = cls; - b.title = tip || label; - b.setAttribute("aria-pressed", "false"); - b.innerHTML = cls === "tile" ? `${icon}` : `${icon}`; - if (cls === "tile") b.querySelector("span").textContent = label; - else b.setAttribute("aria-label", label); - b.addEventListener("click", () => { - lightTile(box, k); - onPick(k); - }); - box.append(b); - }); -} - -function lightTile(box, k) { - [...box.children].forEach((b, i) => b.setAttribute("aria-pressed", String(i === k))); -} - -function widest() { - return Math.max(...params.silhouette.map(([, d]) => d)); -} - -function setNumber(input, value) { - if (input && document.activeElement !== input && value !== undefined && value !== null) input.value = value; -} - -function twistWords(deg) { - const words = { 0: "No twist", 90: "A quarter turn", 180: "Half a turn", 270: "Three quarters of a turn", - 360: "A whole turn", 720: "Two whole turns" }; - for (const [k, w] of Object.entries(words)) if (Math.abs(Math.abs(deg) - Number(k)) < 0.5) return w; - return `${Math.round(deg)}°`; -} - -function titleOf(p) { - const match = C.gallery.find((g) => g.slug === p.name); - if (match) return match.title; - if (p.name === "plain_cylinder") return "Plain cylinder"; - const name = p.name.replace(/_/g, " ").trim(); - return name ? name[0].toUpperCase() + name.slice(1) : "Your vase"; -} - -function refreshSimple() { - const p = params; - $("name").textContent = titleOf(p); - document.title = `${titleOf(p)} – Vaseinator`; - setNumber($("tall"), Math.round(p.height) / 10); - setNumber($("wide"), Math.round(widest()) / 10); - // a mask or helmet is sized by the head; a tissue box cover by the box of tissues, and is a box from every side - $("size").hidden = p.worn || p.box; - $("headrow").hidden = !p.worn || p.box; - $("sidetitle").hidden = p.worn || p.box; - $("side").hidden = p.worn || p.box; - for (const id of ["abovetitle", "above", "drawown", "twisttitle", "twistrow"]) $(id).hidden = p.box; - $("boxrow").hidden = !p.box; - for (const b of document.querySelectorAll(".lidonly")) b.hidden = !p.box; - for (const input of document.querySelectorAll("#boxrow input")) setNumber(input, Math.round(p[input.dataset.key]) / 10); - lightTile($("tissue"), C.tissue_holes.findIndex((t) => t.value === p.tissue_hole)); - lightTile($("above"), C.shapes.findIndex(({ settings: s }) => - p.shape2 === K.SAME && p.shape1 === s.shape1 && (p.shape1 !== K.POLYGON || p.count1 === s.count1))); - lightTile($("side"), p.profile >= 0 && p.profile < C.profiles.length ? p.profile : -1); - const twist = $("twist"); - if (document.activeElement !== twist) twist.value = Math.round(Math.min(Math.max(p.twist, 0), 360) / 15); - $("twistwords").textContent = p.twist_style === K.OWN ? "Your own curve" : twistWords(p.twist); - if (p.colour_style === K.ONE_COLOUR) { - lightTile($("colour"), C.colours.findIndex((c) => c.colour.toLowerCase() === p.color1.toLowerCase())); - } else { - const k = C.many_colours.findIndex((m) => m.settings.colour_style === p.colour_style); - lightTile($("colour"), k < 0 ? -1 : C.colours.length + k); - } - for (const input of document.querySelectorAll("#printer input")) setNumber(input, p[input.dataset.key]); -} - -function buildSimple() { - tiles($("above"), C.shapes.map((s) => ({ label: s.name, icon: aboveIcon(s.outline) })), - (k) => change({ shape2: K.SAME, turn1: 0, ...C.shapes[k].settings })); - tiles($("side"), C.profiles.map((s) => ({ label: s.name, icon: sideIcon(s.widths), tip: s.full })), - (k) => change(() => { - const w = widest(); - return { profile: k, silhouette: C.profiles[k].points.map(([h, f]) => [h, Math.round(f * w * 100) / 100]) }; - })); - tiles($("pattern"), C.patterns.map((s) => ({ label: s.name, icon: patternIcon(s.name) })), - (k) => change({ ...C.no_pattern, ...C.patterns[k].settings })); - const colourItems = C.colours.map((c) => ({ label: c.name, icon: swatch([c.colour]) })) - .concat(C.many_colours.map((m) => ({ label: `${m.name} of colour (${m.palette})`, icon: swatch(m.colours.slice(0, 3)) }))); - tiles($("colour"), colourItems, (k) => { - if (k < C.colours.length) { - change({ colour_style: K.ONE_COLOUR, color1: C.colours[k].colour }); - } else { - const m = C.many_colours[k - C.colours.length]; - change({ ...m.settings, ...Object.fromEntries(m.colours.map((c, i) => [`color${i + 1}`, c])) }); - } - }, "swatch"); - $("tall").addEventListener("change", (e) => { - const cm = parseFloat(e.target.value); - if (cm > 0) change({ height: Math.round(cm * 100) / 10 }); - }); - $("wide").addEventListener("change", (e) => { - const cm = parseFloat(e.target.value); - if (cm > 0) commit("widest", cm * 10); - }); - $("head").addEventListener("change", (e) => { - const cm = parseFloat(e.target.value); - if (cm > 0) fitHead(cm); - }); - tiles($("tissue"), C.tissue_holes.map((t) => ({ label: t.name, icon: tissueIcon(t.value) })), - (k) => commit("tissue_hole", C.tissue_holes[k].value)); - for (const input of document.querySelectorAll("#boxrow input")) { - input.addEventListener("change", () => { - const cm = parseFloat(input.value); - if (cm > 0) commit(input.dataset.key, Math.round(cm * 100) / 10); - }); - } - $("twist").addEventListener("input", (e) => { - $("twistwords").textContent = twistWords(15 * e.target.value); - }); - $("twist").addEventListener("change", (e) => { - const twist = 15 * Number(e.target.value); - change((p) => ({twist, ...(p.twist_style === K.OWN ? {twist_style: K.EVENLY} : {})})); - }); - for (const input of document.querySelectorAll("#printer input")) { - input.addEventListener("change", () => { - const v = parseFloat(input.value); - if (!(v > 0)) return; - change({ [input.dataset.key]: input.dataset.key === "walls" ? Math.round(v) : v }); - // settled() persists the final queued settings. - }); - } - $("drawown").addEventListener("click", async () => { - if (params.shape1 !== K.DRAWN) await commit("shape1", K.DRAWN); - openOutline(1); - }); -} - -function rememberPrinter() { - store.set("printer", Object.fromEntries(C.printer_keys.map((k) => [k, params[k]]))); -} - -async function fitHead(cm) { return enqueue(async () => { - await withBusy("Fitting it to the head…", async () => - replaceNow(JSON.parse(await helper.call("fit", JSON.stringify(params), cm * 10)), false)); - status(`Sized for a head ${cm} cm round — Undo to go back`); -}); } - -// ================================================================== Advanced: every setting -const widgets = new Map(); // key -> {rows: [elements], set(p), input} -const editors = {}; // the outline editors in the panel, by k - -function widgetInput(key) { - const w = widgets.get(key); - return w ? w.input : null; -} - -function colourStyleOf(p) { - // a seamless vase prints one filament: no bands or stripes - return p.vase_mode === K.REAL_VASE && (p.colour_style === 1 || p.colour_style === 2) ? K.ONE_COLOUR : p.colour_style; -} - -function numberBox(row, { decimals = 2 } = {}) { - const input = document.createElement("input"); - input.type = "number"; - input.min = row.lo; - input.max = row.hi; - input.step = row.step; - input.dataset.decimals = decimals; - return input; -} - -function makeRow(row, rowsBox) { - const { key, label, kind } = row; - const [unit, none] = String(row.extra || "").split("|"); - const lab = document.createElement("label"); - lab.className = "label"; - lab.textContent = label; - lab.title = row.tip || ""; - const control = document.createElement("div"); - control.className = "control"; - control.title = row.tip || ""; - let set = () => {}; - let input = null; - const whole = ["curve", "expr", "outline"].includes(kind); - if (kind === "float" || kind === "int" || kind === "seed" || kind === "pct" || kind === "widest" || kind === "head") { - const scale = kind === "pct" ? 100 : 1; // a percent is shown ×100; its limits are in percent already - input = numberBox(row); - lab.htmlFor = input.id = `set-${key}`; - control.append(input); - if (unit) { - const u = document.createElement("span"); - u.className = "unit"; - u.textContent = unit.trim(); - control.append(u); - } - let noneLabel = null; - if (none) { - noneLabel = document.createElement("span"); - noneLabel.className = "none"; - noneLabel.textContent = `(${none.toLowerCase()})`; - control.append(noneLabel); - } - if (kind === "seed") { - const dice = document.createElement("button"); - dice.className = "dice"; - dice.textContent = "🎲"; - dice.title = "Another pattern"; - dice.addEventListener("click", () => commit(key, Math.floor(Math.random() * 99999))); - control.append(dice); - } - input.addEventListener("change", () => { - let v = parseFloat(input.value); - if (Number.isNaN(v)) return; - v = Math.min(Math.max(v, row.lo), row.hi) / scale; - if (kind === "int" || kind === "seed") v = Math.round(v); - if (kind === "head") return fitHead(v); - commit(key, v); - }); - set = (p) => { - let v; - if (kind === "widest") v = Math.round(widest() * 10) / 10; - else if (kind === "head") v = meta ? meta.head : 57; - else v = p[key] * scale; - setNumber(input, kind === "pct" ? Math.round(v * 10) / 10 : Math.round(v * 1000) / 1000); - if (noneLabel) noneLabel.hidden = !(p[key] === row.lo); - if (/^count[12]$/.test(key)) input.min = p[`shape${key.slice(-1)}`] === K.POLYGON ? 3 : 1; - }; - } else if (kind === "bool") { - input = document.createElement("input"); - input.type = "checkbox"; - lab.htmlFor = input.id = `set-${key}`; - control.append(input); - input.addEventListener("change", () => commit(key, input.checked)); - set = (p) => { input.checked = Boolean(p[key]); }; - } else if (kind === "choice") { - input = document.createElement("select"); - lab.htmlFor = input.id = `set-${key}`; - const fill = (choices) => { - const now = JSON.stringify(choices); - if (input.dataset.choices === now) return; - input.dataset.choices = now; - input.replaceChildren(...choices.map(([v, t]) => new Option(t, v))); - }; - fill(row.choices); - control.append(input); - input.addEventListener("change", () => commit(key, Number(input.value))); - set = (p) => { - if (key === "colour_style") { - fill(p.vase_mode === K.REAL_VASE ? C.real_colour_styles : C.colour_styles); - input.value = colourStyleOf(p); - } else { - input.value = p[key]; - } - }; - } else if (kind === "color") { - input = document.createElement("input"); - input.type = "color"; - lab.htmlFor = input.id = `set-${key}`; - const hex = document.createElement("span"); - hex.className = "unit"; - control.append(input, hex); - input.addEventListener("change", () => commit(key, input.value.toUpperCase())); - set = (p) => { - input.value = p[key].toLowerCase(); - hex.textContent = p[key].toUpperCase(); - }; - } else if (kind === "slider") { - input = document.createElement("input"); - input.type = "range"; - input.min = row.lo; - input.max = row.hi; - input.step = row.step; - lab.htmlFor = input.id = `set-${key}`; - const note = document.createElement("span"); - note.className = "unit"; - control.append(input, note); - const words = (v) => (Math.abs(v) < 0.05 ? "Even" : v > 0 ? "Thicker towards the top" : "Thicker towards the bottom"); - input.addEventListener("input", () => { - note.textContent = words(Number(input.value)); - commit(key, Number(input.value), true); - }); - input.addEventListener("change", () => commit(key, Number(input.value))); - set = (p) => { - if (document.activeElement !== input) input.value = p[key]; - note.textContent = words(p[key]); - }; - } else if (kind === "palette") { - input = document.createElement("select"); - lab.htmlFor = input.id = `set-${key}`; - input.append(new Option("Choose a palette…", ""), ...C.palettes.map((pal) => new Option(pal.name, pal.name))); - control.append(input); - input.addEventListener("change", () => { - const pal = C.palettes.find((x) => x.name === input.value); - input.value = ""; - if (pal) change(Object.fromEntries(pal.colours.map((c, i) => [`color${i + 1}`, c]))); - }); - } else if (kind === "curve") { - control.className = "whole"; - const canvas = document.createElement("canvas"); - control.append(canvas); - const o = row.curve; - const editor = new CurveEditor(canvas, { lo: o.lo, hi: o.hi, unit: o.unit || "", mirror: Boolean(o.mirror), - height: o.height || 110, floor: o.floor ?? null, grow: Boolean(o.grow), decimals: o.decimals || 0 }, - (pts, live) => commit(key, pts, live)); - set = (p) => editor.setPoints(p[key]); - } else if (kind === "expr") { - control.className = "whole"; - const text = document.createElement("textarea"); - text.spellcheck = false; - text.placeholder = "1.5 * sin(6*theta + 4*pi*h)"; - text.setAttribute("aria-label", "Your own function"); - input = text; - const tools = document.createElement("div"); - tools.className = "row"; - const examples = document.createElement("select"); - examples.append(new Option("Examples…", ""), ...C.examples.map(([n, f]) => new Option(`${n}: ${f}`, f))); - const help = document.createElement("button"); - help.textContent = "Help…"; - const clear = document.createElement("button"); - clear.textContent = "Clear"; - tools.append(examples, help, clear); - control.append(text, tools); - let timer = 0; - text.addEventListener("input", () => { - clearTimeout(timer); - const value = text.value; - pendingText = () => { - clearTimeout(timer); - pendingText = null; - commit(key, value); - }; - timer = setTimeout(flushText, 600); - }); - examples.addEventListener("change", () => { - if (examples.value) setFormula(examples.value); - examples.value = ""; - }); - help.addEventListener("click", () => openHelp("function")); - clear.addEventListener("click", () => setFormula("")); - set = (p) => { if (document.activeElement !== text && text.value !== p[key]) text.value = p[key]; }; - } else if (kind === "outline") { - control.className = "whole outlinebox"; - const k = Number(key.slice(-1)); - const canvas = document.createElement("canvas"); - const tools = document.createElement("div"); - tools.className = "row"; - const start = startFromMenu(k); - const close = document.createElement("button"); - close.textContent = "Close up"; - close.title = "Show one repeat close up, turned to point upwards - or the whole outline. Above 8 repeats it starts close up."; - const big = document.createElement("button"); - big.textContent = "Full window…"; - big.title = "Draw it in a window as big as the screen: the wheel zooms, a right-drag moves it."; - tools.append(start, close, big); - control.append(canvas, tools); - const editor = new OutlineEditor(canvas, {}, (pts, live) => commit(key, pts, live), - () => close.setAttribute("aria-pressed", String(editor.closeUp()))); - editors[k] = editor; - close.addEventListener("click", () => editor.setCloseUp(!editor.closeUp())); - big.addEventListener("click", () => openOutline(k)); - set = (p) => editor.setOutline(p[key], p[`repeat${k}`]); - } - if (whole) { - if (label) { - lab.className = "label wholelabel"; - rowsBox.append(lab); - } - rowsBox.append(control); - widgets.set(key, { rows: label ? [lab, control] : [control], set, input, lab }); - } else { - rowsBox.append(lab, control); - widgets.set(key, { rows: [lab, control], set, input, lab }); - } -} - -function startFromMenu(k) { - const start = document.createElement("select"); - start.setAttribute("aria-label", "Start from"); - start.title = "Start again from a ready-made outline, or trace one from a picture: dark lines drawn round a shape on a light or see-through ground."; - start.append(new Option("Start from…", ""), ...C.outlines.map((o, i) => new Option(o.name, String(i))), - new Option("Trace a picture…", "trace")); - start.addEventListener("change", () => { - const v = start.value; - start.value = ""; - if (v === "trace") tracePicture(k); - else if (v !== "") useOutline(k, C.outlines[Number(v)].points, C.outlines[Number(v)].repeat); - }); - return start; -} - -function useOutline(k, points, repeat) { return enqueue(() => useOutlineNow(k, points, repeat)); } -function useOutlineNow(k, points, repeat) { - // start drawn outline k again from given control points - liveFrom = JSON.stringify(params); - params[`repeat${k}`] = repeat; - return commitNow(`drawn${k}`, points); -} - -let traceFor = 1; -function tracePicture(k) { - traceFor = k; - $("picturefile").click(); -} - -async function traceFile(file) { - const target = traceFor; - return enqueue(() => withBusy("Tracing the picture…", async () => { - const bitmap = await createImageBitmap(file); - const scale = Math.min(1, 1080 / Math.max(bitmap.width, bitmap.height)); - const w = Math.max(Math.round(bitmap.width * scale), 1); - const h = Math.max(Math.round(bitmap.height * scale), 1); - const canvas = new OffscreenCanvas(w, h); - const g = canvas.getContext("2d"); - g.drawImage(bitmap, 0, 0, w, h); - const pixels = g.getImageData(0, 0, w, h).data; - let text = ""; - for (let i = 0; i < pixels.length; i += 0x8000) text += String.fromCharCode(...pixels.subarray(i, i + 0x8000)); - const pts = JSON.parse(await helper.call("trace", btoa(text), w, h)); - bitmap.close(); - await useOutlineNow(target, pts, 1); - status(`Traced ${file.name}: ${pts.length} points, ${pts.filter((q) => q[2]).length} sharp corners.`); - }).catch(() => {})); -} - -function setFormula(text) { - flushText(); - const w = widgets.get("expression"); - if (w && w.input) w.input.value = text; - commit("expression", text); -} - -function buildAdvanced() { - const box = $("groups"); - box.replaceChildren(); - for (const group of C.spec) { - const section = document.createElement("details"); - section.className = "group"; - section.open = !store.get(`closed.${group.title}`); - section.addEventListener("toggle", () => store.set(`closed.${group.title}`, !section.open)); - const summary = document.createElement("summary"); - summary.textContent = group.title; - const rows = document.createElement("div"); - rows.className = "rows"; - section.append(summary, rows); - box.append(section); - group.rows.forEach((row) => makeRow(row, rows)); - group.element = section; - } - $("designname").addEventListener("change", (e) => commit("name", e.target.value.trim() || "vase")); -} - -function refreshAdvanced() { - if (!C || !params) return; - const p = params; - setNumber($("designname"), p.name); - for (const [key, w] of widgets) { - const show = shown.size === 0 || shown.has(key); - w.rows.forEach((el) => { el.hidden = !show; }); - if (show) w.set(p); - } - for (const group of C.spec) group.element.hidden = !group.rows.some((r) => shown.size === 0 || shown.has(r.key)); - // the settings named for the shape: sides, points, petals, teeth - for (const k of [1, 2]) { - const names = C.shape_labels[String(p[`shape${k}`])] || [null, null, null]; - ["count", "depth", "sharp"].forEach((what, i) => { - const w = widgets.get(`${what}${k}`); - if (w && names[i]) w.lab.textContent = names[i]; - }); - } - const c1 = widgets.get("color1"); - if (c1) c1.lab.textContent = colourStyleOf(p) === K.ONE_COLOUR ? "Colour" : "Filament 1"; - if (outlineWindow.k) { - const k = outlineWindow.k; - outlineWindow.editor.setOutline(p[`drawn${k}`], p[`repeat${k}`]); - setNumber($("winrepeat"), p[`repeat${k}`]); - } -} - -function setMode(advanced) { - $("simple").hidden = advanced; - $("advanced").hidden = !advanced; - $("simplebtn").setAttribute("aria-selected", String(!advanced)); - $("advancedbtn").setAttribute("aria-selected", String(advanced)); - store.set("advanced", advanced); - if (advanced) refreshAdvanced(); -} - -// ================================================================== drawing in a window of its own -const outlineWindow = { k: 0, editor: null }; - -function openOutline(k) { - const win = $("outlinewin"); - outlineWindow.k = k; - $("outlinetitle").textContent = k === 1 ? "Drawing the cross-section" : "Drawing what it turns into"; - $("winrepeat").value = params[`repeat${k}`]; - win.showModal(); - outlineWindow.editor.view = null; - outlineWindow.editor.setOutline(params[`drawn${k}`], params[`repeat${k}`]); - $("bigoutline").focus(); -} - -function buildOutlineWindow() { - const close = $("winclose"); - outlineWindow.editor = new OutlineEditor($("bigoutline"), { big: true }, - (pts, live) => commit(`drawn${outlineWindow.k}`, pts, live), - () => close.setAttribute("aria-pressed", String(outlineWindow.editor.closeUp()))); - close.addEventListener("click", () => outlineWindow.editor.setCloseUp(!outlineWindow.editor.closeUp())); - $("winfit").addEventListener("click", () => outlineWindow.editor.fit()); - $("windone").addEventListener("click", () => $("outlinewin").close()); - $("outlinewin").addEventListener("close", () => { outlineWindow.k = 0; }); - $("winrepeat").addEventListener("change", (e) => { - const n = Math.min(Math.max(Math.round(Number(e.target.value)), 1), 100); - if (n) commit(`repeat${outlineWindow.k}`, n); - }); - const start = $("winstart"); - start.append(new Option("Start from…", ""), ...C.outlines.map((o, i) => new Option(o.name, String(i))), - new Option("Trace a picture…", "trace")); - start.addEventListener("change", () => { - const v = start.value; - start.value = ""; - if (v === "trace") tracePicture(outlineWindow.k); - else if (v !== "") useOutline(outlineWindow.k, C.outlines[Number(v)].points, C.outlines[Number(v)].repeat); - }); -} - -// ================================================================== the gallery, help and files -function buildGallery() { - const designs = $("designs"); - const entries = [{ title: "Plain cylinder", slug: "plain_cylinder", - about: "200 mm across and 200 mm tall, with everything else off: a blank start for your own shape or function" }] - .concat(C.gallery); - for (const g of entries) { - const b = document.createElement("button"); - b.className = "design"; - b.title = g.about; - b.innerHTML = ``; - b.querySelector("b").textContent = g.title; - b.addEventListener("click", () => { - $("gallery").close(); - chooseDesign(g.title); - }); - designs.append(b); - } - const menu = $("designsmenu"); - for (const g of C.gallery) { - const b = document.createElement("button"); - b.textContent = g.title; - b.title = g.about; - b.addEventListener("click", () => { - closeMenus(); - chooseDesign(g.title); - }); - menu.append(b); - } -} - -function chooseDesign(title) { - return enqueue(() => withBusy("Getting it ready…", async () => - replaceNow(JSON.parse(await helper.call("design_of", title, JSON.stringify(params)))))); -} - -function openHelp(which) { - const titles = { printing: "Printing it watertight", function: "Your own function", drawing: "Drawing a cross-section", - about: `About Vaseinator ${C.version}` }; - $("helptitle").textContent = titles[which]; - $("helptext").innerHTML = C.help[which]; - $("helpexamples").hidden = which !== "function"; - $("help").showModal(); -} - -async function withBusy(text, work) { - showBusy(text); - try { - await work(); - } catch (err) { - showProblem(err.message); - throw err; - } finally { - if (!busy) showBusy(null); - } -} - -function download(data, name, type) { - const url = URL.createObjectURL(data instanceof Blob ? data : new Blob([data], { type })); - const a = document.createElement("a"); - a.href = url; - a.download = name; - document.body.append(a); - a.click(); - a.remove(); - setTimeout(() => URL.revokeObjectURL(url), 10000); -} - -function offer(data, name, type, title, steps = "") { - // a file that took a while to make: a browser only lets a click save a file straight away, so the file is - // offered with a button of its own to click - const link = $("stepsfile"); - if (link.href) URL.revokeObjectURL(link.href); - link.href = URL.createObjectURL(new Blob([data], { type })); - link.download = name; - link.textContent = `Save ${name}`; - $("stepstitle").textContent = title; - $("steplist").innerHTML = steps; - $("steps").showModal(); - link.focus(); -} - -function fileName() { - return (params.name || "vase").replace(/[^\w\- ]/g, "_").trim() || "vase"; -} - -// ================================================================== the menus and buttons -let currentView = 0; - -function setView(n) { - currentView = n; - view.setMode(n); - document.querySelectorAll(".views button").forEach((b) => b.setAttribute("aria-pressed", String(Number(b.dataset.view) === n))); -} - -function toggle(id, on) { - $(id).setAttribute("aria-pressed", String(on)); -} - -function effectiveLight() { - const t = document.documentElement.dataset.theme; - return t ? t === "light" : window.matchMedia("(prefers-color-scheme: light)").matches; -} - -async function libraryWork(op, payload = {}, text = JSON.stringify(params)) { - const result = JSON.parse(await helper.call("workspace_action", libraryState ? JSON.stringify(libraryState) : "", text, op, JSON.stringify(payload))); - libraryState = result.workspace; - // Unlike optional preferences, library failures must be visible to avoid silent data loss. - try { localStorage.setItem("vaseinator.library", JSON.stringify(libraryState)); } - catch { showProblem("The library could not be saved in this browser. Export a backup before closing the page."); } - if (result.params) replaceNow(result.params); - return result; -} - -function openLibrary() { - libraryDialog({ - current: () => params, - read: () => enqueue(() => libraryWork("read")), - command: (op, data) => { flushText(); return enqueue(() => libraryWork(op, data)); }, - exportFile: () => enqueue(() => download(JSON.stringify(libraryState, null, 2), "vaseinator-library.json", "application/json")), - importFile: (refresh) => { - const input = document.createElement("input"); input.type = "file"; input.accept = ".json"; - input.onchange = async () => { if (input.files[0]) { await enqueue(async () => libraryWork("import", JSON.parse(await input.files[0].text()))); refresh(); } }; - input.click(); - }, - }); -} - -const actions = { - library: openLibrary, - compare: () => enqueue(() => withBusy("Comparing the surfaces…", async () => { - const data = JSON.parse(await builder.call("compare_design", JSON.stringify(params))); - $("helptitle").textContent = "Original and softened"; - $("helptext").replaceChildren(); - const text = document.createElement("p"); text.textContent = data.text; - const image = document.createElement("img"); image.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(data.svg); image.alt = data.text; image.style.width = "100%"; - $("helptext").append(text, image); $("helpexamples").hidden = true; $("help").showModal(); - })), - calibration: () => enqueue(() => withBusy("Making clearance samples…", async () => { - const {data, steps} = await builder.call("export_calibration", JSON.stringify(params)); - offer(data, "clearance_samples.3mf", "model/3mf", "How to use the samples", steps); - })), - new: () => enqueue(() => replaceNow({ ...C.defaults, ...Object.fromEntries(C.printer_keys.map((k) => [k, params[k]])) })), - plain: () => chooseDesign("Plain cylinder"), - open: () => $("openfile").click(), - savevase: async () => download(await helper.call("save_vase", JSON.stringify(params)), fileName() + ".vase", "application/json"), - print: () => withBusy("Making the file for your slicer…", async () => { - const { data, steps } = await builder.call("export_3mf", JSON.stringify(params)); - offer(data, fileName() + ".3mf", "model/3mf", "How to print it", steps); - }).catch(() => {}), - lid: () => withBusy("Making the lid…", async () => { - const { data, steps } = await builder.call("export_lid", JSON.stringify(params)); - offer(data, fileName() + " lid.3mf", "model/3mf", "How to print the lid", steps); - }).catch(() => {}), - stl: () => withBusy("Making the STL…", async () => - offer(await builder.call("export_stl", JSON.stringify(params)), fileName() + ".stl", "model/stl", - "Your STL is ready")).catch(() => {}), - "3mf": () => withBusy("Making the 3MF…", async () => { - const { data } = await builder.call("export_3mf", JSON.stringify(params)); - offer(data, fileName() + ".3mf", "model/3mf", "Your 3MF is ready", - "

In your slicer, open it with File ▸ Import.

"); - }).catch(() => {}), - picture: async () => download(await view.picture(), fileName() + "_3d.png", "image/png"), - undo: () => step(undoStack, redoStack), - redo: () => step(redoStack, undoStack), - surprise: () => enqueue(() => withBusy("Thinking of something…", async () => - replaceNow(JSON.parse(await helper.call("surprise_me", JSON.stringify(params), Math.floor(Math.random() * 1e6), JSON.stringify(libraryState?.locks || []))))).catch(() => {})), - plan: async () => { - if (!meta) return; - try { - await navigator.clipboard.writeText(meta.plan); - status("Colour changes copied to the clipboard"); - } catch { - status("The browser didn't allow copying: the colour changes are in the panel"); - } - }, - gallery: () => $("gallery").showModal(), - view0: () => setView(0), - view1: () => setView(1), - view2: () => setView(2), - view3: () => setView(3), - spin: () => { - const on = $("spin").getAttribute("aria-pressed") !== "true"; - view.setSpin(on); - toggle("spin", on); - }, - printing: () => { - const on = $("printing").getAttribute("aria-pressed") !== "true"; - view.setPrinting(on); - toggle("printing", on); - }, - reset: () => view.reset(), - bigger: () => { - const on = $("bigger").getAttribute("aria-pressed") !== "true"; - document.documentElement.style.setProperty("--scale", on ? "1.25" : "1"); - toggle("bigger", on); - store.set("bigger", on); - }, - theme: () => { - const light = !effectiveLight(); - document.documentElement.dataset.theme = light ? "light" : "dark"; - store.set("theme", light ? "light" : "dark"); - drawPlots(); - Object.values(editors).forEach((e) => e.draw()); - for (const w of widgets.values()) if (w.set) w.set(params); - }, - "help-printing": () => openHelp("printing"), - "help-function": () => openHelp("function"), - "help-drawing": () => openHelp("drawing"), - "help-about": () => openHelp("about"), -}; - -for (const key of ["savevase", "print", "lid", "stl", "3mf", "picture", "plan"]) { - const action = actions[key]; - actions[key] = () => enqueue(action); -} -for (const key of Object.keys(actions)) { - const action = actions[key]; - actions[key] = () => { flushText(); return action(); }; -} - -function closeMenus(except) { - document.querySelectorAll(".menu[open]").forEach((m) => { if (m !== except) m.open = false; }); -} - -function wire() { - document.addEventListener("click", (e) => { - const b = e.target.closest("[data-do]"); - if (b) { - closeMenus(); - if (C && actions[b.dataset.do]) actions[b.dataset.do](); - return; - } - if (!e.target.closest(".menu")) closeMenus(); - }); - document.querySelectorAll(".menu").forEach((m) => m.addEventListener("toggle", () => { if (m.open) closeMenus(m); })); - document.querySelectorAll(".views button").forEach((b) => b.addEventListener("click", () => setView(Number(b.dataset.view)))); - $("simplebtn").addEventListener("click", () => setMode(false)); - $("advancedbtn").addEventListener("click", () => setMode(true)); - $("openfile").addEventListener("change", async (e) => { - const file = e.target.files[0]; - e.target.value = ""; - if (!file) return; - await enqueue(() => withBusy("Opening it…", async () => replaceNow(JSON.parse(await helper.call("open_vase", await file.text()))))); - }); - $("picturefile").addEventListener("change", (e) => { - const file = e.target.files[0]; - e.target.value = ""; - if (file) traceFile(file); - }); - $("useexample").addEventListener("click", () => { - setFormula($("examplepick").value); - $("help").close(); - }); - document.addEventListener("keydown", (e) => { - if (!C) return; - const typing = e.target.matches("input, textarea, select"); - if (e.ctrlKey || e.metaKey) { - const key = e.key.toLowerCase(); - const act = { z: e.shiftKey ? "redo" : "undo", y: "redo", s: "savevase", o: "open", g: "gallery" }[key]; - if (!act || (typing && (key === "z" || key === "y"))) return; - e.preventDefault(); - actions[act](); - } else if (!typing && !e.target.matches("canvas") && (e.key === "s" || e.key === "S")) { - actions.spin(); - } - }); - const theme = store.get("theme"); - if (theme) document.documentElement.dataset.theme = theme; - if (store.get("bigger")) actions.bigger(); -} - -// ================================================================== starting up -function failed(text) { - $("loading").hidden = false; - $("loading").classList.add("failed"); - $("loadingtext").textContent = `Vaseinator couldn't start: ${text}. Check the internet connection, then reload ` + - "the page."; -} - -async function boot() { - wire(); - try { - const [choices] = await Promise.all([helper.call("start"), builder.call("start")]); - C = JSON.parse(choices); - } catch (err) { - failed(err.message); - return; - } - K = C.constants; - $("examplepick").append(...C.examples.map(([n, f]) => new Option(`${n}: ${f}`, f))); - buildSimple(); - buildAdvanced(); - buildOutlineWindow(); - buildGallery(); - const printer = store.get("printer") || {}; - params = { ...C.defaults, ...printer, ...(store.get("design") || {}) }; - $("loading").hidden = true; - undoButtons(); - setMode(Boolean(store.get("advanced"))); - settled({ refit: true }); -} - -boot(); +// Vaseinator on the web: the desktop app, Simple and Advanced. The vase is built by the app's own Python (in +// worker.js, with Pyodide) - the settings, the rules for which are shown and what each change brings with it, +// and the report beside the view are the desktop app's own - and drawn here with three.js. + +import { CurveEditor } from "./curves.js"; +import { OutlineEditor } from "./outline.js"; +import { drawSide, drawTop } from "./plots.js"; +import { View } from "./view.js"; +import { libraryDialog } from "./library.js"; + +const $ = (id) => document.getElementById(id); + +// what this browser remembers: the design, the printer, the mode and the look - if it may +const store = { + get(key) { + try { return JSON.parse(localStorage.getItem("vaseinator." + key)); } catch { return null; } + }, + set(key, value) { + try { localStorage.setItem("vaseinator." + key, JSON.stringify(value)); } catch { /* private window */ } + }, +}; + +// ================================================================== Python, in two workers +// one builds the vase (which can take a few seconds); the other answers everything else straight away +class Python { + constructor() { + this.worker = new Worker("worker.js"); + this.waiting = new Map(); + this.next = 1; + this.worker.onmessage = (event) => { + const { id, ok, result, error } = event.data; + const w = this.waiting.get(id); + this.waiting.delete(id); + if (ok) w.resolve(result); + else w.reject(new Error(lastLine(error))); + }; + this.worker.onerror = (event) => { + for (const w of this.waiting.values()) w.reject(new Error(event.message || "Python stopped")); + this.waiting.clear(); + failed(event.message || "Python stopped"); + }; + } + + call(name, ...args) { + return new Promise((resolve, reject) => { + const id = this.next++; + this.waiting.set(id, { resolve, reject }); + this.worker.postMessage({ id, call: name, args }); + }); + } +} + +function lastLine(text) { + // a Python error comes with its whole traceback: its last line says what went wrong + const lines = String(text).trim().split("\n"); + return lines[lines.length - 1].replace(/^\w*Error: /, ""); +} + +const builder = new Python(); +const helper = new Python(); + +// ================================================================== the design +let C = null; // the settings, choices, constants and help, from Python +let K = null; // C.constants +let params = null; // the design's settings +let libraryState = store.get("library"); +let recoveryTimer = 0; +let meta = null; // what the last build says about it +let shown = new Set(); // the Advanced settings shown for it +const undoStack = []; +const redoStack = []; +let liveFrom = null; // the settings before a drag began, for Undo +let queue = Promise.resolve(); +let pendingText = null; +function flushText() { + if (pendingText) pendingText(); +} +function enqueue(work) { + const result = queue.then(work); + queue = result.catch((err) => showProblem(err.message)); + return queue; +} + +function pushUndo(snapshot) { + undoStack.push(snapshot); + if (undoStack.length > 100) undoStack.shift(); + redoStack.length = 0; + undoButtons(); +} + +function undoButtons() { + $("undo").disabled = !undoStack.length; + $("redo").disabled = !redoStack.length; +} + +function status(text) { + $("status").textContent = text || ""; +} + +function needsNormal(p) { + // holes, a face, an open bottom or a wall part way round: spiral vase mode prints one closed line a layer + return p.holes !== 0 || p.open_bottom || p.around < 360 || p.eyes !== 0 || p.mouth !== 0 || p.straps || p.box; +} + +function change(updates) { return enqueue(() => changeNow(updates)); } +function changeNow(updates) { + // several settings at once, as a picture in Simple mode sets them + if (!C) return; + pushUndo(JSON.stringify(params)); + Object.assign(params, typeof updates === "function" ? updates(params) : updates); + if (params.vase_mode === K.REAL_VASE && needsNormal(params)) params.vase_mode = K.FAKE_VASE; + settled({}); +} + +function commit(key, value, live = false) { return enqueue(() => commitNow(key, value, live)); } +async function commitNow(key, value, live = false) { + // one setting, as the desktop app's panel changes it: Python applies the app's own rules + if (!C) return Promise.resolve(); + if (live) { + if (liveFrom === null) liveFrom = JSON.stringify(params); + params[key] = value; + if (key === "silhouette") params.profile = K.OWN_PROFILE; + rebuild({ live: true }); + return Promise.resolve(); + } + { + const before = liveFrom !== null ? liveFrom : JSON.stringify(params); + liveFrom = null; + try { + const r = JSON.parse(await helper.call("apply", JSON.stringify(params), key, JSON.stringify(value))); + pushUndo(before); + params = r.params; + shown = new Set(r.shown); + status(r.message); + settled({ known: true }); + } catch (err) { + showProblem(err.message); + } + } +} + +function settled({ refit = false, known = false }) { + store.set("design", params); + rememberPrinter(); + clearTimeout(recoveryTimer); + const snapshot = JSON.stringify(params); + recoveryTimer = setTimeout(() => enqueue(() => libraryWork("recover", {}, snapshot)), 1500); + refreshSimple(); + refreshAdvanced(); + if (!known) { + const snapshot = JSON.stringify(params); + helper.call("shown", snapshot).then((keys) => { + if (JSON.stringify(params) !== snapshot) return; + shown = new Set(JSON.parse(keys)); + refreshAdvanced(); + }).catch(() => {}); + } + rebuild({ refit }); +} + +function replace(next, refit = true) { return enqueue(() => replaceNow(next, refit)); } +function replaceNow(next, refit = true) { + pushUndo(JSON.stringify(params)); + params = next; + settled({ refit }); +} + +function step(from, to) { return enqueue(() => stepNow(from, to)); } +function stepNow(from, to) { + if (!from.length) return; + to.push(JSON.stringify(params)); + params = JSON.parse(from.pop()); + undoButtons(); + settled({}); +} + +// ================================================================== building it +let seq = 0; // the newest design asked for +let wanted = null; // the next build to do +let busy = false; +let refit = true; +let refineTimer = 0; + +function rebuild({ refit: fit = false, live = false } = {}) { + seq += 1; + refit = refit || fit; + clearTimeout(refineTimer); + wanted = { seq, draft: true, live, text: JSON.stringify(params) }; + pump(); +} + +async function pump() { + if (busy || !wanted) return; + const job = wanted; + wanted = null; + busy = true; + showBusy(job.draft ? "Building…" : "Adding the fine detail…"); + let result = null; + try { + result = await builder.call("build", job.text, job.draft); + } catch (err) { + showProblem(err.message); + } + busy = false; + if (result && job.seq === seq) { + show(result); + if (job.draft && !job.live) { + // a quick draft first; then, if nothing changes for a moment, the vase in full detail + refineTimer = setTimeout(() => { + if (job.seq === seq) { + wanted = { seq: job.seq, draft: false, text: job.text }; + pump(); + } + }, 600); + } + } + if (wanted) pump(); + else showBusy(null); +} + +function showBusy(text) { + $("busy").hidden = !text; + if (text) $("busytext").textContent = text; +} + +// ================================================================== the view, the pictures, the report +const view = new View($("canvas"), $("viewer"), (text) => { $("hint").textContent = text; }); +window.vaseinator = { view, get params() { return params; }, get meta() { return meta; } }; // for checking it + +function show(result) { + const m = JSON.parse(result.meta); + if (m.errors && m.errors.length) { + $("report").innerHTML = m.report; + flagCrossing(m.errors); + return; + } + flagCrossing([]); + meta = m; + view.show(result, m, refit); + refit = false; + setNumber($("head"), m.head); + setNumber(widgetInput("head"), m.head); + lightTile($("pattern"), m.pattern); + $("report").innerHTML = m.report; + drawPlots(); +} + +function drawPlots() { + if (!meta) return; + drawSide($("sideplot"), meta.plots); + drawTop($("topplot"), meta.plots); +} +new ResizeObserver(drawPlots).observe($("plots")); + +function showProblem(text) { + const p = document.createElement("p"); + p.className = "e"; + p.textContent = `✗ ${text}`; + $("report").prepend(p); +} + +function flagCrossing(errors) { + // a drawn outline that crosses itself: the build says so; the editors show it red themselves + const crossing = errors.some((e) => e.includes("crosses itself")); + document.body.classList.toggle("crossing", crossing); +} + +// ================================================================== Simple: the pictures +const ACCENT = "#F0A356"; +const EDGE = "#966636"; +const DEEP = "#82582E"; +const svg = (body, size = 52) => ``; + +function aboveIcon(outline) { + const pts = outline.map(([x, y]) => `${(26 + 22.9 * x).toFixed(1)},${(26 - 22.9 * y).toFixed(1)}`).join(" "); + return svg(``); +} + +function sideIcon(widths) { + const n = widths.length; + const left = []; + const right = []; + widths.forEach((w, i) => { + const y = 48 - (44 * i) / (n - 1); + left.push(`${(26 - 18.7 * w).toFixed(1)},${y.toFixed(1)}`); + right.unshift(`${(26 + 18.7 * w).toFixed(1)},${y.toFixed(1)}`); + }); + return svg(``); +} + +function patternIcon(name) { + // a little vase body with the pattern drawn on it, as the desktop app draws them + const [l, t, r, b] = [10, 4, 42, 48]; + const lines = []; + const line = (x1, y1, x2, y2) => lines.push(``); + const dots = []; + const holes = []; + const range = (a, z, n) => Array.from({ length: n }, (_, i) => a + ((z - a) * i) / (n - 1)); + if (name === "Ribs") range(l + 4, r - 4, 6).forEach((x) => line(x, t, x, b)); + if (name === "Spiral ribs" || name === "Diamonds") { + range(l - 30, r + 4, 9).forEach((x) => { + line(x, b, x + 26, t); + if (name === "Diamonds") line(x, t, x + 26, b); + }); + } + if (name === "Rings") range(t + 5, b - 5, 6).forEach((y) => line(l, y, r, y)); + if (name === "Bumps") { + range(t + 6, b - 6, 6).forEach((y, row) => + range(l + 5 + (row % 2) * 3, r - 5, 4).forEach((x) => dots.push(``))); + } + if (name === "Lumpy") { + [[18, 12, 5], [31, 18, 6], [21, 30, 7], [34, 37, 5], [17, 44, 4]].forEach(([x, y, rr]) => + dots.push(``)); + } + if (name === "Gem") { + range(t, b, 5).forEach((y) => line(l, y, r, y)); + range(t, b, 5).slice(0, -1).forEach((y, k) => + range(l - 8 + 4 * (k % 2), r, 4).forEach((x) => line(x, y, x + 8, y + (b - t) / 4))); + } + if (name === "Round holes") { + range(t + 7, b - 7, 4).forEach((y, row) => + range(l + 6 + (row % 2) * 4, r - 6, 3).forEach((x) => holes.push(``))); + } + if (name === "Voronoi holes") { + [[[14, 8], [22, 6], [25, 13], [19, 18], [13, 15]], [[28, 9], [35, 7], [38, 15], [31, 19]], + [[13, 22], [21, 22], [23, 30], [15, 33]], [[27, 24], [36, 22], [38, 31], [29, 33]], + [[15, 38], [24, 37], [25, 45], [14, 46]], [[29, 38], [37, 37], [36, 46], [28, 45]]].forEach((cell) => + holes.push(``)); + } + const id = "p" + name.replace(/\W/g, ""); + return svg( + `` + + `${holes.join("")}` + + `` + + `${lines.join("")}${dots.join("")}`); +} + +function tissueIcon(kind) { + // a tissue box cover from above, with its hole for the tissues cut out, as the desktop app draws them + const hole = kind === C.constants.TISSUE_ROUND ? `` + : kind === C.constants.TISSUE_OVAL ? `` + : ``; + return svg(`${hole}` + + ``); +} + +function swatch(colours) { + const n = colours.length; + const id = "s" + colours.join("").replace(/#/g, ""); + const bands = colours.map((c, k) => + ``).join(""); + return ``; +} + +function tiles(box, items, onPick, cls = "tile") { + box.replaceChildren(); + items.forEach(({ label, icon, tip }, k) => { + const b = document.createElement("button"); + b.className = cls; + b.title = tip || label; + b.setAttribute("aria-pressed", "false"); + b.innerHTML = cls === "tile" ? `${icon}` : `${icon}`; + if (cls === "tile") b.querySelector("span").textContent = label; + else b.setAttribute("aria-label", label); + b.addEventListener("click", () => { + lightTile(box, k); + onPick(k); + }); + box.append(b); + }); +} + +function lightTile(box, k) { + [...box.children].forEach((b, i) => b.setAttribute("aria-pressed", String(i === k))); +} + +function widest() { + return Math.max(...params.silhouette.map(([, d]) => d)); +} + +function setNumber(input, value) { + if (input && document.activeElement !== input && value !== undefined && value !== null) input.value = value; +} + +function twistWords(deg) { + const words = { 0: "No twist", 90: "A quarter turn", 180: "Half a turn", 270: "Three quarters of a turn", + 360: "A whole turn", 720: "Two whole turns" }; + for (const [k, w] of Object.entries(words)) if (Math.abs(Math.abs(deg) - Number(k)) < 0.5) return w; + return `${Math.round(deg)}°`; +} + +function titleOf(p) { + const match = C.gallery.find((g) => g.slug === p.name); + if (match) return match.title; + if (p.name === "plain_cylinder") return "Plain cylinder"; + const name = p.name.replace(/_/g, " ").trim(); + return name ? name[0].toUpperCase() + name.slice(1) : "Your vase"; +} + +function refreshSimple() { + const p = params; + $("name").textContent = titleOf(p); + document.title = `${titleOf(p)} – Vaseinator`; + setNumber($("tall"), Math.round(p.height) / 10); + setNumber($("wide"), Math.round(widest()) / 10); + // a mask or helmet is sized by the head; a tissue box cover by the box of tissues, and is a box from every side + $("size").hidden = p.worn || p.box; + $("headrow").hidden = !p.worn || p.box; + $("sidetitle").hidden = p.worn || p.box; + $("side").hidden = p.worn || p.box; + for (const id of ["abovetitle", "above", "drawown", "twisttitle", "twistrow"]) $(id).hidden = p.box; + $("boxrow").hidden = !p.box; + for (const b of document.querySelectorAll(".lidonly")) b.hidden = !p.box; + for (const input of document.querySelectorAll("#boxrow input")) setNumber(input, Math.round(p[input.dataset.key]) / 10); + lightTile($("tissue"), C.tissue_holes.findIndex((t) => t.value === p.tissue_hole)); + lightTile($("above"), C.shapes.findIndex(({ settings: s }) => + p.shape2 === K.SAME && p.shape1 === s.shape1 && (p.shape1 !== K.POLYGON || p.count1 === s.count1))); + lightTile($("side"), p.profile >= 0 && p.profile < C.profiles.length ? p.profile : -1); + const twist = $("twist"); + if (document.activeElement !== twist) twist.value = Math.round(Math.min(Math.max(p.twist, 0), 360) / 15); + $("twistwords").textContent = p.twist_style === K.OWN ? "Your own curve" : twistWords(p.twist); + if (p.colour_style === K.ONE_COLOUR) { + lightTile($("colour"), C.colours.findIndex((c) => c.colour.toLowerCase() === p.color1.toLowerCase())); + } else { + const k = C.many_colours.findIndex((m) => m.settings.colour_style === p.colour_style); + lightTile($("colour"), k < 0 ? -1 : C.colours.length + k); + } + for (const input of document.querySelectorAll("#printer input")) setNumber(input, p[input.dataset.key]); +} + +function buildSimple() { + tiles($("above"), C.shapes.map((s) => ({ label: s.name, icon: aboveIcon(s.outline) })), + (k) => change({ shape2: K.SAME, turn1: 0, ...C.shapes[k].settings })); + tiles($("side"), C.profiles.map((s) => ({ label: s.name, icon: sideIcon(s.widths), tip: s.full })), + (k) => change(() => { + const w = widest(); + return { profile: k, silhouette: C.profiles[k].points.map(([h, f]) => [h, Math.round(f * w * 100) / 100]) }; + })); + tiles($("pattern"), C.patterns.map((s) => ({ label: s.name, icon: patternIcon(s.name) })), + (k) => change({ ...C.no_pattern, ...C.patterns[k].settings })); + const colourItems = C.colours.map((c) => ({ label: c.name, icon: swatch([c.colour]) })) + .concat(C.many_colours.map((m) => ({ label: `${m.name} of colour (${m.palette})`, icon: swatch(m.colours.slice(0, 3)) }))); + tiles($("colour"), colourItems, (k) => { + if (k < C.colours.length) { + change({ colour_style: K.ONE_COLOUR, color1: C.colours[k].colour }); + } else { + const m = C.many_colours[k - C.colours.length]; + change({ ...m.settings, ...Object.fromEntries(m.colours.map((c, i) => [`color${i + 1}`, c])) }); + } + }, "swatch"); + $("tall").addEventListener("change", (e) => { + const cm = parseFloat(e.target.value); + if (cm > 0) change({ height: Math.round(cm * 100) / 10 }); + }); + $("wide").addEventListener("change", (e) => { + const cm = parseFloat(e.target.value); + if (cm > 0) commit("widest", cm * 10); + }); + $("head").addEventListener("change", (e) => { + const cm = parseFloat(e.target.value); + if (cm > 0) fitHead(cm); + }); + tiles($("tissue"), C.tissue_holes.map((t) => ({ label: t.name, icon: tissueIcon(t.value) })), + (k) => commit("tissue_hole", C.tissue_holes[k].value)); + for (const input of document.querySelectorAll("#boxrow input")) { + input.addEventListener("change", () => { + const cm = parseFloat(input.value); + if (cm > 0) commit(input.dataset.key, Math.round(cm * 100) / 10); + }); + } + $("twist").addEventListener("input", (e) => { + $("twistwords").textContent = twistWords(15 * e.target.value); + }); + $("twist").addEventListener("change", (e) => { + const twist = 15 * Number(e.target.value); + change((p) => ({twist, ...(p.twist_style === K.OWN ? {twist_style: K.EVENLY} : {})})); + }); + for (const input of document.querySelectorAll("#printer input")) { + input.addEventListener("change", () => { + const v = parseFloat(input.value); + if (!(v > 0)) return; + change({ [input.dataset.key]: input.dataset.key === "walls" ? Math.round(v) : v }); + // settled() persists the final queued settings. + }); + } + $("drawown").addEventListener("click", async () => { + if (params.shape1 !== K.DRAWN) await commit("shape1", K.DRAWN); + openOutline(1); + }); +} + +function rememberPrinter() { + store.set("printer", Object.fromEntries(C.printer_keys.map((k) => [k, params[k]]))); +} + +async function fitHead(cm) { return enqueue(async () => { + await withBusy("Fitting it to the head…", async () => + replaceNow(JSON.parse(await helper.call("fit", JSON.stringify(params), cm * 10)), false)); + status(`Sized for a head ${cm} cm round — Undo to go back`); +}); } + +// ================================================================== Advanced: every setting +const widgets = new Map(); // key -> {rows: [elements], set(p), input} +const editors = {}; // the outline editors in the panel, by k + +function widgetInput(key) { + const w = widgets.get(key); + return w ? w.input : null; +} + +function colourStyleOf(p) { + // a seamless vase prints one filament: no bands or stripes + return p.vase_mode === K.REAL_VASE && (p.colour_style === 1 || p.colour_style === 2) ? K.ONE_COLOUR : p.colour_style; +} + +function numberBox(row, { decimals = 2 } = {}) { + const input = document.createElement("input"); + input.type = "number"; + input.min = row.lo; + input.max = row.hi; + input.step = row.step; + input.dataset.decimals = decimals; + return input; +} + +function makeRow(row, rowsBox) { + const { key, label, kind } = row; + const [unit, none] = String(row.extra || "").split("|"); + const lab = document.createElement("label"); + lab.className = "label"; + lab.textContent = label; + lab.title = row.tip || ""; + const control = document.createElement("div"); + control.className = "control"; + control.title = row.tip || ""; + let set = () => {}; + let input = null; + const whole = ["curve", "expr", "outline"].includes(kind); + if (kind === "float" || kind === "int" || kind === "seed" || kind === "pct" || kind === "widest" || kind === "head") { + const scale = kind === "pct" ? 100 : 1; // a percent is shown ×100; its limits are in percent already + input = numberBox(row); + lab.htmlFor = input.id = `set-${key}`; + control.append(input); + if (unit) { + const u = document.createElement("span"); + u.className = "unit"; + u.textContent = unit.trim(); + control.append(u); + } + let noneLabel = null; + if (none) { + noneLabel = document.createElement("span"); + noneLabel.className = "none"; + noneLabel.textContent = `(${none.toLowerCase()})`; + control.append(noneLabel); + } + if (kind === "seed") { + const dice = document.createElement("button"); + dice.className = "dice"; + dice.textContent = "🎲"; + dice.title = "Another pattern"; + dice.addEventListener("click", () => commit(key, Math.floor(Math.random() * 99999))); + control.append(dice); + } + input.addEventListener("change", () => { + let v = parseFloat(input.value); + if (Number.isNaN(v)) return; + v = Math.min(Math.max(v, row.lo), row.hi) / scale; + if (kind === "int" || kind === "seed") v = Math.round(v); + if (kind === "head") return fitHead(v); + commit(key, v); + }); + set = (p) => { + let v; + if (kind === "widest") v = Math.round(widest() * 10) / 10; + else if (kind === "head") v = meta ? meta.head : 57; + else v = p[key] * scale; + setNumber(input, kind === "pct" ? Math.round(v * 10) / 10 : Math.round(v * 1000) / 1000); + if (noneLabel) noneLabel.hidden = !(p[key] === row.lo); + if (/^count[12]$/.test(key)) input.min = p[`shape${key.slice(-1)}`] === K.POLYGON ? 3 : 1; + }; + } else if (kind === "bool") { + input = document.createElement("input"); + input.type = "checkbox"; + lab.htmlFor = input.id = `set-${key}`; + control.append(input); + input.addEventListener("change", () => commit(key, input.checked)); + set = (p) => { input.checked = Boolean(p[key]); }; + } else if (kind === "choice") { + input = document.createElement("select"); + lab.htmlFor = input.id = `set-${key}`; + const fill = (choices) => { + const now = JSON.stringify(choices); + if (input.dataset.choices === now) return; + input.dataset.choices = now; + input.replaceChildren(...choices.map(([v, t]) => new Option(t, v))); + }; + fill(row.choices); + control.append(input); + input.addEventListener("change", () => commit(key, Number(input.value))); + set = (p) => { + if (key === "colour_style") { + fill(p.vase_mode === K.REAL_VASE ? C.real_colour_styles : C.colour_styles); + input.value = colourStyleOf(p); + } else { + input.value = p[key]; + } + }; + } else if (kind === "color") { + input = document.createElement("input"); + input.type = "color"; + lab.htmlFor = input.id = `set-${key}`; + const hex = document.createElement("span"); + hex.className = "unit"; + control.append(input, hex); + input.addEventListener("change", () => commit(key, input.value.toUpperCase())); + set = (p) => { + input.value = p[key].toLowerCase(); + hex.textContent = p[key].toUpperCase(); + }; + } else if (kind === "slider") { + input = document.createElement("input"); + input.type = "range"; + input.min = row.lo; + input.max = row.hi; + input.step = row.step; + lab.htmlFor = input.id = `set-${key}`; + const note = document.createElement("span"); + note.className = "unit"; + control.append(input, note); + const words = (v) => (Math.abs(v) < 0.05 ? "Even" : v > 0 ? "Thicker towards the top" : "Thicker towards the bottom"); + input.addEventListener("input", () => { + note.textContent = words(Number(input.value)); + commit(key, Number(input.value), true); + }); + input.addEventListener("change", () => commit(key, Number(input.value))); + set = (p) => { + if (document.activeElement !== input) input.value = p[key]; + note.textContent = words(p[key]); + }; + } else if (kind === "palette") { + input = document.createElement("select"); + lab.htmlFor = input.id = `set-${key}`; + input.append(new Option("Choose a palette…", ""), ...C.palettes.map((pal) => new Option(pal.name, pal.name))); + control.append(input); + input.addEventListener("change", () => { + const pal = C.palettes.find((x) => x.name === input.value); + input.value = ""; + if (pal) change(Object.fromEntries(pal.colours.map((c, i) => [`color${i + 1}`, c]))); + }); + } else if (kind === "curve") { + control.className = "whole"; + const canvas = document.createElement("canvas"); + control.append(canvas); + const o = row.curve; + const editor = new CurveEditor(canvas, { lo: o.lo, hi: o.hi, unit: o.unit || "", mirror: Boolean(o.mirror), + height: o.height || 110, floor: o.floor ?? null, grow: Boolean(o.grow), decimals: o.decimals || 0 }, + (pts, live) => commit(key, pts, live)); + set = (p) => editor.setPoints(p[key]); + } else if (kind === "expr") { + control.className = "whole"; + const text = document.createElement("textarea"); + text.spellcheck = false; + text.placeholder = "1.5 * sin(6*theta + 4*pi*h)"; + text.setAttribute("aria-label", "Your own function"); + input = text; + const tools = document.createElement("div"); + tools.className = "row"; + const examples = document.createElement("select"); + examples.append(new Option("Examples…", ""), ...C.examples.map(([n, f]) => new Option(`${n}: ${f}`, f))); + const help = document.createElement("button"); + help.textContent = "Help…"; + const clear = document.createElement("button"); + clear.textContent = "Clear"; + tools.append(examples, help, clear); + control.append(text, tools); + let timer = 0; + text.addEventListener("input", () => { + clearTimeout(timer); + const value = text.value; + pendingText = () => { + clearTimeout(timer); + pendingText = null; + commit(key, value); + }; + timer = setTimeout(flushText, 600); + }); + examples.addEventListener("change", () => { + if (examples.value) setFormula(examples.value); + examples.value = ""; + }); + help.addEventListener("click", () => openHelp("function")); + clear.addEventListener("click", () => setFormula("")); + set = (p) => { if (document.activeElement !== text && text.value !== p[key]) text.value = p[key]; }; + } else if (kind === "outline") { + control.className = "whole outlinebox"; + const k = Number(key.slice(-1)); + const canvas = document.createElement("canvas"); + const tools = document.createElement("div"); + tools.className = "row"; + const start = startFromMenu(k); + const close = document.createElement("button"); + close.textContent = "Close up"; + close.title = "Show one repeat close up, turned to point upwards - or the whole outline. Above 8 repeats it starts close up."; + const big = document.createElement("button"); + big.textContent = "Full window…"; + big.title = "Draw it in a window as big as the screen: the wheel zooms, a right-drag moves it."; + tools.append(start, close, big); + control.append(canvas, tools); + const editor = new OutlineEditor(canvas, {}, (pts, live) => commit(key, pts, live), + () => close.setAttribute("aria-pressed", String(editor.closeUp()))); + editors[k] = editor; + close.addEventListener("click", () => editor.setCloseUp(!editor.closeUp())); + big.addEventListener("click", () => openOutline(k)); + set = (p) => editor.setOutline(p[key], p[`repeat${k}`]); + } + if (whole) { + if (label) { + lab.className = "label wholelabel"; + rowsBox.append(lab); + } + rowsBox.append(control); + widgets.set(key, { rows: label ? [lab, control] : [control], set, input, lab }); + } else { + rowsBox.append(lab, control); + widgets.set(key, { rows: [lab, control], set, input, lab }); + } +} + +function startFromMenu(k) { + const start = document.createElement("select"); + start.setAttribute("aria-label", "Start from"); + start.title = "Start again from a ready-made outline, or trace one from a picture: dark lines drawn round a shape on a light or see-through ground."; + start.append(new Option("Start from…", ""), ...C.outlines.map((o, i) => new Option(o.name, String(i))), + new Option("Trace a picture…", "trace")); + start.addEventListener("change", () => { + const v = start.value; + start.value = ""; + if (v === "trace") tracePicture(k); + else if (v !== "") useOutline(k, C.outlines[Number(v)].points, C.outlines[Number(v)].repeat); + }); + return start; +} + +function useOutline(k, points, repeat) { return enqueue(() => useOutlineNow(k, points, repeat)); } +function useOutlineNow(k, points, repeat) { + // start drawn outline k again from given control points + liveFrom = JSON.stringify(params); + params[`repeat${k}`] = repeat; + return commitNow(`drawn${k}`, points); +} + +let traceFor = 1; +function tracePicture(k) { + traceFor = k; + $("picturefile").click(); +} + +async function traceFile(file) { + const target = traceFor; + return enqueue(() => withBusy("Tracing the picture…", async () => { + const bitmap = await createImageBitmap(file); + const scale = Math.min(1, 1080 / Math.max(bitmap.width, bitmap.height)); + const w = Math.max(Math.round(bitmap.width * scale), 1); + const h = Math.max(Math.round(bitmap.height * scale), 1); + const canvas = new OffscreenCanvas(w, h); + const g = canvas.getContext("2d"); + g.drawImage(bitmap, 0, 0, w, h); + const pixels = g.getImageData(0, 0, w, h).data; + let text = ""; + for (let i = 0; i < pixels.length; i += 0x8000) text += String.fromCharCode(...pixels.subarray(i, i + 0x8000)); + const pts = JSON.parse(await helper.call("trace", btoa(text), w, h)); + bitmap.close(); + await useOutlineNow(target, pts, 1); + status(`Traced ${file.name}: ${pts.length} points, ${pts.filter((q) => q[2]).length} sharp corners.`); + }).catch(() => {})); +} + +function setFormula(text) { + flushText(); + const w = widgets.get("expression"); + if (w && w.input) w.input.value = text; + commit("expression", text); +} + +function buildAdvanced() { + const box = $("groups"); + box.replaceChildren(); + for (const group of C.spec) { + const section = document.createElement("details"); + section.className = "group"; + section.open = !store.get(`closed.${group.title}`); + section.addEventListener("toggle", () => store.set(`closed.${group.title}`, !section.open)); + const summary = document.createElement("summary"); + summary.textContent = group.title; + const rows = document.createElement("div"); + rows.className = "rows"; + section.append(summary, rows); + box.append(section); + group.rows.forEach((row) => makeRow(row, rows)); + group.element = section; + } + $("designname").addEventListener("change", (e) => commit("name", e.target.value.trim() || "vase")); +} + +function refreshAdvanced() { + if (!C || !params) return; + const p = params; + setNumber($("designname"), p.name); + for (const [key, w] of widgets) { + const show = shown.size === 0 || shown.has(key); + w.rows.forEach((el) => { el.hidden = !show; }); + if (show) w.set(p); + } + for (const group of C.spec) group.element.hidden = !group.rows.some((r) => shown.size === 0 || shown.has(r.key)); + // the settings named for the shape: sides, points, petals, teeth + for (const k of [1, 2]) { + const names = C.shape_labels[String(p[`shape${k}`])] || [null, null, null]; + ["count", "depth", "sharp"].forEach((what, i) => { + const w = widgets.get(`${what}${k}`); + if (w && names[i]) w.lab.textContent = names[i]; + }); + } + const c1 = widgets.get("color1"); + if (c1) c1.lab.textContent = colourStyleOf(p) === K.ONE_COLOUR ? "Colour" : "Filament 1"; + if (outlineWindow.k) { + const k = outlineWindow.k; + outlineWindow.editor.setOutline(p[`drawn${k}`], p[`repeat${k}`]); + setNumber($("winrepeat"), p[`repeat${k}`]); + } +} + +function setMode(advanced) { + $("simple").hidden = advanced; + $("advanced").hidden = !advanced; + $("simplebtn").setAttribute("aria-selected", String(!advanced)); + $("advancedbtn").setAttribute("aria-selected", String(advanced)); + store.set("advanced", advanced); + if (advanced) refreshAdvanced(); +} + +// ================================================================== drawing in a window of its own +const outlineWindow = { k: 0, editor: null }; + +function openOutline(k) { + const win = $("outlinewin"); + outlineWindow.k = k; + $("outlinetitle").textContent = k === 1 ? "Drawing the cross-section" : "Drawing what it turns into"; + $("winrepeat").value = params[`repeat${k}`]; + win.showModal(); + outlineWindow.editor.view = null; + outlineWindow.editor.setOutline(params[`drawn${k}`], params[`repeat${k}`]); + $("bigoutline").focus(); +} + +function buildOutlineWindow() { + const close = $("winclose"); + outlineWindow.editor = new OutlineEditor($("bigoutline"), { big: true }, + (pts, live) => commit(`drawn${outlineWindow.k}`, pts, live), + () => close.setAttribute("aria-pressed", String(outlineWindow.editor.closeUp()))); + close.addEventListener("click", () => outlineWindow.editor.setCloseUp(!outlineWindow.editor.closeUp())); + $("winfit").addEventListener("click", () => outlineWindow.editor.fit()); + $("windone").addEventListener("click", () => $("outlinewin").close()); + $("outlinewin").addEventListener("close", () => { outlineWindow.k = 0; }); + $("winrepeat").addEventListener("change", (e) => { + const n = Math.min(Math.max(Math.round(Number(e.target.value)), 1), 100); + if (n) commit(`repeat${outlineWindow.k}`, n); + }); + const start = $("winstart"); + start.append(new Option("Start from…", ""), ...C.outlines.map((o, i) => new Option(o.name, String(i))), + new Option("Trace a picture…", "trace")); + start.addEventListener("change", () => { + const v = start.value; + start.value = ""; + if (v === "trace") tracePicture(outlineWindow.k); + else if (v !== "") useOutline(outlineWindow.k, C.outlines[Number(v)].points, C.outlines[Number(v)].repeat); + }); +} + +// ================================================================== the gallery, help and files +function buildGallery() { + const designs = $("designs"); + const entries = [{ title: "Plain cylinder", slug: "plain_cylinder", + about: "200 mm across and 200 mm tall, with everything else off: a blank start for your own shape or function" }] + .concat(C.gallery); + for (const g of entries) { + const b = document.createElement("button"); + b.className = "design"; + b.title = g.about; + b.innerHTML = ``; + b.querySelector("b").textContent = g.title; + b.addEventListener("click", () => { + $("gallery").close(); + chooseDesign(g.title); + }); + designs.append(b); + } + const menu = $("designsmenu"); + for (const g of C.gallery) { + const b = document.createElement("button"); + b.textContent = g.title; + b.title = g.about; + b.addEventListener("click", () => { + closeMenus(); + chooseDesign(g.title); + }); + menu.append(b); + } +} + +function chooseDesign(title) { + return enqueue(() => withBusy("Getting it ready…", async () => + replaceNow(JSON.parse(await helper.call("design_of", title, JSON.stringify(params)))))); +} + +function openHelp(which) { + const titles = { printing: "Printing it watertight", function: "Your own function", drawing: "Drawing a cross-section", + about: `About Vaseinator ${C.version}` }; + $("helptitle").textContent = titles[which]; + $("helptext").innerHTML = C.help[which]; + $("helpexamples").hidden = which !== "function"; + $("help").showModal(); +} + +async function withBusy(text, work) { + showBusy(text); + try { + await work(); + } catch (err) { + showProblem(err.message); + throw err; + } finally { + if (!busy) showBusy(null); + } +} + +function download(data, name, type) { + const url = URL.createObjectURL(data instanceof Blob ? data : new Blob([data], { type })); + const a = document.createElement("a"); + a.href = url; + a.download = name; + document.body.append(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 10000); +} + +function offer(data, name, type, title, steps = "") { + // a file that took a while to make: a browser only lets a click save a file straight away, so the file is + // offered with a button of its own to click + const link = $("stepsfile"); + if (link.href) URL.revokeObjectURL(link.href); + link.href = URL.createObjectURL(new Blob([data], { type })); + link.download = name; + link.textContent = `Save ${name}`; + $("stepstitle").textContent = title; + $("steplist").innerHTML = steps; + $("steps").showModal(); + link.focus(); +} + +function fileName() { + return (params.name || "vase").replace(/[^\w\- ]/g, "_").trim() || "vase"; +} + +// ================================================================== the menus and buttons +let currentView = 0; + +function setView(n) { + currentView = n; + view.setMode(n); + document.querySelectorAll(".views button").forEach((b) => b.setAttribute("aria-pressed", String(Number(b.dataset.view) === n))); +} + +function toggle(id, on) { + $(id).setAttribute("aria-pressed", String(on)); +} + +function effectiveLight() { + const t = document.documentElement.dataset.theme; + return t ? t === "light" : window.matchMedia("(prefers-color-scheme: light)").matches; +} + +async function libraryWork(op, payload = {}, text = JSON.stringify(params)) { + const result = JSON.parse(await helper.call("workspace_action", libraryState ? JSON.stringify(libraryState) : "", text, op, JSON.stringify(payload))); + libraryState = result.workspace; + // Unlike optional preferences, library failures must be visible to avoid silent data loss. + try { localStorage.setItem("vaseinator.library", JSON.stringify(libraryState)); } + catch { showProblem("The library could not be saved in this browser. Export a backup before closing the page."); } + if (result.params) replaceNow(result.params); + return result; +} + +function openLibrary() { + libraryDialog({ + current: () => params, + read: () => enqueue(() => libraryWork("read")), + command: (op, data) => { flushText(); return enqueue(() => libraryWork(op, data)); }, + exportFile: () => enqueue(() => download(JSON.stringify(libraryState, null, 2), "vaseinator-library.json", "application/json")), + importFile: (refresh) => { + const input = document.createElement("input"); input.type = "file"; input.accept = ".json"; + input.onchange = async () => { if (input.files[0]) { await enqueue(async () => libraryWork("import", JSON.parse(await input.files[0].text()))); refresh(); } }; + input.click(); + }, + }); +} + +const actions = { + library: openLibrary, + compare: () => enqueue(() => withBusy("Comparing the surfaces…", async () => { + const data = JSON.parse(await builder.call("compare_design", JSON.stringify(params))); + $("helptitle").textContent = "Original and softened"; + $("helptext").replaceChildren(); + const text = document.createElement("p"); text.textContent = data.text; + const image = document.createElement("img"); image.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(data.svg); image.alt = data.text; image.style.width = "100%"; + $("helptext").append(text, image); $("helpexamples").hidden = true; $("help").showModal(); + })), + calibration: () => enqueue(() => withBusy("Making clearance samples…", async () => { + const {data, steps} = await builder.call("export_calibration", JSON.stringify(params)); + offer(data, "clearance_samples.3mf", "model/3mf", "How to use the samples", steps); + })), + new: () => enqueue(() => replaceNow({ ...C.defaults, ...Object.fromEntries(C.printer_keys.map((k) => [k, params[k]])) })), + plain: () => chooseDesign("Plain cylinder"), + open: () => $("openfile").click(), + savevase: async () => download(await helper.call("save_vase", JSON.stringify(params)), fileName() + ".vase", "application/json"), + print: () => withBusy("Making the file for your slicer…", async () => { + const { data, steps } = await builder.call("export_3mf", JSON.stringify(params)); + offer(data, fileName() + ".3mf", "model/3mf", "How to print it", steps); + }).catch(() => {}), + lid: () => withBusy("Making the lid…", async () => { + const { data, steps } = await builder.call("export_lid", JSON.stringify(params)); + offer(data, fileName() + " lid.3mf", "model/3mf", "How to print the lid", steps); + }).catch(() => {}), + stl: () => withBusy("Making the STL…", async () => + offer(await builder.call("export_stl", JSON.stringify(params)), fileName() + ".stl", "model/stl", + "Your STL is ready")).catch(() => {}), + "3mf": () => withBusy("Making the 3MF…", async () => { + const { data } = await builder.call("export_3mf", JSON.stringify(params)); + offer(data, fileName() + ".3mf", "model/3mf", "Your 3MF is ready", + "

In your slicer, open it with File ▸ Import.

"); + }).catch(() => {}), + picture: async () => download(await view.picture(), fileName() + "_3d.png", "image/png"), + undo: () => step(undoStack, redoStack), + redo: () => step(redoStack, undoStack), + surprise: () => enqueue(() => withBusy("Thinking of something…", async () => + replaceNow(JSON.parse(await helper.call("surprise_me", JSON.stringify(params), Math.floor(Math.random() * 1e6), JSON.stringify(libraryState?.locks || []))))).catch(() => {})), + plan: async () => { + if (!meta) return; + try { + await navigator.clipboard.writeText(meta.plan); + status("Colour changes copied to the clipboard"); + } catch { + status("The browser didn't allow copying: the colour changes are in the panel"); + } + }, + gallery: () => $("gallery").showModal(), + view0: () => setView(0), + view1: () => setView(1), + view2: () => setView(2), + view3: () => setView(3), + spin: () => { + const on = $("spin").getAttribute("aria-pressed") !== "true"; + view.setSpin(on); + toggle("spin", on); + }, + printing: () => { + const on = $("printing").getAttribute("aria-pressed") !== "true"; + view.setPrinting(on); + toggle("printing", on); + }, + reset: () => view.reset(), + bigger: () => { + const on = $("bigger").getAttribute("aria-pressed") !== "true"; + document.documentElement.style.setProperty("--scale", on ? "1.25" : "1"); + toggle("bigger", on); + store.set("bigger", on); + }, + theme: () => { + const light = !effectiveLight(); + document.documentElement.dataset.theme = light ? "light" : "dark"; + store.set("theme", light ? "light" : "dark"); + drawPlots(); + Object.values(editors).forEach((e) => e.draw()); + for (const w of widgets.values()) if (w.set) w.set(params); + }, + "help-printing": () => openHelp("printing"), + "help-function": () => openHelp("function"), + "help-drawing": () => openHelp("drawing"), + "help-about": () => openHelp("about"), +}; + +for (const key of ["savevase", "print", "lid", "stl", "3mf", "picture", "plan"]) { + const action = actions[key]; + actions[key] = () => enqueue(action); +} +for (const key of Object.keys(actions)) { + const action = actions[key]; + actions[key] = () => { flushText(); return action(); }; +} + +function closeMenus(except) { + document.querySelectorAll(".menu[open]").forEach((m) => { if (m !== except) m.open = false; }); +} + +function wire() { + document.addEventListener("click", (e) => { + const b = e.target.closest("[data-do]"); + if (b) { + closeMenus(); + if (C && actions[b.dataset.do]) actions[b.dataset.do](); + return; + } + if (!e.target.closest(".menu")) closeMenus(); + }); + document.querySelectorAll(".menu").forEach((m) => m.addEventListener("toggle", () => { if (m.open) closeMenus(m); })); + document.querySelectorAll(".views button").forEach((b) => b.addEventListener("click", () => setView(Number(b.dataset.view)))); + $("simplebtn").addEventListener("click", () => setMode(false)); + $("advancedbtn").addEventListener("click", () => setMode(true)); + $("openfile").addEventListener("change", async (e) => { + const file = e.target.files[0]; + e.target.value = ""; + if (!file) return; + await enqueue(() => withBusy("Opening it…", async () => replaceNow(JSON.parse(await helper.call("open_vase", await file.text()))))); + }); + $("picturefile").addEventListener("change", (e) => { + const file = e.target.files[0]; + e.target.value = ""; + if (file) traceFile(file); + }); + $("useexample").addEventListener("click", () => { + setFormula($("examplepick").value); + $("help").close(); + }); + document.addEventListener("keydown", (e) => { + if (!C) return; + const typing = e.target.matches("input, textarea, select"); + if (e.ctrlKey || e.metaKey) { + const key = e.key.toLowerCase(); + const act = { z: e.shiftKey ? "redo" : "undo", y: "redo", s: "savevase", o: "open", g: "gallery" }[key]; + if (!act || (typing && (key === "z" || key === "y"))) return; + e.preventDefault(); + actions[act](); + } else if (!typing && !e.target.matches("canvas") && (e.key === "s" || e.key === "S")) { + actions.spin(); + } + }); + const theme = store.get("theme"); + if (theme) document.documentElement.dataset.theme = theme; + if (store.get("bigger")) actions.bigger(); +} + +// ================================================================== starting up +function failed(text) { + $("loading").hidden = false; + $("loading").classList.add("failed"); + $("loadingtext").textContent = `Vaseinator couldn't start: ${text}. Check the internet connection, then reload ` + + "the page."; +} + +async function boot() { + wire(); + try { + const [choices] = await Promise.all([helper.call("start"), builder.call("start")]); + C = JSON.parse(choices); + } catch (err) { + failed(err.message); + return; + } + K = C.constants; + $("examplepick").append(...C.examples.map(([n, f]) => new Option(`${n}: ${f}`, f))); + buildSimple(); + buildAdvanced(); + buildOutlineWindow(); + buildGallery(); + const printer = store.get("printer") || {}; + params = { ...C.defaults, ...printer, ...(store.get("design") || {}) }; + $("loading").hidden = true; + undoButtons(); + setMode(Boolean(store.get("advanced"))); + settled({ refit: true }); +} + +boot(); diff --git a/web/bridge.py b/web/bridge.py index 28bcd5d..b9c8647 100644 --- a/web/bridge.py +++ b/web/bridge.py @@ -1,307 +1,307 @@ -"""The web page's Python: the same vaseinator package as the desktop app, run in the browser by Pyodide. - -The page's workers call these functions with settings as JSON text and get JSON text back - and, for a -built vase, its mesh as raw bytes the page draws with three.js. The settings, the rules for which are shown -and what a change brings with it, the panel's report and the help are the desktop app's own. -""" - -from __future__ import annotations - -import base64 -import io -import json -import math -import os -import re -import tempfile - -import numpy as np - -from vaseinator import __version__, sections -from vaseinator.choices import (COLOURS, MANY_COLOURS, NO_PATTERN, PATTERNS, SHAPES_FROM_ABOVE, TISSUE_HOLES, pattern_of, - twist_words) -from vaseinator.expr import EXAMPLES -from vaseinator.geometry import TOLERANCE, VaseDesign, fit_head, require_exportable -from vaseinator.lid import Lid -from vaseinator.params import (CIRCLE, DRAWN, EVENLY, FAKE_VASE, FILE_FORMAT, FILE_VERSION, ONE_COLOUR, OWN, - OWN_PROFILE, POLYGON, REAL_VASE, SAME, TISSUE_OVAL, TISSUE_ROUND, VaseParams) -from vaseinator.presets import GALLERY, PRINTER_KEYS, gallery_design, plain_design, slug, surprise -from vaseinator.report import ABOUT, DRAWING_HELP, PRINTING_HELP, function_help, info_html, lid_steps, print_steps -from vaseinator.settings import COLOUR_STYLES, LIVE_KEYS, REAL_COLOUR_STYLES, SPEC, TEXT_KEYS, change, wanted -from vaseinator.shapes import PALETTES, PROFILES, SHAPE_LABELS, pchip, shape_table -from vaseinator.workspace import Workspace, locked_surprise, comparison, calibration_parts, CALIBRATION_HELP -from vaseinator.stl import write_stl -from vaseinator.threemf import write_3mf - -SLICES = 14 # layers drawn in the from-above picture, as the desktop app draws them - - -def _report(d, errors, warnings) -> str: - """The desktop app's report, without its own style sheet: the page styles it.""" - return re.sub(r"^", "", info_html(d, errors, warnings), flags=re.S) - - -def _params(text: str) -> VaseParams: - return VaseParams.from_dict(json.loads(text)) - - -def _dump(p: VaseParams) -> str: - return json.dumps(p.to_dict()) - - -def _shown(p: VaseParams) -> list: - return [row[0] for _, rows in SPEC for row in rows if wanted(p, row[0])] - - -# ------------------------------------------------------------------ the choices -def _spec(): - """Every setting, by group, as the Advanced panel lists them.""" - groups = [] - for title, items in SPEC: - rows = [] - for key, label, kind, lo, hi, step, extra, tip in items: - row = dict(key=key, label=label, kind=kind, tip=tip, extra=extra) - if kind == "choice": - row["choices"] = [[v, t] for v, t in lo] - elif kind == "curve": - row["curve"] = lo - else: - row.update(lo=lo, hi=hi, step=step) - rows.append(row) - groups.append(dict(title=title, rows=rows)) - return groups - - -def start() -> str: - """Everything the page needs: the gallery, the choices and pictures, every setting, the help.""" - def above(settings): - r = shape_table(settings.get("shape1", CIRCLE), settings.get("count1", 5), settings.get("depth1", 0.0), - settings.get("sharp1", 0.5)) - a = np.arange(len(r)) * (2 * math.pi / len(r)) + math.radians(settings.get("turn1", 0.0)) - step = max(len(r) // 160, 1) - return [[round(float(rr * math.cos(t)), 4), round(float(rr * math.sin(t)), 4)] - for rr, t in zip(r[::step], a[::step])] - - def side(points): - pts = np.asarray(points, dtype=float) - hs = np.linspace(0.0, 1.0, 40) - d = np.clip(pchip(pts[:, 0], pts[:, 1], hs), 0.05, None) - return [round(float(v), 4) for v in d / d.max()] - - palettes = dict(PALETTES) - return json.dumps(dict( - version=__version__, - gallery=[dict(title=t, about=about, slug=slug(t), worn=bool(ch.get("worn", False))) - for t, about, ch in GALLERY], - shapes=[dict(name=n, settings=s, outline=above(s)) for n, s in SHAPES_FROM_ABOVE], - profiles=[dict(name=full.split(" (")[0], full=full, widths=side(pts), points=[list(q) for q in pts]) - for full, pts in PROFILES], - patterns=[dict(name=n, settings=s) for n, s in PATTERNS], - tissue_holes=[dict(name=n, value=v) for n, v in TISSUE_HOLES], - no_pattern=NO_PATTERN, - colours=[dict(name=n, colour=c) for n, c in COLOURS], - many_colours=[dict(name=n, palette=pal, settings=s, colours=list(palettes[pal])) - for n, pal, s in MANY_COLOURS], - palettes=[dict(name=n, colours=list(c)) for n, c in PALETTES], - printer_keys=list(PRINTER_KEYS), - spec=_spec(), live_keys=sorted(LIVE_KEYS), text_keys=sorted(TEXT_KEYS), - colour_styles=[[v, t] for v, t in COLOUR_STYLES], - real_colour_styles=[[v, t] for v, t in REAL_COLOUR_STYLES], - outlines=[dict(name=n, points=[list(q) for q in pts], repeat=r) for n, pts, r in sections.OUTLINES], - examples=[[n, f] for n, f in EXAMPLES], - shape_labels={str(k): list(v) for k, v in SHAPE_LABELS.items()}, - help=dict(printing=PRINTING_HELP, drawing=DRAWING_HELP, about=ABOUT, function=function_help()), - constants=dict(SAME=SAME, CIRCLE=CIRCLE, POLYGON=POLYGON, DRAWN=DRAWN, OWN=OWN, OWN_PROFILE=OWN_PROFILE, - EVENLY=EVENLY, REAL_VASE=REAL_VASE, FAKE_VASE=FAKE_VASE, ONE_COLOUR=ONE_COLOUR, - TISSUE_OVAL=TISSUE_OVAL, TISSUE_ROUND=TISSUE_ROUND), - defaults=VaseParams().to_dict(), - plain=plain_design().to_dict(), - )) - - -def shown(text: str) -> str: - """Which settings the Advanced panel shows for this design.""" - return json.dumps(_shown(_params(text))) - - -def apply(text: str, key: str, value: str) -> str: - """One change, as the panel makes it, with what follows from it (the desktop app's own rules).""" - p = _params(text) - touched, message = change(p, key, json.loads(value)) - p = VaseParams.from_dict(p.to_dict()) - return json.dumps(dict(params=p.to_dict(), touched=touched, message=message, shown=_shown(p))) - - -def design_of(title: str, keep: str) -> str: - """A ready-made design (or the plain cylinder), with the printer settings of ``keep``.""" - k = _params(keep) - return _dump(plain_design(k) if title == "Plain cylinder" else gallery_design(title, keep=k)) - - -def surprise_me(keep: str, seed: int, locks: str = "[]") -> str: - return _dump(locked_surprise(_params(keep), int(seed), json.loads(locks))) - - -def fit(text: str, head_mm: float) -> str: - """The design scaled to fit a head this many mm round.""" - return _dump(fit_head(_params(text), float(head_mm))) - - -def trace(pixels: str, width: int, height: int) -> str: - """Control points tracing the outline drawn in a picture: its RGBA pixels, base64.""" - rgba = np.frombuffer(base64.b64decode(pixels), dtype=np.uint8).reshape(int(height), int(width), 4) - return json.dumps(sections.trace_rgba(rgba)) - - -# ------------------------------------------------------------------ building it -def _gradient(colours, phase): - """The blend the 3D view uses for a gradient spool, for each phase.""" - ring = np.asarray(colours, dtype=float) - n = len(ring) - ph = np.mod(phase, 1.0) * n - k = np.floor(ph).astype(int) % n - t = ph - np.floor(ph) - t = t * t * (3.0 - 2.0 * t) - return ring[k] * (1.0 - t)[:, None] + ring[(k + 1) % n] * t[:, None] - - -def _colours(d: VaseDesign, z: np.ndarray) -> np.ndarray: - """The colour of each height, as the desktop app's 3D view shows it: bands, or a gradient spool.""" - cols = d.p.colors() - if d.gradient: - return _gradient([cols[k] for k in d.gradient_fils], d.colour_phase(z)) - starts = np.array([b[0] for b in d.bands]) - band_cols = np.array([cols[f] for _, f in d.bands], dtype=float) - idx = np.clip(np.searchsorted(starts, z, side="right") - 1, 0, len(starts) - 1) - return band_cols[idx] - - -def _plots(d: VaseDesign) -> dict: - """What the side and from-above pictures under the 3D view draw, as the desktop app draws them.""" - z = np.asarray(d.profile_z, dtype=float) - step = max(1, len(z) // 400) - keep = np.unique(np.concatenate([np.arange(0, len(z), step), [len(z) - 1]])) - lim = d.p.max_overhang + TOLERANCE - bad = np.nonzero(d.band_lean > lim)[0] - bad_z = (z[np.minimum(bad, len(z) - 1)] if d.rows is None - else np.array([d.rows[min(b, len(d.rows) - 1)][0] for b in bad])) - moved = z[d.row_moved > 0.05] if d.softened > 0.05 and d.row_moved is not None else np.zeros(0) - heights = [min(max(d.H * k / SLICES, 0.02), d.H - 0.02) for k in range(SLICES + 1)] - slices = [np.round(d.contour(zc)[:, :, :2], 2).reshape(-1).tolist() for zc in heights] - return dict(z=np.round(z[keep], 3).tolist(), rmax=np.round(d.profile_max[keep], 3).tolist(), - rmin=np.round(d.profile_min[keep], 3).tolist(), colours=np.round(_colours(d, z[keep]), 4).tolist(), - zb=float(d.zb), bad=np.round(bad_z, 2).tolist(), moved=np.round(moved[::step], 2).tolist(), - slices=slices, slice_colours=np.round(_colours(d, np.array(heights)), 4).tolist(), - half=0.5 * float(max(d.size[0], d.size[1], 1.0)), height=float(d.H), widest=float(d.widest)) - - -def build(text: str, draft: bool): - """Build the vase. Returns (what the page shows about it, as JSON; positions; triangles; colours; each - corner's steepest lean) - the last four as raw float32 / uint32 bytes.""" - p = _params(text) - d = VaseDesign(p, draft=bool(draft)) - meta = dict(errors=d.errors, warnings=d.warnings) - if d.errors: - meta["report"] = _report(None, d.errors, d.warnings) - return json.dumps(meta), b"", b"", b"", b"" - verts = np.asarray(d.shell_verts, dtype=np.float64) - faces = np.asarray(d.shell_faces, dtype=np.int64) - lean = np.full(len(verts), -1.0) - np.maximum.at(lean, faces.ravel(), np.repeat(np.asarray(d.shell_lean, dtype=float), 3)) - fz, fa = np.asarray(d.filament_z, dtype=float), np.asarray(d.filament_at, dtype=float) - pick = np.unique(np.linspace(0, len(fz) - 1, min(len(fz), 300)).astype(int)) - meta.update( - info=d.info(), notes=d.notes, draft=bool(draft), height=float(d.H), size=[float(s) for s in d.size], - widest=float(max(v for _, v in p.silhouette)), head=round(float(d.head_fit) / 10.0, 1), - worn=bool(d.p.worn), pattern=pattern_of(d.p), - twist_words=twist_words(d.p.twist) if d.p.twist_style != OWN else "Your own curve", - ok=bool(d.worst <= d.p.max_overhang + TOLERANCE), look=int(d.p.look), normal=bool(d.normal), - triangles=int(len(faces)), bed=[float(d.p.bed_x), float(d.p.bed_y), float(d.p.printer_height)], - limit=float(d.p.max_overhang), report=_report(d, [], d.warnings), plan=d.colour_plan(), plots=_plots(d), - printing=dict(z=np.round(fz[pick], 3).tolist(), at=np.round(fa[pick], 2).tolist(), layers=int(d.layers), - seconds=float(d.seconds), grams=float(d.grams))) - colours = _colours(d, verts[:, 2]).astype(np.float32) - return (json.dumps(meta), verts.astype(np.float32).tobytes(), faces.astype(np.uint32).tobytes(), - colours.tobytes(), lean.astype(np.float32).tobytes()) - - -# ------------------------------------------------------------------ files -def _name(p: VaseParams) -> str: - name = "".join(ch if ch.isalnum() or ch in "-_ " else "_" for ch in p.name).strip() - return name or "vase" - - -def export_3mf(text: str): - """The vase for the slicer, as the desktop app's Save for printing makes it: a part per band of colour. - Returns (the file, the steps to print it, as HTML).""" - p = _params(text) - d = VaseDesign(p) - require_exportable(d) - colors = d.p.colors() - parts = d.export_parts() - base = _name(p) - many = len(parts) > 1 - named = [(f"{base} band {k + 1}" if many else base, v, f, fil, colors[fil]) for k, (fil, v, f) in enumerate(parts)] - path = os.path.join(tempfile.gettempdir(), "vase.3mf") - write_3mf(path, base, named, (p.bed_x / 2.0, p.bed_y / 2.0, 0.0)) - with open(path, "rb") as fh: - data = fh.read() - steps = print_steps(d, f"{base}.3mf", len(parts)) - steps = re.sub(r"^

Saved .*?

", "", steps, flags=re.S) # saved by the button above the steps - return data, steps.replace(" (the button below, or File â–¸ Import)", " (File â–¸ Import)") - - -def export_lid(text: str): - """A tissue box cover's lid, made to fit it, as the desktop app's Save the lid for printing makes it. - Returns (the file, the steps to print it and put it on, as HTML).""" - p = _params(text) - d = VaseDesign(p) - require_exportable(d) - lid = Lid(d) - require_exportable(lid) - base = _name(p) + " lid" - path = os.path.join(tempfile.gettempdir(), "lid.3mf") - write_3mf(path, base, [(base, lid.verts, lid.faces, 1, d.p.colors()[1])], (p.bed_x / 2.0, p.bed_y / 2.0, 0.0)) - with open(path, "rb") as fh: - data = fh.read() - steps = re.sub(r"^

Saved .*?

", "", lid_steps(lid, f"{base}.3mf"), flags=re.S) - return data, steps.replace(" (the button below, or File â–¸ Import)", " (File â–¸ Import)") - - -def export_stl(text: str) -> bytes: - p = _params(text) - d = VaseDesign(p) - require_exportable(d) - path = os.path.join(tempfile.gettempdir(), "vase.stl") - write_stl(path, *d.export_mesh(), _name(p)) - with open(path, "rb") as fh: - return fh.read() - - -def save_vase(text: str) -> str: - """A .vase design file, the same as the desktop app saves.""" - return json.dumps({"format": FILE_FORMAT, "version": FILE_VERSION, "params": _params(text).to_dict()}, indent=2) - - -def open_vase(text: str) -> str: - """The settings from a .vase design file, made safe.""" - data = json.load(io.StringIO(text)) - if data.get("format") != FILE_FORMAT: - raise ValueError("That is not a Vaseinator design file.") - return _dump(VaseParams.from_dict(data.get("params", {}))) - - -def workspace_action(state: str, text: str, op: str, payload: str = "{}") -> str: - return json.dumps(Workspace(json.loads(state) if state else None).command(op, _params(text), json.loads(payload))) - - -def compare_design(text: str) -> str: - return json.dumps(comparison(VaseDesign(_params(text)))) - - -def export_calibration(text: str): - p = _params(text) - path = os.path.join(tempfile.gettempdir(), "calibration.3mf") - write_3mf(path, "Clearance samples", calibration_parts(), (p.bed_x/2, p.bed_y/2, 0)) - with open(path, "rb") as fh: - return fh.read(), "

" + CALIBRATION_HELP + "

" +"""The web page's Python: the same vaseinator package as the desktop app, run in the browser by Pyodide. + +The page's workers call these functions with settings as JSON text and get JSON text back - and, for a +built vase, its mesh as raw bytes the page draws with three.js. The settings, the rules for which are shown +and what a change brings with it, the panel's report and the help are the desktop app's own. +""" + +from __future__ import annotations + +import base64 +import io +import json +import math +import os +import re +import tempfile + +import numpy as np + +from vaseinator import __version__, sections +from vaseinator.choices import (COLOURS, MANY_COLOURS, NO_PATTERN, PATTERNS, SHAPES_FROM_ABOVE, TISSUE_HOLES, pattern_of, + twist_words) +from vaseinator.expr import EXAMPLES +from vaseinator.geometry import TOLERANCE, VaseDesign, fit_head, require_exportable +from vaseinator.lid import Lid +from vaseinator.params import (CIRCLE, DRAWN, EVENLY, FAKE_VASE, FILE_FORMAT, FILE_VERSION, ONE_COLOUR, OWN, + OWN_PROFILE, POLYGON, REAL_VASE, SAME, TISSUE_OVAL, TISSUE_ROUND, VaseParams) +from vaseinator.presets import GALLERY, PRINTER_KEYS, gallery_design, plain_design, slug, surprise +from vaseinator.report import ABOUT, DRAWING_HELP, PRINTING_HELP, function_help, info_html, lid_steps, print_steps +from vaseinator.settings import COLOUR_STYLES, LIVE_KEYS, REAL_COLOUR_STYLES, SPEC, TEXT_KEYS, change, wanted +from vaseinator.shapes import PALETTES, PROFILES, SHAPE_LABELS, pchip, shape_table +from vaseinator.workspace import Workspace, locked_surprise, comparison, calibration_parts, CALIBRATION_HELP +from vaseinator.stl import write_stl +from vaseinator.threemf import write_3mf + +SLICES = 14 # layers drawn in the from-above picture, as the desktop app draws them + + +def _report(d, errors, warnings) -> str: + """The desktop app's report, without its own style sheet: the page styles it.""" + return re.sub(r"^", "", info_html(d, errors, warnings), flags=re.S) + + +def _params(text: str) -> VaseParams: + return VaseParams.from_dict(json.loads(text)) + + +def _dump(p: VaseParams) -> str: + return json.dumps(p.to_dict()) + + +def _shown(p: VaseParams) -> list: + return [row[0] for _, rows in SPEC for row in rows if wanted(p, row[0])] + + +# ------------------------------------------------------------------ the choices +def _spec(): + """Every setting, by group, as the Advanced panel lists them.""" + groups = [] + for title, items in SPEC: + rows = [] + for key, label, kind, lo, hi, step, extra, tip in items: + row = dict(key=key, label=label, kind=kind, tip=tip, extra=extra) + if kind == "choice": + row["choices"] = [[v, t] for v, t in lo] + elif kind == "curve": + row["curve"] = lo + else: + row.update(lo=lo, hi=hi, step=step) + rows.append(row) + groups.append(dict(title=title, rows=rows)) + return groups + + +def start() -> str: + """Everything the page needs: the gallery, the choices and pictures, every setting, the help.""" + def above(settings): + r = shape_table(settings.get("shape1", CIRCLE), settings.get("count1", 5), settings.get("depth1", 0.0), + settings.get("sharp1", 0.5)) + a = np.arange(len(r)) * (2 * math.pi / len(r)) + math.radians(settings.get("turn1", 0.0)) + step = max(len(r) // 160, 1) + return [[round(float(rr * math.cos(t)), 4), round(float(rr * math.sin(t)), 4)] + for rr, t in zip(r[::step], a[::step])] + + def side(points): + pts = np.asarray(points, dtype=float) + hs = np.linspace(0.0, 1.0, 40) + d = np.clip(pchip(pts[:, 0], pts[:, 1], hs), 0.05, None) + return [round(float(v), 4) for v in d / d.max()] + + palettes = dict(PALETTES) + return json.dumps(dict( + version=__version__, + gallery=[dict(title=t, about=about, slug=slug(t), worn=bool(ch.get("worn", False))) + for t, about, ch in GALLERY], + shapes=[dict(name=n, settings=s, outline=above(s)) for n, s in SHAPES_FROM_ABOVE], + profiles=[dict(name=full.split(" (")[0], full=full, widths=side(pts), points=[list(q) for q in pts]) + for full, pts in PROFILES], + patterns=[dict(name=n, settings=s) for n, s in PATTERNS], + tissue_holes=[dict(name=n, value=v) for n, v in TISSUE_HOLES], + no_pattern=NO_PATTERN, + colours=[dict(name=n, colour=c) for n, c in COLOURS], + many_colours=[dict(name=n, palette=pal, settings=s, colours=list(palettes[pal])) + for n, pal, s in MANY_COLOURS], + palettes=[dict(name=n, colours=list(c)) for n, c in PALETTES], + printer_keys=list(PRINTER_KEYS), + spec=_spec(), live_keys=sorted(LIVE_KEYS), text_keys=sorted(TEXT_KEYS), + colour_styles=[[v, t] for v, t in COLOUR_STYLES], + real_colour_styles=[[v, t] for v, t in REAL_COLOUR_STYLES], + outlines=[dict(name=n, points=[list(q) for q in pts], repeat=r) for n, pts, r in sections.OUTLINES], + examples=[[n, f] for n, f in EXAMPLES], + shape_labels={str(k): list(v) for k, v in SHAPE_LABELS.items()}, + help=dict(printing=PRINTING_HELP, drawing=DRAWING_HELP, about=ABOUT, function=function_help()), + constants=dict(SAME=SAME, CIRCLE=CIRCLE, POLYGON=POLYGON, DRAWN=DRAWN, OWN=OWN, OWN_PROFILE=OWN_PROFILE, + EVENLY=EVENLY, REAL_VASE=REAL_VASE, FAKE_VASE=FAKE_VASE, ONE_COLOUR=ONE_COLOUR, + TISSUE_OVAL=TISSUE_OVAL, TISSUE_ROUND=TISSUE_ROUND), + defaults=VaseParams().to_dict(), + plain=plain_design().to_dict(), + )) + + +def shown(text: str) -> str: + """Which settings the Advanced panel shows for this design.""" + return json.dumps(_shown(_params(text))) + + +def apply(text: str, key: str, value: str) -> str: + """One change, as the panel makes it, with what follows from it (the desktop app's own rules).""" + p = _params(text) + touched, message = change(p, key, json.loads(value)) + p = VaseParams.from_dict(p.to_dict()) + return json.dumps(dict(params=p.to_dict(), touched=touched, message=message, shown=_shown(p))) + + +def design_of(title: str, keep: str) -> str: + """A ready-made design (or the plain cylinder), with the printer settings of ``keep``.""" + k = _params(keep) + return _dump(plain_design(k) if title == "Plain cylinder" else gallery_design(title, keep=k)) + + +def surprise_me(keep: str, seed: int, locks: str = "[]") -> str: + return _dump(locked_surprise(_params(keep), int(seed), json.loads(locks))) + + +def fit(text: str, head_mm: float) -> str: + """The design scaled to fit a head this many mm round.""" + return _dump(fit_head(_params(text), float(head_mm))) + + +def trace(pixels: str, width: int, height: int) -> str: + """Control points tracing the outline drawn in a picture: its RGBA pixels, base64.""" + rgba = np.frombuffer(base64.b64decode(pixels), dtype=np.uint8).reshape(int(height), int(width), 4) + return json.dumps(sections.trace_rgba(rgba)) + + +# ------------------------------------------------------------------ building it +def _gradient(colours, phase): + """The blend the 3D view uses for a gradient spool, for each phase.""" + ring = np.asarray(colours, dtype=float) + n = len(ring) + ph = np.mod(phase, 1.0) * n + k = np.floor(ph).astype(int) % n + t = ph - np.floor(ph) + t = t * t * (3.0 - 2.0 * t) + return ring[k] * (1.0 - t)[:, None] + ring[(k + 1) % n] * t[:, None] + + +def _colours(d: VaseDesign, z: np.ndarray) -> np.ndarray: + """The colour of each height, as the desktop app's 3D view shows it: bands, or a gradient spool.""" + cols = d.p.colors() + if d.gradient: + return _gradient([cols[k] for k in d.gradient_fils], d.colour_phase(z)) + starts = np.array([b[0] for b in d.bands]) + band_cols = np.array([cols[f] for _, f in d.bands], dtype=float) + idx = np.clip(np.searchsorted(starts, z, side="right") - 1, 0, len(starts) - 1) + return band_cols[idx] + + +def _plots(d: VaseDesign) -> dict: + """What the side and from-above pictures under the 3D view draw, as the desktop app draws them.""" + z = np.asarray(d.profile_z, dtype=float) + step = max(1, len(z) // 400) + keep = np.unique(np.concatenate([np.arange(0, len(z), step), [len(z) - 1]])) + lim = d.p.max_overhang + TOLERANCE + bad = np.nonzero(d.band_lean > lim)[0] + bad_z = (z[np.minimum(bad, len(z) - 1)] if d.rows is None + else np.array([d.rows[min(b, len(d.rows) - 1)][0] for b in bad])) + moved = z[d.row_moved > 0.05] if d.softened > 0.05 and d.row_moved is not None else np.zeros(0) + heights = [min(max(d.H * k / SLICES, 0.02), d.H - 0.02) for k in range(SLICES + 1)] + slices = [np.round(d.contour(zc)[:, :, :2], 2).reshape(-1).tolist() for zc in heights] + return dict(z=np.round(z[keep], 3).tolist(), rmax=np.round(d.profile_max[keep], 3).tolist(), + rmin=np.round(d.profile_min[keep], 3).tolist(), colours=np.round(_colours(d, z[keep]), 4).tolist(), + zb=float(d.zb), bad=np.round(bad_z, 2).tolist(), moved=np.round(moved[::step], 2).tolist(), + slices=slices, slice_colours=np.round(_colours(d, np.array(heights)), 4).tolist(), + half=0.5 * float(max(d.size[0], d.size[1], 1.0)), height=float(d.H), widest=float(d.widest)) + + +def build(text: str, draft: bool): + """Build the vase. Returns (what the page shows about it, as JSON; positions; triangles; colours; each + corner's steepest lean) - the last four as raw float32 / uint32 bytes.""" + p = _params(text) + d = VaseDesign(p, draft=bool(draft)) + meta = dict(errors=d.errors, warnings=d.warnings) + if d.errors: + meta["report"] = _report(None, d.errors, d.warnings) + return json.dumps(meta), b"", b"", b"", b"" + verts = np.asarray(d.shell_verts, dtype=np.float64) + faces = np.asarray(d.shell_faces, dtype=np.int64) + lean = np.full(len(verts), -1.0) + np.maximum.at(lean, faces.ravel(), np.repeat(np.asarray(d.shell_lean, dtype=float), 3)) + fz, fa = np.asarray(d.filament_z, dtype=float), np.asarray(d.filament_at, dtype=float) + pick = np.unique(np.linspace(0, len(fz) - 1, min(len(fz), 300)).astype(int)) + meta.update( + info=d.info(), notes=d.notes, draft=bool(draft), height=float(d.H), size=[float(s) for s in d.size], + widest=float(max(v for _, v in p.silhouette)), head=round(float(d.head_fit) / 10.0, 1), + worn=bool(d.p.worn), pattern=pattern_of(d.p), + twist_words=twist_words(d.p.twist) if d.p.twist_style != OWN else "Your own curve", + ok=bool(d.worst <= d.p.max_overhang + TOLERANCE), look=int(d.p.look), normal=bool(d.normal), + triangles=int(len(faces)), bed=[float(d.p.bed_x), float(d.p.bed_y), float(d.p.printer_height)], + limit=float(d.p.max_overhang), report=_report(d, [], d.warnings), plan=d.colour_plan(), plots=_plots(d), + printing=dict(z=np.round(fz[pick], 3).tolist(), at=np.round(fa[pick], 2).tolist(), layers=int(d.layers), + seconds=float(d.seconds), grams=float(d.grams))) + colours = _colours(d, verts[:, 2]).astype(np.float32) + return (json.dumps(meta), verts.astype(np.float32).tobytes(), faces.astype(np.uint32).tobytes(), + colours.tobytes(), lean.astype(np.float32).tobytes()) + + +# ------------------------------------------------------------------ files +def _name(p: VaseParams) -> str: + name = "".join(ch if ch.isalnum() or ch in "-_ " else "_" for ch in p.name).strip() + return name or "vase" + + +def export_3mf(text: str): + """The vase for the slicer, as the desktop app's Save for printing makes it: a part per band of colour. + Returns (the file, the steps to print it, as HTML).""" + p = _params(text) + d = VaseDesign(p) + require_exportable(d) + colors = d.p.colors() + parts = d.export_parts() + base = _name(p) + many = len(parts) > 1 + named = [(f"{base} band {k + 1}" if many else base, v, f, fil, colors[fil]) for k, (fil, v, f) in enumerate(parts)] + path = os.path.join(tempfile.gettempdir(), "vase.3mf") + write_3mf(path, base, named, (p.bed_x / 2.0, p.bed_y / 2.0, 0.0)) + with open(path, "rb") as fh: + data = fh.read() + steps = print_steps(d, f"{base}.3mf", len(parts)) + steps = re.sub(r"^

Saved .*?

", "", steps, flags=re.S) # saved by the button above the steps + return data, steps.replace(" (the button below, or File â–¸ Import)", " (File â–¸ Import)") + + +def export_lid(text: str): + """A tissue box cover's lid, made to fit it, as the desktop app's Save the lid for printing makes it. + Returns (the file, the steps to print it and put it on, as HTML).""" + p = _params(text) + d = VaseDesign(p) + require_exportable(d) + lid = Lid(d) + require_exportable(lid) + base = _name(p) + " lid" + path = os.path.join(tempfile.gettempdir(), "lid.3mf") + write_3mf(path, base, [(base, lid.verts, lid.faces, 1, d.p.colors()[1])], (p.bed_x / 2.0, p.bed_y / 2.0, 0.0)) + with open(path, "rb") as fh: + data = fh.read() + steps = re.sub(r"^

Saved .*?

", "", lid_steps(lid, f"{base}.3mf"), flags=re.S) + return data, steps.replace(" (the button below, or File â–¸ Import)", " (File â–¸ Import)") + + +def export_stl(text: str) -> bytes: + p = _params(text) + d = VaseDesign(p) + require_exportable(d) + path = os.path.join(tempfile.gettempdir(), "vase.stl") + write_stl(path, *d.export_mesh(), _name(p)) + with open(path, "rb") as fh: + return fh.read() + + +def save_vase(text: str) -> str: + """A .vase design file, the same as the desktop app saves.""" + return json.dumps({"format": FILE_FORMAT, "version": FILE_VERSION, "params": _params(text).to_dict()}, indent=2) + + +def open_vase(text: str) -> str: + """The settings from a .vase design file, made safe.""" + data = json.load(io.StringIO(text)) + if data.get("format") != FILE_FORMAT: + raise ValueError("That is not a Vaseinator design file.") + return _dump(VaseParams.from_dict(data.get("params", {}))) + + +def workspace_action(state: str, text: str, op: str, payload: str = "{}") -> str: + return json.dumps(Workspace(json.loads(state) if state else None).command(op, _params(text), json.loads(payload))) + + +def compare_design(text: str) -> str: + return json.dumps(comparison(VaseDesign(_params(text)))) + + +def export_calibration(text: str): + p = _params(text) + path = os.path.join(tempfile.gettempdir(), "calibration.3mf") + write_3mf(path, "Clearance samples", calibration_parts(), (p.bed_x/2, p.bed_y/2, 0)) + with open(path, "rb") as fh: + return fh.read(), "

" + CALIBRATION_HELP + "

" diff --git a/web/index.html b/web/index.html index b7f886e..cb61192 100644 --- a/web/index.html +++ b/web/index.html @@ -1,254 +1,254 @@ - - - - - - Vaseinator - - - - - - -
- -
-

Vaseinator

-

Vases, lampshades, masks and helmets that print without supports

-
- - -
- -
- - -
- -
-
- - - - -
- - - -
- -

Drag to turn · wheel or pinch to zoom · right-drag to move

-
- -

Getting Vaseinator ready…

-

The first time takes a little while: it brings Python and numpy into your browser, - so the vase is built right here - nothing is sent anywhere.

-
-
- -
-
-
From the side
-
Layers from above
-
-
-
-

-
-
-
- - - - - -
-

Choose a design

- -
-

Click one to start from it. Your printer settings are kept, and Undo takes you back.

-
-
- - -
-

How to print it

- -
- Save -
-
-
- - -
-

Help

- -
-
- -
- - -
-

Drawing the cross-section

-
- - - - - -
-
- -
- - - - + + + + + + Vaseinator + + + + + + +
+ +
+

Vaseinator

+

Vases, lampshades, masks and helmets that print without supports

+
+ + +
+ +
+ + +
+ +
+
+ + + + +
+ + + +
+ +

Drag to turn · wheel or pinch to zoom · right-drag to move

+
+ +

Getting Vaseinator ready…

+

The first time takes a little while: it brings Python and numpy into your browser, + so the vase is built right here - nothing is sent anywhere.

+
+
+ +
+
+
From the side
+
Layers from above
+
+
+
+

+
+
+
+ + + + + +
+

Choose a design

+ +
+

Click one to start from it. Your printer settings are kept, and Undo takes you back.

+
+
+ + +
+

How to print it

+ +
+ Save +
+
+
+ + +
+

Help

+ +
+
+ +
+ + +
+

Drawing the cross-section

+
+ + + + + +
+
+ +
+ + + + diff --git a/web/style.css b/web/style.css index 485c184..2e9900d 100644 --- a/web/style.css +++ b/web/style.css @@ -1,413 +1,413 @@ -/* Vaseinator on the web: the desktop app, dark like it (light if the browser or the View menu asks). */ -:root { - --bg: #1b1e23; - --panel: #23272e; - --raised: #2b3038; - --well: #1a1c21; - --grid: #2a2e36; - --line: #3b4049; - --text: #e1e6ec; - --muted: #9aa3ad; - --accent: #f0a356; - --accent-ink: #1b1e23; - --ok: #6fdc8c; - --bad: #ff6b6b; - --warn: #f2c14e; - --view-top: #3a4150; - --view-bottom: #15171b; - --scale: 1; - color-scheme: dark; -} -@media (prefers-color-scheme: light) { - :root:not([data-theme="dark"]) { - --bg: #eef0f3; - --panel: #ffffff; - --raised: #f3f4f6; - --well: #f7f8fa; - --grid: #e3e6ea; - --line: #d0d5db; - --text: #1d2228; - --muted: #5c6670; - --accent: #c46a1e; - --accent-ink: #ffffff; - --ok: #1d8a45; - --bad: #c62828; - --warn: #9a6a00; - --view-top: #dfe5ee; - --view-bottom: #aab3c0; - color-scheme: light; - } -} -:root[data-theme="light"] { - --bg: #eef0f3; - --panel: #ffffff; - --raised: #f3f4f6; - --well: #f7f8fa; - --grid: #e3e6ea; - --line: #d0d5db; - --text: #1d2228; - --muted: #5c6670; - --accent: #c46a1e; - --accent-ink: #ffffff; - --ok: #1d8a45; - --bad: #c62828; - --warn: #9a6a00; - --view-top: #dfe5ee; - --view-bottom: #aab3c0; - color-scheme: light; -} - -* { box-sizing: border-box; } -[hidden] { display: none !important; } -html { font-size: calc(15px * var(--scale)); } -body { - margin: 0; - background: var(--bg); - color: var(--text); - font-family: "Segoe UI", system-ui, -apple-system, Roboto, sans-serif; - height: 100dvh; - display: flex; - flex-direction: column; -} -button, input, select, textarea { font: inherit; color: inherit; } -button { - background: var(--raised); - border: 1px solid var(--line); - border-radius: 6px; - padding: 0.4rem 0.8rem; - cursor: pointer; -} -button:hover:not(:disabled) { border-color: var(--accent); } -button:disabled { opacity: 0.45; cursor: default; } -button:focus-visible, input:focus-visible, a:focus-visible, summary:focus-visible, select:focus-visible, -textarea:focus-visible, canvas:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} -button[aria-pressed="true"] { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); } -input[type="number"], input[type="text"], select, textarea { - padding: 0.3rem 0.4rem; - border: 1px solid var(--line); - border-radius: 6px; - background: var(--raised); -} -kbd { margin-left: auto; padding-left: 1.5rem; color: var(--muted); font: inherit; font-size: 0.8rem; } - -/* ---------------------------------------------------------------- header and menus */ -header { - display: flex; - align-items: center; - gap: 0.8rem; - padding: 0.4rem 1rem; - border-bottom: 1px solid var(--line); - background: var(--panel); - position: relative; - z-index: 5; -} -.logo { width: 34px; height: 34px; } -.brand h1 { margin: 0; font-size: 1.2rem; color: var(--accent); } -.brand p { margin: 0; color: var(--muted); font-size: 0.8rem; } -.menubar { display: flex; gap: 0.1rem; margin-left: 0.6rem; } -.menu { position: relative; } -.menu summary { - list-style: none; - cursor: pointer; - padding: 0.35rem 0.6rem; - border-radius: 6px; -} -.menu summary::-webkit-details-marker { display: none; } -.menu[open] summary, .menu summary:hover { background: var(--raised); } -.menu .items { - position: absolute; - top: 100%; - left: 0; - min-width: 15rem; - max-height: 75vh; - overflow-y: auto; - padding: 0.3rem; - background: var(--panel); - border: 1px solid var(--line); - border-radius: 8px; - box-shadow: 0 8px 24px rgb(0 0 0 / 0.35); - display: flex; - flex-direction: column; -} -.menu .items button { - display: flex; - align-items: center; - text-align: left; - border: none; - background: none; - padding: 0.4rem 0.6rem; -} -.menu .items button:hover { background: var(--raised); } -.menu hr { border: none; border-top: 1px solid var(--line); margin: 0.25rem 0; width: 100%; } -nav { margin-left: auto; display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap; } -nav a { - color: var(--text); - text-decoration: none; - border: 1px solid var(--line); - border-radius: 6px; - padding: 0.4rem 0.8rem; -} -nav a:hover { border-color: var(--accent); } - -/* ---------------------------------------------------------------- layout */ -main { - flex: 1; - min-height: 0; - display: grid; - grid-template-columns: minmax(310px, 400px) 1fr; - grid-template-rows: 1fr auto; - grid-template-areas: "panel viewer" "panel below"; -} -#panel { - grid-area: panel; - overflow-y: auto; - overflow-x: hidden; - padding: 0.6rem 1rem 1.5rem; - background: var(--panel); - border-right: 1px solid var(--line); -} -#viewer { grid-area: viewer; position: relative; min-height: 0; } -#below { - grid-area: below; - display: grid; - grid-template-columns: minmax(260px, 2fr) 3fr; - height: 34vh; - min-height: 210px; - border-top: 1px solid var(--line); - background: var(--panel); -} -#plots { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; padding: 0.4rem 0.6rem; min-height: 0; } -#plots figure { margin: 0; display: flex; flex-direction: column; min-height: 0; } -#plots figcaption { color: var(--muted); font-size: 0.82rem; margin-bottom: 0.2rem; } -#plots canvas { flex: 1; width: 100%; min-height: 0; border-radius: 6px; } -#info { overflow-y: auto; padding: 0.3rem 1rem 0.8rem; border-left: 1px solid var(--line); font-size: 0.9rem; } - -/* ---------------------------------------------------------------- the panel */ -.modes { display: grid; grid-template-columns: 1fr 1fr; margin-bottom: 0.7rem; } -.modes button { border-radius: 0; padding: 0.5rem; } -.modes button:first-child { border-radius: 6px 0 0 6px; } -.modes button:last-child { border-radius: 0 6px 6px 0; } -.modes button[aria-selected="true"] { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); } -#panel h2 { margin: 0 0 0.6rem; font-size: 1.3rem; color: var(--accent); } -#panel h3 { margin: 1.1rem 0 0.45rem; font-size: 1.05rem; color: var(--accent); } -.row { display: flex; gap: 0.5rem; align-items: center; } -.row > .big { flex: 1; } -.big { padding: 0.6rem 0.8rem; font-weight: 600; } -.wide { width: 100%; margin-top: 0.4rem; } -.numbers label { display: flex; align-items: center; gap: 0.35rem; flex: 1; } -.numbers input { width: 4.6rem; padding: 0.4rem; } -#boxrow .numbers { flex-wrap: wrap; row-gap: 0.4rem; } -#boxrow .numbers label { flex: 0 1 auto; } -#boxrow .numbers input { width: 3.6rem; } -.tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.4rem; } -.tile { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.2rem; - padding: 0.35rem 0.2rem 0.3rem; - font-size: 0.82rem; - line-height: 1.15; -} -.tile svg { width: 46px; height: 46px; } -.tile[aria-pressed="true"] { - background: color-mix(in srgb, var(--accent) 18%, var(--raised)); - color: var(--text); - border: 2px solid var(--accent); -} -#twist { flex: 1; accent-color: var(--accent); } -.words { min-width: 10rem; color: var(--muted); } -.swatches { display: flex; flex-wrap: wrap; gap: 0.35rem; } -.swatch { width: 2.3rem; height: 2.3rem; padding: 3px; border-radius: 50%; } -.swatch span { display: block; width: 100%; height: 100%; border-radius: 50%; overflow: hidden; } -.swatch[aria-pressed="true"] { border: 3px solid var(--accent); background: var(--raised); } -a.save { display: block; box-sizing: border-box; text-align: center; text-decoration: none; margin-top: 0.8rem; } -.save { - width: 100%; - margin-top: 1.4rem; - padding: 0.8rem; - font-size: 1.15rem; - font-weight: 700; - background: var(--accent); - color: var(--accent-ink); - border-color: var(--accent); -} -button.lid { width: 100%; margin-top: 0.5rem; padding: 0.6rem; font-weight: 600; } -.files { margin-top: 0.5rem; flex-wrap: wrap; } -.files button { flex: 1; font-size: 0.88rem; } -details#printer { margin-top: 1rem; border-top: 1px solid var(--line); padding-top: 0.6rem; } -details#printer summary { cursor: pointer; font-weight: 600; } -.hint { color: var(--muted); font-size: 0.85rem; margin: 0.4rem 0; } -.grid { display: grid; grid-template-columns: 1fr; gap: 0.35rem; } -.grid label { display: flex; align-items: center; justify-content: space-between; gap: 0.4rem; font-size: 0.9rem; } -.grid input { width: 5rem; margin-left: auto; } - -/* ---------------------------------------------------------------- Advanced: every setting */ -.namerow { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } -.namerow input { flex: 1; } -.group { border: 1px solid var(--line); border-radius: 8px; margin-top: 0.7rem; background: var(--bg); } -.group > summary { - cursor: pointer; - padding: 0.45rem 0.7rem; - font-weight: 600; - color: var(--accent); -} -.group .rows { - display: grid; - grid-template-columns: max-content minmax(0, 1fr); - gap: 0.35rem 0.6rem; - align-items: center; - padding: 0.2rem 0.7rem 0.7rem; -} -.rows > .label { text-align: right; font-size: 0.9rem; color: var(--muted); } -.rows > .control { display: flex; align-items: center; gap: 0.35rem; min-width: 0; } -.rows > .whole { grid-column: 1 / -1; display: flex; flex-direction: column; gap: 0.35rem; } -.control input[type="number"] { width: 6.5rem; } -.control select { max-width: 100%; min-width: 0; flex: 1; } -.control input[type="range"] { flex: 1; accent-color: var(--accent); } -.control input[type="color"] { width: 3rem; height: 1.9rem; padding: 0.1rem; border: 1px solid var(--line); border-radius: 6px; background: var(--raised); } -.control .unit, .control .none { color: var(--muted); font-size: 0.85rem; } -.control .dice { padding: 0.2rem 0.5rem; } -.whole canvas { width: 100%; border-radius: 6px; display: block; } -.whole textarea { width: 100%; min-height: 5.5rem; font-family: Consolas, "Cascadia Mono", monospace; font-size: 0.88rem; } -.whole .row { flex-wrap: wrap; } -.whole .row button, .whole .row select { font-size: 0.85rem; padding: 0.25rem 0.5rem; } -.whole .row select { flex: 1; min-width: 0; max-width: 100%; } -.outlinebox canvas { height: 300px; } - -/* ---------------------------------------------------------------- the 3D view */ -#viewer { background: linear-gradient(var(--view-top), var(--view-bottom)); } -#canvas { display: block; width: 100%; height: 100%; touch-action: none; } -.viewbar { - position: absolute; - top: 0.5rem; - left: 0.5rem; - right: 0.5rem; - display: flex; - gap: 0.4rem; - flex-wrap: wrap; - justify-content: flex-end; - pointer-events: none; -} -.viewbar > * { pointer-events: auto; } -.viewbar button { background: color-mix(in srgb, var(--panel) 88%, transparent); } -.viewbar button[aria-pressed="true"] { background: var(--accent); color: var(--accent-ink); } -.views { display: flex; margin-right: auto; } -.views button { border-radius: 0; } -.views button:first-child { border-radius: 6px 0 0 6px; } -.views button:last-child { border-radius: 0 6px 6px 0; } -.views button[aria-pressed="true"] { background: var(--accent); color: var(--accent-ink); } -.help { position: absolute; left: 0.8rem; bottom: 0.4rem; margin: 0; font-size: 0.82rem; color: var(--muted); - text-shadow: 0 0 4px var(--bg); } -#busy { - position: absolute; - top: 3.1rem; - left: 0.6rem; - padding: 0.35rem 0.7rem; - border-radius: 6px; - background: color-mix(in srgb, var(--panel) 85%, transparent); - font-size: 0.85rem; -} -.spinner { - display: inline-block; - width: 0.8rem; - height: 0.8rem; - border: 2px solid var(--muted); - border-top-color: var(--accent); - border-radius: 50%; - vertical-align: -2px; - animation: turn 0.9s linear infinite; -} -@keyframes turn { to { transform: rotate(360deg); } } -@media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 3s; } } -#loading { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - text-align: center; - padding: 2rem; - background: color-mix(in srgb, var(--bg) 92%, transparent); - z-index: 2; -} -#loading img { width: 72px; height: 72px; margin-bottom: 0.5rem; } -#loading p { max-width: 30rem; margin: 0.3rem 0; } -#loadingtext { color: var(--muted); } -#loading.failed #loadingtext { color: var(--bad); } - -/* ---------------------------------------------------------------- the report (the desktop app's own HTML) */ -#report h3 { margin: 0.5rem 0 0.3rem; color: var(--accent); font-size: 1rem; } -#report td { padding: 0.12rem 0.6rem 0.12rem 0; vertical-align: top; } -#report .k { color: var(--muted); } -#report td.k { white-space: nowrap; } -#report .e { color: var(--bad); } -#report .w { color: var(--warn); } -#report .ok { color: var(--ok); } -#report p { margin: 0.35rem 0; } -#report ul { padding-left: 1.2rem; margin: 0.3rem 0; } -.status { color: var(--accent); font-size: 0.88rem; min-height: 1.2em; margin: 0.4rem 0 0; } - -/* ---------------------------------------------------------------- dialogs */ -dialog { - border: 1px solid var(--line); - border-radius: 10px; - background: var(--panel); - color: var(--text); - padding: 1rem 1.2rem; - max-width: min(1100px, 94vw); - max-height: 90dvh; -} -dialog::backdrop { background: rgb(0 0 0 / 0.55); } -.dialoghead { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } -.dialoghead h2 { margin: 0; color: var(--accent); font-size: 1.25rem; } -.designs { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 0.6rem; margin-top: 0.5rem; } -.design { display: flex; flex-direction: column; align-items: center; gap: 0.3rem; padding: 0.4rem; } -.design img { width: 150px; height: 190px; object-fit: contain; } -.design b { font-size: 0.9rem; } -#steplist ol { padding-left: 1.4rem; } -#steplist li { margin: 0.35rem 0; } -#help { width: min(760px, 94vw); } -.helptext { max-height: 62dvh; overflow-y: auto; line-height: 1.45; } -.helptext code { color: var(--accent); } -.helptext td { padding: 2px 8px; vertical-align: top; } -#helpexamples { margin-top: 0.8rem; } -#helpexamples select { max-width: 24rem; } -dialog.full { - width: 96vw; - height: 94dvh; - max-width: none; - max-height: none; - display: none; - flex-direction: column; - gap: 0.6rem; -} -dialog.full[open] { display: flex; } -dialog.full canvas { flex: 1; width: 100%; min-height: 0; border-radius: 8px; touch-action: none; } -.tools { flex-wrap: wrap; } -.tools input { width: 4.5rem; } - -/* ---------------------------------------------------------------- narrow screens: one column */ -@media (max-width: 900px) { - body { height: auto; } - main { display: flex; flex-direction: column; } - #viewer { order: 1; height: 60vh; } - #panel { order: 2; border-right: none; border-top: 1px solid var(--line); overflow: visible; } - #below { order: 3; grid-template-columns: 1fr; height: auto; } - #plots { height: 220px; } - #info { border-left: none; border-top: 1px solid var(--line); } - .brand p { display: none; } - header { flex-wrap: wrap; } - .menubar { order: 3; width: 100%; margin-left: 0; } -} - -.library { width: min(960px, 92vw); max-height: 85vh; overflow: auto; } -.library label { display: inline-flex; gap: .35em; margin: .4em; } -.library button { margin: .25em; } -.library-entry { display: flex; flex-wrap: wrap; align-items: center; gap: .5em; padding: .5em; border-bottom: 1px solid #8885; } -.library-entry img { width: 65px; height: 65px; } -.library-entry span { flex: 1; min-width: 150px; } +/* Vaseinator on the web: the desktop app, dark like it (light if the browser or the View menu asks). */ +:root { + --bg: #1b1e23; + --panel: #23272e; + --raised: #2b3038; + --well: #1a1c21; + --grid: #2a2e36; + --line: #3b4049; + --text: #e1e6ec; + --muted: #9aa3ad; + --accent: #f0a356; + --accent-ink: #1b1e23; + --ok: #6fdc8c; + --bad: #ff6b6b; + --warn: #f2c14e; + --view-top: #3a4150; + --view-bottom: #15171b; + --scale: 1; + color-scheme: dark; +} +@media (prefers-color-scheme: light) { + :root:not([data-theme="dark"]) { + --bg: #eef0f3; + --panel: #ffffff; + --raised: #f3f4f6; + --well: #f7f8fa; + --grid: #e3e6ea; + --line: #d0d5db; + --text: #1d2228; + --muted: #5c6670; + --accent: #c46a1e; + --accent-ink: #ffffff; + --ok: #1d8a45; + --bad: #c62828; + --warn: #9a6a00; + --view-top: #dfe5ee; + --view-bottom: #aab3c0; + color-scheme: light; + } +} +:root[data-theme="light"] { + --bg: #eef0f3; + --panel: #ffffff; + --raised: #f3f4f6; + --well: #f7f8fa; + --grid: #e3e6ea; + --line: #d0d5db; + --text: #1d2228; + --muted: #5c6670; + --accent: #c46a1e; + --accent-ink: #ffffff; + --ok: #1d8a45; + --bad: #c62828; + --warn: #9a6a00; + --view-top: #dfe5ee; + --view-bottom: #aab3c0; + color-scheme: light; +} + +* { box-sizing: border-box; } +[hidden] { display: none !important; } +html { font-size: calc(15px * var(--scale)); } +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: "Segoe UI", system-ui, -apple-system, Roboto, sans-serif; + height: 100dvh; + display: flex; + flex-direction: column; +} +button, input, select, textarea { font: inherit; color: inherit; } +button { + background: var(--raised); + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.4rem 0.8rem; + cursor: pointer; +} +button:hover:not(:disabled) { border-color: var(--accent); } +button:disabled { opacity: 0.45; cursor: default; } +button:focus-visible, input:focus-visible, a:focus-visible, summary:focus-visible, select:focus-visible, +textarea:focus-visible, canvas:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +button[aria-pressed="true"] { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); } +input[type="number"], input[type="text"], select, textarea { + padding: 0.3rem 0.4rem; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--raised); +} +kbd { margin-left: auto; padding-left: 1.5rem; color: var(--muted); font: inherit; font-size: 0.8rem; } + +/* ---------------------------------------------------------------- header and menus */ +header { + display: flex; + align-items: center; + gap: 0.8rem; + padding: 0.4rem 1rem; + border-bottom: 1px solid var(--line); + background: var(--panel); + position: relative; + z-index: 5; +} +.logo { width: 34px; height: 34px; } +.brand h1 { margin: 0; font-size: 1.2rem; color: var(--accent); } +.brand p { margin: 0; color: var(--muted); font-size: 0.8rem; } +.menubar { display: flex; gap: 0.1rem; margin-left: 0.6rem; } +.menu { position: relative; } +.menu summary { + list-style: none; + cursor: pointer; + padding: 0.35rem 0.6rem; + border-radius: 6px; +} +.menu summary::-webkit-details-marker { display: none; } +.menu[open] summary, .menu summary:hover { background: var(--raised); } +.menu .items { + position: absolute; + top: 100%; + left: 0; + min-width: 15rem; + max-height: 75vh; + overflow-y: auto; + padding: 0.3rem; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: 0 8px 24px rgb(0 0 0 / 0.35); + display: flex; + flex-direction: column; +} +.menu .items button { + display: flex; + align-items: center; + text-align: left; + border: none; + background: none; + padding: 0.4rem 0.6rem; +} +.menu .items button:hover { background: var(--raised); } +.menu hr { border: none; border-top: 1px solid var(--line); margin: 0.25rem 0; width: 100%; } +nav { margin-left: auto; display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap; } +nav a { + color: var(--text); + text-decoration: none; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.4rem 0.8rem; +} +nav a:hover { border-color: var(--accent); } + +/* ---------------------------------------------------------------- layout */ +main { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: minmax(310px, 400px) 1fr; + grid-template-rows: 1fr auto; + grid-template-areas: "panel viewer" "panel below"; +} +#panel { + grid-area: panel; + overflow-y: auto; + overflow-x: hidden; + padding: 0.6rem 1rem 1.5rem; + background: var(--panel); + border-right: 1px solid var(--line); +} +#viewer { grid-area: viewer; position: relative; min-height: 0; } +#below { + grid-area: below; + display: grid; + grid-template-columns: minmax(260px, 2fr) 3fr; + height: 34vh; + min-height: 210px; + border-top: 1px solid var(--line); + background: var(--panel); +} +#plots { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; padding: 0.4rem 0.6rem; min-height: 0; } +#plots figure { margin: 0; display: flex; flex-direction: column; min-height: 0; } +#plots figcaption { color: var(--muted); font-size: 0.82rem; margin-bottom: 0.2rem; } +#plots canvas { flex: 1; width: 100%; min-height: 0; border-radius: 6px; } +#info { overflow-y: auto; padding: 0.3rem 1rem 0.8rem; border-left: 1px solid var(--line); font-size: 0.9rem; } + +/* ---------------------------------------------------------------- the panel */ +.modes { display: grid; grid-template-columns: 1fr 1fr; margin-bottom: 0.7rem; } +.modes button { border-radius: 0; padding: 0.5rem; } +.modes button:first-child { border-radius: 6px 0 0 6px; } +.modes button:last-child { border-radius: 0 6px 6px 0; } +.modes button[aria-selected="true"] { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); } +#panel h2 { margin: 0 0 0.6rem; font-size: 1.3rem; color: var(--accent); } +#panel h3 { margin: 1.1rem 0 0.45rem; font-size: 1.05rem; color: var(--accent); } +.row { display: flex; gap: 0.5rem; align-items: center; } +.row > .big { flex: 1; } +.big { padding: 0.6rem 0.8rem; font-weight: 600; } +.wide { width: 100%; margin-top: 0.4rem; } +.numbers label { display: flex; align-items: center; gap: 0.35rem; flex: 1; } +.numbers input { width: 4.6rem; padding: 0.4rem; } +#boxrow .numbers { flex-wrap: wrap; row-gap: 0.4rem; } +#boxrow .numbers label { flex: 0 1 auto; } +#boxrow .numbers input { width: 3.6rem; } +.tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.4rem; } +.tile { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.2rem; + padding: 0.35rem 0.2rem 0.3rem; + font-size: 0.82rem; + line-height: 1.15; +} +.tile svg { width: 46px; height: 46px; } +.tile[aria-pressed="true"] { + background: color-mix(in srgb, var(--accent) 18%, var(--raised)); + color: var(--text); + border: 2px solid var(--accent); +} +#twist { flex: 1; accent-color: var(--accent); } +.words { min-width: 10rem; color: var(--muted); } +.swatches { display: flex; flex-wrap: wrap; gap: 0.35rem; } +.swatch { width: 2.3rem; height: 2.3rem; padding: 3px; border-radius: 50%; } +.swatch span { display: block; width: 100%; height: 100%; border-radius: 50%; overflow: hidden; } +.swatch[aria-pressed="true"] { border: 3px solid var(--accent); background: var(--raised); } +a.save { display: block; box-sizing: border-box; text-align: center; text-decoration: none; margin-top: 0.8rem; } +.save { + width: 100%; + margin-top: 1.4rem; + padding: 0.8rem; + font-size: 1.15rem; + font-weight: 700; + background: var(--accent); + color: var(--accent-ink); + border-color: var(--accent); +} +button.lid { width: 100%; margin-top: 0.5rem; padding: 0.6rem; font-weight: 600; } +.files { margin-top: 0.5rem; flex-wrap: wrap; } +.files button { flex: 1; font-size: 0.88rem; } +details#printer { margin-top: 1rem; border-top: 1px solid var(--line); padding-top: 0.6rem; } +details#printer summary { cursor: pointer; font-weight: 600; } +.hint { color: var(--muted); font-size: 0.85rem; margin: 0.4rem 0; } +.grid { display: grid; grid-template-columns: 1fr; gap: 0.35rem; } +.grid label { display: flex; align-items: center; justify-content: space-between; gap: 0.4rem; font-size: 0.9rem; } +.grid input { width: 5rem; margin-left: auto; } + +/* ---------------------------------------------------------------- Advanced: every setting */ +.namerow { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } +.namerow input { flex: 1; } +.group { border: 1px solid var(--line); border-radius: 8px; margin-top: 0.7rem; background: var(--bg); } +.group > summary { + cursor: pointer; + padding: 0.45rem 0.7rem; + font-weight: 600; + color: var(--accent); +} +.group .rows { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 0.35rem 0.6rem; + align-items: center; + padding: 0.2rem 0.7rem 0.7rem; +} +.rows > .label { text-align: right; font-size: 0.9rem; color: var(--muted); } +.rows > .control { display: flex; align-items: center; gap: 0.35rem; min-width: 0; } +.rows > .whole { grid-column: 1 / -1; display: flex; flex-direction: column; gap: 0.35rem; } +.control input[type="number"] { width: 6.5rem; } +.control select { max-width: 100%; min-width: 0; flex: 1; } +.control input[type="range"] { flex: 1; accent-color: var(--accent); } +.control input[type="color"] { width: 3rem; height: 1.9rem; padding: 0.1rem; border: 1px solid var(--line); border-radius: 6px; background: var(--raised); } +.control .unit, .control .none { color: var(--muted); font-size: 0.85rem; } +.control .dice { padding: 0.2rem 0.5rem; } +.whole canvas { width: 100%; border-radius: 6px; display: block; } +.whole textarea { width: 100%; min-height: 5.5rem; font-family: Consolas, "Cascadia Mono", monospace; font-size: 0.88rem; } +.whole .row { flex-wrap: wrap; } +.whole .row button, .whole .row select { font-size: 0.85rem; padding: 0.25rem 0.5rem; } +.whole .row select { flex: 1; min-width: 0; max-width: 100%; } +.outlinebox canvas { height: 300px; } + +/* ---------------------------------------------------------------- the 3D view */ +#viewer { background: linear-gradient(var(--view-top), var(--view-bottom)); } +#canvas { display: block; width: 100%; height: 100%; touch-action: none; } +.viewbar { + position: absolute; + top: 0.5rem; + left: 0.5rem; + right: 0.5rem; + display: flex; + gap: 0.4rem; + flex-wrap: wrap; + justify-content: flex-end; + pointer-events: none; +} +.viewbar > * { pointer-events: auto; } +.viewbar button { background: color-mix(in srgb, var(--panel) 88%, transparent); } +.viewbar button[aria-pressed="true"] { background: var(--accent); color: var(--accent-ink); } +.views { display: flex; margin-right: auto; } +.views button { border-radius: 0; } +.views button:first-child { border-radius: 6px 0 0 6px; } +.views button:last-child { border-radius: 0 6px 6px 0; } +.views button[aria-pressed="true"] { background: var(--accent); color: var(--accent-ink); } +.help { position: absolute; left: 0.8rem; bottom: 0.4rem; margin: 0; font-size: 0.82rem; color: var(--muted); + text-shadow: 0 0 4px var(--bg); } +#busy { + position: absolute; + top: 3.1rem; + left: 0.6rem; + padding: 0.35rem 0.7rem; + border-radius: 6px; + background: color-mix(in srgb, var(--panel) 85%, transparent); + font-size: 0.85rem; +} +.spinner { + display: inline-block; + width: 0.8rem; + height: 0.8rem; + border: 2px solid var(--muted); + border-top-color: var(--accent); + border-radius: 50%; + vertical-align: -2px; + animation: turn 0.9s linear infinite; +} +@keyframes turn { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 3s; } } +#loading { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 2rem; + background: color-mix(in srgb, var(--bg) 92%, transparent); + z-index: 2; +} +#loading img { width: 72px; height: 72px; margin-bottom: 0.5rem; } +#loading p { max-width: 30rem; margin: 0.3rem 0; } +#loadingtext { color: var(--muted); } +#loading.failed #loadingtext { color: var(--bad); } + +/* ---------------------------------------------------------------- the report (the desktop app's own HTML) */ +#report h3 { margin: 0.5rem 0 0.3rem; color: var(--accent); font-size: 1rem; } +#report td { padding: 0.12rem 0.6rem 0.12rem 0; vertical-align: top; } +#report .k { color: var(--muted); } +#report td.k { white-space: nowrap; } +#report .e { color: var(--bad); } +#report .w { color: var(--warn); } +#report .ok { color: var(--ok); } +#report p { margin: 0.35rem 0; } +#report ul { padding-left: 1.2rem; margin: 0.3rem 0; } +.status { color: var(--accent); font-size: 0.88rem; min-height: 1.2em; margin: 0.4rem 0 0; } + +/* ---------------------------------------------------------------- dialogs */ +dialog { + border: 1px solid var(--line); + border-radius: 10px; + background: var(--panel); + color: var(--text); + padding: 1rem 1.2rem; + max-width: min(1100px, 94vw); + max-height: 90dvh; +} +dialog::backdrop { background: rgb(0 0 0 / 0.55); } +.dialoghead { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } +.dialoghead h2 { margin: 0; color: var(--accent); font-size: 1.25rem; } +.designs { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 0.6rem; margin-top: 0.5rem; } +.design { display: flex; flex-direction: column; align-items: center; gap: 0.3rem; padding: 0.4rem; } +.design img { width: 150px; height: 190px; object-fit: contain; } +.design b { font-size: 0.9rem; } +#steplist ol { padding-left: 1.4rem; } +#steplist li { margin: 0.35rem 0; } +#help { width: min(760px, 94vw); } +.helptext { max-height: 62dvh; overflow-y: auto; line-height: 1.45; } +.helptext code { color: var(--accent); } +.helptext td { padding: 2px 8px; vertical-align: top; } +#helpexamples { margin-top: 0.8rem; } +#helpexamples select { max-width: 24rem; } +dialog.full { + width: 96vw; + height: 94dvh; + max-width: none; + max-height: none; + display: none; + flex-direction: column; + gap: 0.6rem; +} +dialog.full[open] { display: flex; } +dialog.full canvas { flex: 1; width: 100%; min-height: 0; border-radius: 8px; touch-action: none; } +.tools { flex-wrap: wrap; } +.tools input { width: 4.5rem; } + +/* ---------------------------------------------------------------- narrow screens: one column */ +@media (max-width: 900px) { + body { height: auto; } + main { display: flex; flex-direction: column; } + #viewer { order: 1; height: 60vh; } + #panel { order: 2; border-right: none; border-top: 1px solid var(--line); overflow: visible; } + #below { order: 3; grid-template-columns: 1fr; height: auto; } + #plots { height: 220px; } + #info { border-left: none; border-top: 1px solid var(--line); } + .brand p { display: none; } + header { flex-wrap: wrap; } + .menubar { order: 3; width: 100%; margin-left: 0; } +} + +.library { width: min(960px, 92vw); max-height: 85vh; overflow: auto; } +.library label { display: inline-flex; gap: .35em; margin: .4em; } +.library button { margin: .25em; } +.library-entry { display: flex; flex-wrap: wrap; align-items: center; gap: .5em; padding: .5em; border-bottom: 1px solid #8885; } +.library-entry img { width: 65px; height: 65px; } +.library-entry span { flex: 1; min-width: 150px; } diff --git a/web/worker.js b/web/worker.js index 040b0a7..3410ae7 100644 --- a/web/worker.js +++ b/web/worker.js @@ -1,78 +1,78 @@ -// Vaseinator in the browser: the desktop app's own Python, run by Pyodide in a worker so the page stays -// responsive. The page runs two: one builds the vase, the other answers everything else at once. -// -// Messages in: {id, call, args} - call is a function of bridge.py -// Messages out: {id, ok, result} or {id, ok: false, error}; a mesh or a file comes as transferable buffers. - -importScripts("https://cdn.jsdelivr.net/pyodide/v0.26.4/full/pyodide.js"); -// meshoptimizer: the web version's Simplify model, before saving (the desktop app uses pyfqmr) -importScripts("https://cdn.jsdelivr.net/npm/meshoptimizer@0.22.0/meshopt_simplifier.js"); - -let bridge = null; -const VERSION = self.location.search; // the site's version, so its Python comes fresh with it - -async function start() { - const py = await loadPyodide(); - await py.loadPackage("numpy"); - await MeshoptSimplifier.ready; - py.registerJsModule("meshopt", { - // positions (float32) and triangles (uint32) as bytes; the triangles kept, as bytes, merged wherever that moves - // the surface by no more than ``error`` mm - simplify(positions, triangles, error) { - const pos = positions.toJs(); - const tri = triangles.toJs(); - const [kept] = MeshoptSimplifier.simplify(new Uint32Array(tri.buffer, tri.byteOffset, tri.byteLength / 4), - new Float32Array(pos.buffer, pos.byteOffset, pos.byteLength / 4), 3, 0, error, ["ErrorAbsolute"]); - return new Uint8Array(kept.buffer, kept.byteOffset, kept.byteLength); - }, - }); - const files = await (await fetch("py/files.json" + VERSION)).json(); - py.FS.mkdirTree("/home/pyodide/vaseinator"); - for (const name of files) { - const text = await (await fetch("py/" + name + VERSION)).text(); - py.FS.writeFile("/home/pyodide/" + name, text); - } - py.runPython("import sys; sys.path.insert(0, '/home/pyodide')"); - bridge = py.pyimport("bridge"); - return bridge.start(); -} - -function bytesOf(proxy) { - // a Python bytes object, as a fresh ArrayBuffer of its own - const u8 = proxy.toJs(); - proxy.destroy?.(); - return u8.buffer.byteLength === u8.byteLength ? u8.buffer : u8.slice().buffer; -} - -self.onmessage = async (event) => { - const { id, call, args } = event.data; - try { - if (call === "start") { - self.postMessage({ id, ok: true, result: await start() }); - return; - } - if (call === "build") { - const out = bridge.build(...args); - const result = { meta: out.get(0), positions: bytesOf(out.get(1)), triangles: bytesOf(out.get(2)), - colours: bytesOf(out.get(3)), lean: bytesOf(out.get(4)) }; - out.destroy(); - self.postMessage({ id, ok: true, result }, [result.positions, result.triangles, result.colours, result.lean]); - return; - } - if (call === "export_3mf" || call === "export_lid" || call === "export_calibration") { - const out = bridge[call](...args); - const result = { data: bytesOf(out.get(0)), steps: out.get(1) }; - out.destroy(); - self.postMessage({ id, ok: true, result }, [result.data]); - return; - } - if (call === "export_stl") { - const buffer = bytesOf(bridge.export_stl(...args)); - self.postMessage({ id, ok: true, result: buffer }, [buffer]); - return; - } - self.postMessage({ id, ok: true, result: bridge[call](...args) }); - } catch (err) { - self.postMessage({ id, ok: false, error: String(err && err.message ? err.message : err) }); - } -}; +// Vaseinator in the browser: the desktop app's own Python, run by Pyodide in a worker so the page stays +// responsive. The page runs two: one builds the vase, the other answers everything else at once. +// +// Messages in: {id, call, args} - call is a function of bridge.py +// Messages out: {id, ok, result} or {id, ok: false, error}; a mesh or a file comes as transferable buffers. + +importScripts("https://cdn.jsdelivr.net/pyodide/v0.26.4/full/pyodide.js"); +// meshoptimizer: the web version's Simplify model, before saving (the desktop app uses pyfqmr) +importScripts("https://cdn.jsdelivr.net/npm/meshoptimizer@0.22.0/meshopt_simplifier.js"); + +let bridge = null; +const VERSION = self.location.search; // the site's version, so its Python comes fresh with it + +async function start() { + const py = await loadPyodide(); + await py.loadPackage("numpy"); + await MeshoptSimplifier.ready; + py.registerJsModule("meshopt", { + // positions (float32) and triangles (uint32) as bytes; the triangles kept, as bytes, merged wherever that moves + // the surface by no more than ``error`` mm + simplify(positions, triangles, error) { + const pos = positions.toJs(); + const tri = triangles.toJs(); + const [kept] = MeshoptSimplifier.simplify(new Uint32Array(tri.buffer, tri.byteOffset, tri.byteLength / 4), + new Float32Array(pos.buffer, pos.byteOffset, pos.byteLength / 4), 3, 0, error, ["ErrorAbsolute"]); + return new Uint8Array(kept.buffer, kept.byteOffset, kept.byteLength); + }, + }); + const files = await (await fetch("py/files.json" + VERSION)).json(); + py.FS.mkdirTree("/home/pyodide/vaseinator"); + for (const name of files) { + const text = await (await fetch("py/" + name + VERSION)).text(); + py.FS.writeFile("/home/pyodide/" + name, text); + } + py.runPython("import sys; sys.path.insert(0, '/home/pyodide')"); + bridge = py.pyimport("bridge"); + return bridge.start(); +} + +function bytesOf(proxy) { + // a Python bytes object, as a fresh ArrayBuffer of its own + const u8 = proxy.toJs(); + proxy.destroy?.(); + return u8.buffer.byteLength === u8.byteLength ? u8.buffer : u8.slice().buffer; +} + +self.onmessage = async (event) => { + const { id, call, args } = event.data; + try { + if (call === "start") { + self.postMessage({ id, ok: true, result: await start() }); + return; + } + if (call === "build") { + const out = bridge.build(...args); + const result = { meta: out.get(0), positions: bytesOf(out.get(1)), triangles: bytesOf(out.get(2)), + colours: bytesOf(out.get(3)), lean: bytesOf(out.get(4)) }; + out.destroy(); + self.postMessage({ id, ok: true, result }, [result.positions, result.triangles, result.colours, result.lean]); + return; + } + if (call === "export_3mf" || call === "export_lid" || call === "export_calibration") { + const out = bridge[call](...args); + const result = { data: bytesOf(out.get(0)), steps: out.get(1) }; + out.destroy(); + self.postMessage({ id, ok: true, result }, [result.data]); + return; + } + if (call === "export_stl") { + const buffer = bytesOf(bridge.export_stl(...args)); + self.postMessage({ id, ok: true, result: buffer }, [buffer]); + return; + } + self.postMessage({ id, ok: true, result: bridge[call](...args) }); + } catch (err) { + self.postMessage({ id, ok: false, error: String(err && err.message ? err.message : err) }); + } +};