diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml
index aa79ddb4..6def4042 100644
--- a/.github/workflows/packaging-lint.yml
+++ b/.github/workflows/packaging-lint.yml
@@ -6,9 +6,9 @@ name: Packaging lint
on:
push:
branches: [main]
- paths: [".github/workflows/**", "packaging/**"]
+ paths: [".github/workflows/**", "packaging/**", "assets/**"]
pull_request:
- paths: [".github/workflows/**", "packaging/**"]
+ paths: [".github/workflows/**", "packaging/**", "assets/**"]
workflow_dispatch:
permissions:
@@ -52,13 +52,13 @@ jobs:
}
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
- xmllint --noout packaging/windows/solidcraft.wxs packaging/windows/solidcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
- desktop-file-validate packaging/linux/ai.storyteller.solidcraft.desktop
+ xmllint --noout assets/platforms/windows/solidcraft.wxs assets/platforms/windows/solidcraft.en-us.wxl assets/platforms/macos/*.plist* assets/platforms/linux/*.xml*
+ desktop-file-validate assets/platforms/linux/ai.storyteller.solidcraft.desktop
# The window's X11 class is its app id; the old id stays only as the rename shims.
- grep -qx 'StartupWMClass=ai.storyteller.solidcraft' packaging/linux/ai.storyteller.solidcraft.desktop
- grep -q 'ai.storyteller.solvecraft ' packaging/linux/ai.storyteller.solidcraft.metainfo.xml.in
+ grep -qx 'StartupWMClass=ai.storyteller.solidcraft' assets/platforms/linux/ai.storyteller.solidcraft.desktop
+ grep -q 'ai.storyteller.solvecraft ' assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in
grep -qF -e '--end-of-life-rebase=' packaging/flatpak-site.sh
- sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.solidcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.solidcraft.metainfo.xml"
+ sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.solidcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.solidcraft.metainfo.xml"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solidcraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solidcraft' and m['command'] == 'solidcraft'"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/snap/snapcraft.yaml')); assert m['name'] == 'solidcraft' and m['confinement'] == 'strict' and m['apps']['solidcraft']['command'] == 'usr/bin/solidcraft'"
diff --git a/AGENTS.md b/AGENTS.md
index bb7f59fe..05e54a1a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -59,8 +59,8 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
nothing from FreeCAD is linked, vendored or shipped.) The sketch solver and the blend
operations are our own.
- **Assets — absolutely essential.** SolidCraft contains **no iconography, images, fonts or
- artwork from commercial CAD products — ever.** Every icon is drawn in code
- (`crates/ui-egui/src/icons.rs`). Any file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
+ artwork from commercial CAD products — ever.** Every UI icon is drawn in collected code
+ (`assets/icons/ui/source/`, compiled through `crates/ui-egui/src/icons.rs`). Any file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
(`cargo xtask assets` enforces it). Screenshots of commercial CAD software are never committed.
Breaking this rule is the most serious mistake you can make in this repo.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index dfb516b7..7db9d6e0 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -8,47 +8,44 @@ file is missing from this table.
templates, materials or help text.** Every asset is original work by SolidCraft contributors or
third-party material under an open licence. Screenshots of Autodesk software are never committed.
-Generated-in-code assets are original and have no file to list:
-- the UI icon set (`crates/ui-egui/src/icons.rs`);
-- the axis view, navigation bar and timeline glyphs (`crates/ui-egui/src/viewport.rs`,
- `crates/ui-egui/src/timeline.rs`).
+Editable drawing code, native projects and shipped exports are collected under `assets/`.
## Files
| File | Author | Source | Licence |
|---|---|---|---|
-| assets/icon.svg | SolidCraft contributors | original macOS adaptation of the code-drawn SolidCraft mark, edited and exported with VectorCraft; Apple HIG and production templates consulted as layout references only | MIT OR Apache-2.0 |
-| assets/SolidCraft.icon/icon.json | SolidCraft contributors | approved Icon Composer composition and appearance settings for the original SolidCraft artwork | MIT OR Apache-2.0 |
-| assets/SolidCraft.icon/Assets/02-grid.svg | SolidCraft contributors | original perspective grid with elliptical radial alpha, exported from VectorCraft | MIT OR Apache-2.0 |
-| assets/SolidCraft.icon/Assets/02-grid-dark.svg | SolidCraft contributors | original blue perspective grid with elliptical radial alpha, exported from VectorCraft for the Dark appearance | MIT OR Apache-2.0 |
-| assets/SolidCraft.icon/Assets/03-solid-blue-cutout.svg | SolidCraft contributors | original saturated blue CAD solid with transparent topology cutouts, edited and exported with VectorCraft | MIT OR Apache-2.0 |
-| assets/Assets.car | SolidCraft contributors | Apple actool compilation of assets/SolidCraft.icon for macOS; contains original artwork only | MIT OR Apache-2.0 |
-| assets/icon.vectorcraft | SolidCraft contributors | editable VectorCraft source for assets/icon.svg, with named face and background layers | MIT OR Apache-2.0 |
-| assets/icon.png | SolidCraft contributors | 1024 × 1024 transparent sRGB export of assets/icon.svg from VectorCraft | MIT OR Apache-2.0 |
-| assets/icon.icns | SolidCraft contributors | macOS fallback compiled by Apple's actool from the approved assets/SolidCraft.icon composition | MIT OR Apache-2.0 |
-| assets/icon.iconset/icon_16x16.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
-| assets/icon.iconset/icon_16x16@2x.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
-| assets/icon.iconset/icon_32x32.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
-| assets/icon.iconset/icon_32x32@2x.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
-| assets/icon.iconset/icon_128x128.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
-| assets/icon.iconset/icon_128x128@2x.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
-| assets/icon.iconset/icon_256x256.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
-| assets/icon.iconset/icon_256x256@2x.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
-| assets/icon.iconset/icon_512x512.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
-| assets/icon.iconset/icon_512x512@2x.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
+| assets/archive/icons/macos-v1/exports/icon.svg | SolidCraft contributors | original macOS adaptation of the code-drawn SolidCraft mark, edited and exported with VectorCraft; Apple HIG and production templates consulted as layout references only | MIT OR Apache-2.0 |
+| assets/icons/app/macos/source/SolidCraft.icon/icon.json | SolidCraft contributors | approved Icon Composer composition and appearance settings for the original SolidCraft artwork | MIT OR Apache-2.0 |
+| assets/icons/app/macos/source/SolidCraft.icon/Assets/02-grid.svg | SolidCraft contributors | original perspective grid with elliptical radial alpha, exported from VectorCraft | MIT OR Apache-2.0 |
+| assets/icons/app/macos/source/SolidCraft.icon/Assets/02-grid-dark.svg | SolidCraft contributors | original blue perspective grid with elliptical radial alpha, exported from VectorCraft for the Dark appearance | MIT OR Apache-2.0 |
+| assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg | SolidCraft contributors | original saturated blue CAD solid with transparent topology cutouts, edited and exported with VectorCraft | MIT OR Apache-2.0 |
+| assets/icons/app/macos/exports/Assets.car | SolidCraft contributors | Apple actool compilation of assets/icons/app/macos/source/SolidCraft.icon for macOS; contains original artwork only | MIT OR Apache-2.0 |
+| assets/archive/icons/macos-v1/source/icon.vectorcraft | SolidCraft contributors | editable VectorCraft source for assets/archive/icons/macos-v1/exports/icon.svg, with named face and background layers | MIT OR Apache-2.0 |
+| assets/archive/icons/macos-v1/exports/icon.png | SolidCraft contributors | 1024 × 1024 transparent sRGB export of assets/archive/icons/macos-v1/exports/icon.svg from VectorCraft | MIT OR Apache-2.0 |
+| assets/icons/app/macos/exports/icon.icns | SolidCraft contributors | macOS fallback compiled by Apple's actool from the approved assets/icons/app/macos/source/SolidCraft.icon composition | MIT OR Apache-2.0 |
+| assets/archive/icons/macos-v1/exports/icon.iconset/icon_16x16.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
+| assets/archive/icons/macos-v1/exports/icon.iconset/icon_16x16@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
+| assets/archive/icons/macos-v1/exports/icon.iconset/icon_32x32.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
+| assets/archive/icons/macos-v1/exports/icon.iconset/icon_32x32@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
+| assets/archive/icons/macos-v1/exports/icon.iconset/icon_128x128.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
+| assets/archive/icons/macos-v1/exports/icon.iconset/icon_128x128@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
+| assets/archive/icons/macos-v1/exports/icon.iconset/icon_256x256.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
+| assets/archive/icons/macos-v1/exports/icon.iconset/icon_256x256@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
+| assets/archive/icons/macos-v1/exports/icon.iconset/icon_512x512.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
+| assets/archive/icons/macos-v1/exports/icon.iconset/icon_512x512@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
| docs/screenshots/sample-plate.png | SolidCraft contributors | screenshot of SolidCraft itself (`solidcraft --sample`) | MIT OR Apache-2.0 |
| docs/screenshots/demo-0.8.gif | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
| docs/screenshots/demo-0.8.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
| docs/screenshots/rover-build.gif | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft desktop UI replaying the timeline of the SELENE rover (`Rover-recorded.solidcraft` from https://github.com/0xlillith/solidcraft-rover, an original model by 0xlillith built through SolidCraft MCP); the model's lettering follows Ubuntu Light outlines (Canonical Ltd., Ubuntu Font Licence 1.0, as noted in that repository); the emblem is the SolidCraft project icon (2× playback) | MIT OR Apache-2.0 |
| docs/screenshots/rover-build.mp4 | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft desktop UI replaying the timeline of the SELENE rover (`Rover-recorded.solidcraft` from https://github.com/0xlillith/solidcraft-rover, an original model by 0xlillith built through SolidCraft MCP); the model's lettering follows Ubuntu Light outlines (Canonical Ltd., Ubuntu Font Licence 1.0, as noted in that repository); the emblem is the SolidCraft project icon | MIT OR Apache-2.0 |
-| docs/book/src/og.png | SolidCraft contributors | original 1280 × 640 social preview card (the SolidCraft mark, name and tagline), served by the docs site for link previews | MIT OR Apache-2.0 |
+| assets/branding/web/source/og.png | SolidCraft contributors | original 1280 × 640 social preview card (the SolidCraft mark, name and tagline), served by the docs site for link previews | MIT OR Apache-2.0 |
| docs/book/src/generated/sample-plate.png | SolidCraft contributors | copy of docs/screenshots/sample-plate.png made by `cargo xtask book` (gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/bracket.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/enclosure.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/revolve.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/revolve.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/sheet-metal.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/sheet-metal.json`; gitignored) | MIT OR Apache-2.0 |
-| crates/sketch/fonts/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in crates/sketch/fonts/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
-| apps/solidcraft-web/fonts/BIZUDPGothic-Regular.ttf | The BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic (Google Fonts), unmodified; licence text in apps/solidcraft-web/fonts/OFL.txt; used for web UI Japanese font | SIL OFL 1.1 |
+| assets/fonts/liberation-sans/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in assets/fonts/liberation-sans/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
+| assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf | The BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic (Google Fonts), unmodified; licence text in assets/fonts/biz-udp-gothic/OFL.txt; used for web UI Japanese font | SIL OFL 1.1 |
| tests/oracle/17-loft/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/17-loft/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/23-bracket-multifeature/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/23-bracket-multifeature/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/28-blind-pocket-fillets/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/28-blind-pocket-fillets/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
@@ -70,6 +67,45 @@ Generated-in-code assets are original and have no file to list:
| tests/oracle/63-hinge-knuckle/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/63-hinge-knuckle/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/65-sketch-gear-tooth/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/65-sketch-gear-tooth/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
+| assets/appearances/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
+| assets/fonts/biz-udp-gothic/OFL.txt | The BIZ UDGothic Project Authors | Unmodified accompanying font licence | SIL OFL 1.1 |
+| assets/fonts/liberation-sans/OFL.txt | Red Hat, Inc.; Liberation Fonts contributors | Unmodified accompanying font licence | SIL OFL 1.1 |
+| assets/icons/app/linux/exports/hicolor/128x128/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
+| assets/icons/app/linux/exports/hicolor/16x16/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
+| assets/icons/app/linux/exports/hicolor/24x24/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
+| assets/icons/app/linux/exports/hicolor/256x256/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
+| assets/icons/app/linux/exports/hicolor/32x32/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
+| assets/icons/app/linux/exports/hicolor/48x48/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
+| assets/icons/app/linux/exports/hicolor/512x512/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
+| assets/icons/app/linux/exports/hicolor/64x64/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
+| assets/icons/app/shared/source/app_icon.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
+| assets/icons/app/shared/source/windows_ico.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
+| assets/icons/app/windows/exports/solidcraft-executable.ico | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
+| assets/icons/app/windows/exports/solidcraft.ico | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
+| assets/icons/ui/source/glyphs.rs | SolidCraft / VectorCraft contributors | Original glyphs; caption geometry adapted from VectorCraft titlebar.rs (https://github.com/storytold/vectorcraft) | MIT OR Apache-2.0 |
+| assets/icons/ui/source/icons.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
+| assets/icons/ui/source/markers.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
+| assets/icons/ui/source/navigation.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
+| assets/icons/ui/source/pointer.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
+| assets/locales/de.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
+| assets/locales/en.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
+| assets/locales/ja.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
+| assets/locales/ko.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
+| assets/locales/zh.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
+| assets/materials/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
+| assets/platforms/linux/ai.storyteller.solidcraft.desktop | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
+| assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
+| assets/platforms/linux/ai.storyteller.solidcraft.mime.xml | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
+| assets/platforms/macos/Info.plist.in | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
+| assets/platforms/macos/README.txt | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
+| assets/platforms/windows/solidcraft.en-us.wxl | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
+| assets/platforms/windows/solidcraft.wxs | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
+| assets/shaders/viewport.wgsl | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
+| assets/styles/ui/palettes.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
+| assets/styles/web/loading.css | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
+
+| docs/book/src/og.png | SolidCraft contributors | copy of assets/branding/web/source/og.png staged by cargo xtask book (gitignored) | MIT OR Apache-2.0 |
+
## Code adapted from sibling projects
| SolidCraft file | Adapted from | Licence |
diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt
index 263dd52c..98a4bf3e 100644
--- a/THIRD-PARTY-LICENSES.txt
+++ b/THIRD-PARTY-LICENSES.txt
@@ -13349,11 +13349,11 @@ truck-topology 0.6.0: Apache-2.0 (https://github.com/ricosjp/truck)
Bundled fonts (SIL Open Font License, Version 1.1)
================================================================================
-- LiberationSans-Regular.ttf (crates/sketch/fonts/LiberationSans-Regular.ttf)
+- LiberationSans-Regular.ttf (assets/fonts/liberation-sans/LiberationSans-Regular.ttf)
Copyright 2012 Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google)
Source: https://github.com/liberationfonts
-- BIZUDPGothic-Regular.ttf (apps/solidcraft-web/fonts/BIZUDPGothic-Regular.ttf)
+- BIZUDPGothic-Regular.ttf (assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf)
Copyright 2022 The BIZ UDGothic Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho)
Source: https://github.com/googlefonts/morisawa-biz-ud-gothic
diff --git a/apps/solidcraft-cli/src/main.rs b/apps/solidcraft-cli/src/main.rs
index 59bf178a..8d9db6ff 100644
--- a/apps/solidcraft-cli/src/main.rs
+++ b/apps/solidcraft-cli/src/main.rs
@@ -26,7 +26,7 @@ mod spatial;
use std::process::ExitCode;
use serde_json::{Value, json};
-#[path = "../../app_icon.rs"]
+#[path = "../../../assets/icons/app/shared/source/app_icon.rs"]
mod app_icon;
use solidcraft_engine::Session;
diff --git a/apps/solidcraft-web/index.html b/apps/solidcraft-web/index.html
index 4d30fc67..1a671690 100644
--- a/apps/solidcraft-web/index.html
+++ b/apps/solidcraft-web/index.html
@@ -20,14 +20,9 @@
-
-
-
+
+
+
diff --git a/apps/solidcraft/build.rs b/apps/solidcraft/build.rs
index 7133a148..271b89ed 100644
--- a/apps/solidcraft/build.rs
+++ b/apps/solidcraft/build.rs
@@ -1,61 +1,17 @@
#![forbid(unsafe_code)]
-#[path = "../app_icon.rs"]
-mod app_icon;
fn main() -> Result<(), Box> {
- println!("cargo:rerun-if-changed=../app_icon.rs");
+ let icon = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/icons/app/windows/exports/solidcraft-executable.ico");
+ println!("cargo:rerun-if-changed={}", icon.display());
println!("cargo:rerun-if-changed=build.rs");
if std::env::var("CARGO_CFG_TARGET_OS")? != "windows" {
return Ok(());
}
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").ok_or("missing OUT_DIR")?);
- let sizes = [16usize, 24, 32, 48, 64, 128, 256];
- let mut images = Vec::new();
- for size in sizes {
- let rgba = app_icon::rgba(size)?;
- // ICO DIB: bottom-up BGRA plus a padded one-bit transparency mask.
- let mask_stride = size.div_ceil(32) * 4;
- let mut dib = Vec::new();
- dib.extend_from_slice(&40u32.to_le_bytes());
- dib.extend_from_slice(&(size as i32).to_le_bytes());
- dib.extend_from_slice(&((size * 2) as i32).to_le_bytes());
- dib.extend_from_slice(&1u16.to_le_bytes());
- dib.extend_from_slice(&32u16.to_le_bytes());
- dib.extend_from_slice(&[0; 24]);
- for row in rgba.chunks_exact(size * 4).rev() {
- for p in row.as_chunks::<4>().0.iter() {
- dib.extend_from_slice(&[p[2], p[1], p[0], p[3]]);
- }
- }
- for row in rgba.chunks_exact(size * 4).rev() {
- let mut mask = vec![0u8; mask_stride];
- for (x, p) in row.as_chunks::<4>().0.iter().enumerate() {
- if p[3] == 0 {
- mask[x / 8] |= 0x80 >> (x % 8);
- }
- }
- dib.extend_from_slice(&mask);
- }
- images.push((size, dib));
- }
- let mut ico = vec![0, 0, 1, 0];
- ico.extend_from_slice(&(images.len() as u16).to_le_bytes());
- let mut offset = 6 + 16 * images.len();
- for (size, data) in &images {
- let dim = if *size == 256 { 0 } else { *size as u8 };
- ico.extend_from_slice(&[dim, dim, 0, 0, 1, 0, 32, 0]);
- ico.extend_from_slice(&(data.len() as u32).to_le_bytes());
- ico.extend_from_slice(&(offset as u32).to_le_bytes());
- offset += data.len();
- }
- for (_, data) in images {
- ico.extend_from_slice(&data);
- }
- let icon = out.join("solidcraft.ico");
- std::fs::write(&icon, ico)?;
+ let copy = out.join("solidcraft.ico");
+ std::fs::copy(icon, ©)?;
let rc = out.join("solidcraft.rc");
- std::fs::write(&rc, format!("1 ICON \"{}\"\n", icon.display().to_string().replace('\\', "/")))?;
- // Missing resource tools are a build error, rather than silently shipping a generic icon.
+ std::fs::write(&rc, format!("1 ICON \"{}\"\n", copy.display().to_string().replace('\\', "/")))?;
embed_resource::compile_for(&rc, ["solidcraft"], embed_resource::NONE).manifest_required()?;
Ok(())
}
diff --git a/apps/solidcraft/src/main.rs b/apps/solidcraft/src/main.rs
index 78eb0ccb..dc66c428 100644
--- a/apps/solidcraft/src/main.rs
+++ b/apps/solidcraft/src/main.rs
@@ -13,7 +13,7 @@
#![forbid(unsafe_code)]
#[cfg(not(target_os = "macos"))]
-#[path = "../../app_icon.rs"]
+#[path = "../../../assets/icons/app/shared/source/app_icon.rs"]
mod app_icon;
mod control_server;
#[cfg(any(target_os = "windows", target_os = "linux"))]
diff --git a/assets/README.md b/assets/README.md
index 7600437d..8d81e20e 100644
--- a/assets/README.md
+++ b/assets/README.md
@@ -1,41 +1,98 @@
-# SolidCraft macOS icon
+# Application resources
-`SolidCraft.icon/` is the approved Icon Composer source, containing separate
-grid and solid SVG layers and the owner's appearance settings. The saturated
-blue solid has transparent geometry cutouts instead of painted edge lines.
-The solid and grid are both scaled to 90%, with separate Default and Dark grid artwork.
-Icon Composer supplies the system backdrop and Mono material effects.
-Artwork is original and attributed in `ATTRIBUTION.md`.
+`assets/` is the design resource home for SolidCraft.
+[ATTRIBUTION.md](../ATTRIBUTION.md) is the file inventory and provenance record.
+Keep one editable master per resource and place shipped derivatives in `exports/`.
-`Assets.car` is compiled by Apple's `actool` and supplies the layered icon on
-macOS 26 and later. It is checked in so the existing release workflow can still
-package on older Xcode runners. After editing and saving `SolidCraft.icon`,
-regenerate the catalog with Xcode 26 or later from the repository root:
-
-```sh
-mkdir -p target/macos-icon
-xcrun actool assets/SolidCraft.icon \
- --compile target/macos-icon --platform macosx \
- --minimum-deployment-target 11.0 --app-icon SolidCraft \
- --output-partial-info-plist target/macos-icon/icon-info.plist \
- --output-format human-readable-text
-cp target/macos-icon/Assets.car assets/Assets.car
-cp target/macos-icon/SolidCraft.icns assets/icon.icns
+```text
+assets/
+ icons/
+ app/
+ shared/source/ # original Rust mark and executable ICO encoder
+ macos/source/SolidCraft.icon/ # approved Composer project and linked SVGs
+ macos/exports/ # paired Assets.car and icon.icns
+ windows/exports/ # executable DIB ICO and installer PNG ICO
+ linux/exports/hicolor/ # Linux/FreeBSD package images, 16–512 px
+ ui/source/ # original drawing library and other UI glyphs
+ branding/web/source/og.png # shared social preview card
+ styles/ui/palettes.rs # light/dark defaults
+ styles/web/loading.css # web loading/canvas styles
+ appearances/library.rs # built-in visual presets
+ materials/library.rs # physical defaults and colours
+ shaders/viewport.wgsl # original viewport shader
+ fonts/{liberation-sans,biz-udp-gothic}/ # unmodified fonts and OFL licences
+ locales/{en,de,ja,ko}.json # shared offline catalogs
+ platforms/{macos,linux,windows}/ # bundle, freedesktop and WiX templates
+ archive/icons/macos-v1/
+ source/icon.vectorcraft # earlier editable work, retained
+ exports/ # all historical SVG, PNG and iconset exports
```
-`icon.icns` is the older-macOS fallback generated by the same Apple compiler
-from the approved composition, retaining its layer sizes and positions.
-Regenerate both compiled resources together with the commands above.
-`icon.vectorcraft`, `icon.svg`, `icon.png` and `icon.iconset/` retain the earlier
-VectorCraft artwork; packaging uses the Icon Composer compilation instead.
+## Editing and regeneration
-Only `packaging/macos/package.sh` consumes these resources. Its existing signed
-app/DMG workflow copies the catalog and ICNS into `Contents/Resources`, and
-`Info.plist` references `SolidCraft` through `CFBundleIconName` and
-`CFBundleIconFile`. The macOS viewport opts out of eframe's runtime bitmap so
-Finder, the Dock and the application switcher use the bundle icon. Other
-platforms retain the existing code-drawn icon and packaging.
+[UI artwork](icons/ui/README.md) identifies the drawing masters and their consumers.
+Edit geometry and IDs together in the original Rust source and review changes in
+both application themes. Palettes and model presets are also editable Rust data.
-Design references: [Apple app-icon HIG](https://developer.apple.com/design/human-interface-guidelines/app-icons)
-and [Icon Composer documentation](https://developer.apple.com/documentation/xcode/creating-your-app-icon-using-icon-composer).
-No Apple artwork is shipped.
+The current macOS master is `icons/app/macos/source/SolidCraft.icon/`, including
+its linked SVG layers and appearance settings. The earlier VectorCraft document
+is preserved in the archive. No other native design projects were available for
+the current UI set or preview card. Keep any future `.vectorcraft`, `.ai` or other
+native work and linked inputs under that resource's `source/work/`, with an
+attribution row; retain the editable document alongside its exports.
+
+Regenerate all exports of the shared Windows/Linux/FreeBSD mark together:
+
+```sh
+cargo xtask app-icons
+```
+
+For macOS, use Xcode with Icon Composer support:
+
+```sh
+mkdir -p target/resources/macos-icon
+xcrun actool assets/icons/app/macos/source/SolidCraft.icon \
+ --compile target/resources/macos-icon --platform macosx \
+ --minimum-deployment-target 11.0 --app-icon SolidCraft \
+ --output-partial-info-plist target/resources/macos-icon/icon-info.plist \
+ --output-format human-readable-text
+cp target/resources/macos-icon/Assets.car assets/icons/app/macos/exports/Assets.car
+cp target/resources/macos-icon/SolidCraft.icns assets/icons/app/macos/exports/icon.icns
+```
+
+Review and commit source changes and required exports together. Temporary renders,
+tool caches and package staging belong under ignored `target/` or `dist/`.
+Normal builds use the committed exports and require no design applications.
+The CLI still supports arbitrary PNG/ICO exports with `solidcraft-cli icon`.
+
+## Platform bindings
+
+| Platform | Resources and output paths |
+| --- | --- |
+| macOS | Composer master and paired exports; bundle installs `Assets.car` and `SolidCraft.icns` |
+| Windows executable | `solidcraft-executable.ico`: original DIB/mask representations at 16/24/32/48/64/128/256 px |
+| Windows installer | `solidcraft.ico`: PNG representations at 16/20/24/32/40/48/64/128/256 px; `SOLIDCRAFT_ICONS` override retained |
+| Linux, Nix, deb/rpm, tar, Flatpak, Snap | Copy canonical hicolor images; icon name stays `ai.storyteller.solidcraft` |
+| AppImage | Uses the Linux 256 px image and `.DirIcon` alias |
+| FreeBSD | Shares Linux hicolor images and freedesktop metadata |
+| Web | HTML/Trunk stays in `apps/solidcraft-web/`; copies Japanese font/licence to `fonts/` and uses the collected CSS; no favicon/PWA icon currently exists |
+| Docs and link previews | `xtask book` copies the shared card to the existing `/solidcraft/og.png` public path |
+
+## Code and provenance
+
+Resources remain compiled by their owning layer through existing APIs:
+`ui-egui::icons::paint` and `Tokens` at L5, model presets in L2 `doc`, and sketch
+fonts at L1. State, projection, interaction, commands and GPU bindings stay in
+their current modules. Embedded data uses `CARGO_MANIFEST_DIR`; application code
+needs no runtime resource registry or repository lookup.
+
+`cargo xtask assets` requires an exact attribution row for every collected file
+except Markdown documentation, and continues checking file assets elsewhere in
+the repository. This includes native work, Rust masters, catalogs, shaders and
+platform templates. Packaging selects explicit current files and fails when
+required inputs are absent. Archived artwork has no production consumers.
+
+Dependency-owned egui fonts and vendored renderer shaders stay with their
+libraries; their licences remain in `THIRD-PARTY-LICENSES.txt` and `ATTRIBUTION.md`.
+System fonts, optional packs, user fonts/canvases/decals/materials and native OS
+controls retain external ownership. Root licence/notice texts stay canonical.
diff --git a/assets/appearances/library.rs b/assets/appearances/library.rs
new file mode 100644
index 00000000..cc13d1ae
--- /dev/null
+++ b/assets/appearances/library.rs
@@ -0,0 +1,23 @@
+//! Built-in model appearances: names, sRGB colours and metalness.
+pub const LIBRARY: [(&str, [u8; 3], f64); 20] = [
+ ("Aluminum - Satin", [204, 208, 214], 1.0),
+ ("Aluminum - Anodized Black", [44, 46, 50], 1.0),
+ ("Aluminum - Anodized Blue", [52, 92, 160], 1.0),
+ ("Steel - Satin", [158, 164, 172], 1.0),
+ ("Stainless Steel - Brushed", [186, 191, 198], 1.0),
+ ("Cast Iron", [104, 104, 110], 1.0),
+ ("Brass - Polished", [208, 172, 88], 1.0),
+ ("Copper", [204, 122, 80], 1.0),
+ ("Titanium", [150, 148, 158], 1.0),
+ ("ABS - White", [236, 236, 230], 1.0),
+ ("ABS - Black", [36, 36, 38], 1.0),
+ ("ABS - Red", [196, 40, 40], 1.0),
+ ("ABS - Blue", [40, 90, 186], 1.0),
+ ("ABS - Yellow", [236, 196, 40], 1.0),
+ ("ABS - Green", [52, 150, 80], 1.0),
+ ("Rubber - Black", [28, 28, 28], 1.0),
+ ("Glass - Clear", [190, 220, 228], 0.25),
+ ("Glass - Smoked", [90, 96, 104], 0.45),
+ ("Wood - Oak", [190, 146, 92], 1.0),
+ ("Paint - Gloss White", [246, 246, 244], 1.0),
+];
diff --git a/assets/icon.iconset/icon_128x128.png b/assets/archive/icons/macos-v1/exports/icon.iconset/icon_128x128.png
similarity index 100%
rename from assets/icon.iconset/icon_128x128.png
rename to assets/archive/icons/macos-v1/exports/icon.iconset/icon_128x128.png
diff --git a/assets/icon.iconset/icon_128x128@2x.png b/assets/archive/icons/macos-v1/exports/icon.iconset/icon_128x128@2x.png
similarity index 100%
rename from assets/icon.iconset/icon_128x128@2x.png
rename to assets/archive/icons/macos-v1/exports/icon.iconset/icon_128x128@2x.png
diff --git a/assets/icon.iconset/icon_16x16.png b/assets/archive/icons/macos-v1/exports/icon.iconset/icon_16x16.png
similarity index 100%
rename from assets/icon.iconset/icon_16x16.png
rename to assets/archive/icons/macos-v1/exports/icon.iconset/icon_16x16.png
diff --git a/assets/icon.iconset/icon_16x16@2x.png b/assets/archive/icons/macos-v1/exports/icon.iconset/icon_16x16@2x.png
similarity index 100%
rename from assets/icon.iconset/icon_16x16@2x.png
rename to assets/archive/icons/macos-v1/exports/icon.iconset/icon_16x16@2x.png
diff --git a/assets/icon.iconset/icon_256x256.png b/assets/archive/icons/macos-v1/exports/icon.iconset/icon_256x256.png
similarity index 100%
rename from assets/icon.iconset/icon_256x256.png
rename to assets/archive/icons/macos-v1/exports/icon.iconset/icon_256x256.png
diff --git a/assets/icon.iconset/icon_256x256@2x.png b/assets/archive/icons/macos-v1/exports/icon.iconset/icon_256x256@2x.png
similarity index 100%
rename from assets/icon.iconset/icon_256x256@2x.png
rename to assets/archive/icons/macos-v1/exports/icon.iconset/icon_256x256@2x.png
diff --git a/assets/icon.iconset/icon_32x32.png b/assets/archive/icons/macos-v1/exports/icon.iconset/icon_32x32.png
similarity index 100%
rename from assets/icon.iconset/icon_32x32.png
rename to assets/archive/icons/macos-v1/exports/icon.iconset/icon_32x32.png
diff --git a/assets/icon.iconset/icon_32x32@2x.png b/assets/archive/icons/macos-v1/exports/icon.iconset/icon_32x32@2x.png
similarity index 100%
rename from assets/icon.iconset/icon_32x32@2x.png
rename to assets/archive/icons/macos-v1/exports/icon.iconset/icon_32x32@2x.png
diff --git a/assets/icon.iconset/icon_512x512.png b/assets/archive/icons/macos-v1/exports/icon.iconset/icon_512x512.png
similarity index 100%
rename from assets/icon.iconset/icon_512x512.png
rename to assets/archive/icons/macos-v1/exports/icon.iconset/icon_512x512.png
diff --git a/assets/icon.iconset/icon_512x512@2x.png b/assets/archive/icons/macos-v1/exports/icon.iconset/icon_512x512@2x.png
similarity index 100%
rename from assets/icon.iconset/icon_512x512@2x.png
rename to assets/archive/icons/macos-v1/exports/icon.iconset/icon_512x512@2x.png
diff --git a/assets/icon.png b/assets/archive/icons/macos-v1/exports/icon.png
similarity index 100%
rename from assets/icon.png
rename to assets/archive/icons/macos-v1/exports/icon.png
diff --git a/assets/icon.svg b/assets/archive/icons/macos-v1/exports/icon.svg
similarity index 100%
rename from assets/icon.svg
rename to assets/archive/icons/macos-v1/exports/icon.svg
diff --git a/assets/icon.vectorcraft b/assets/archive/icons/macos-v1/source/icon.vectorcraft
similarity index 100%
rename from assets/icon.vectorcraft
rename to assets/archive/icons/macos-v1/source/icon.vectorcraft
diff --git a/docs/book/src/og.png b/assets/branding/web/source/og.png
similarity index 100%
rename from docs/book/src/og.png
rename to assets/branding/web/source/og.png
diff --git a/apps/solidcraft-web/fonts/BIZUDPGothic-Regular.ttf b/assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf
similarity index 100%
rename from apps/solidcraft-web/fonts/BIZUDPGothic-Regular.ttf
rename to assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf
diff --git a/apps/solidcraft-web/fonts/OFL.txt b/assets/fonts/biz-udp-gothic/OFL.txt
similarity index 100%
rename from apps/solidcraft-web/fonts/OFL.txt
rename to assets/fonts/biz-udp-gothic/OFL.txt
diff --git a/crates/sketch/fonts/LiberationSans-Regular.ttf b/assets/fonts/liberation-sans/LiberationSans-Regular.ttf
similarity index 100%
rename from crates/sketch/fonts/LiberationSans-Regular.ttf
rename to assets/fonts/liberation-sans/LiberationSans-Regular.ttf
diff --git a/crates/sketch/fonts/OFL.txt b/assets/fonts/liberation-sans/OFL.txt
similarity index 100%
rename from crates/sketch/fonts/OFL.txt
rename to assets/fonts/liberation-sans/OFL.txt
diff --git a/assets/icons/app/linux/exports/hicolor/128x128/apps/ai.storyteller.solidcraft.png b/assets/icons/app/linux/exports/hicolor/128x128/apps/ai.storyteller.solidcraft.png
new file mode 100644
index 00000000..4ae84259
Binary files /dev/null and b/assets/icons/app/linux/exports/hicolor/128x128/apps/ai.storyteller.solidcraft.png differ
diff --git a/assets/icons/app/linux/exports/hicolor/16x16/apps/ai.storyteller.solidcraft.png b/assets/icons/app/linux/exports/hicolor/16x16/apps/ai.storyteller.solidcraft.png
new file mode 100644
index 00000000..940120d4
Binary files /dev/null and b/assets/icons/app/linux/exports/hicolor/16x16/apps/ai.storyteller.solidcraft.png differ
diff --git a/assets/icons/app/linux/exports/hicolor/24x24/apps/ai.storyteller.solidcraft.png b/assets/icons/app/linux/exports/hicolor/24x24/apps/ai.storyteller.solidcraft.png
new file mode 100644
index 00000000..b54a031b
Binary files /dev/null and b/assets/icons/app/linux/exports/hicolor/24x24/apps/ai.storyteller.solidcraft.png differ
diff --git a/assets/icons/app/linux/exports/hicolor/256x256/apps/ai.storyteller.solidcraft.png b/assets/icons/app/linux/exports/hicolor/256x256/apps/ai.storyteller.solidcraft.png
new file mode 100644
index 00000000..3d0a1502
Binary files /dev/null and b/assets/icons/app/linux/exports/hicolor/256x256/apps/ai.storyteller.solidcraft.png differ
diff --git a/assets/icons/app/linux/exports/hicolor/32x32/apps/ai.storyteller.solidcraft.png b/assets/icons/app/linux/exports/hicolor/32x32/apps/ai.storyteller.solidcraft.png
new file mode 100644
index 00000000..9b5ca774
Binary files /dev/null and b/assets/icons/app/linux/exports/hicolor/32x32/apps/ai.storyteller.solidcraft.png differ
diff --git a/assets/icons/app/linux/exports/hicolor/48x48/apps/ai.storyteller.solidcraft.png b/assets/icons/app/linux/exports/hicolor/48x48/apps/ai.storyteller.solidcraft.png
new file mode 100644
index 00000000..459770fe
Binary files /dev/null and b/assets/icons/app/linux/exports/hicolor/48x48/apps/ai.storyteller.solidcraft.png differ
diff --git a/assets/icons/app/linux/exports/hicolor/512x512/apps/ai.storyteller.solidcraft.png b/assets/icons/app/linux/exports/hicolor/512x512/apps/ai.storyteller.solidcraft.png
new file mode 100644
index 00000000..128dd431
Binary files /dev/null and b/assets/icons/app/linux/exports/hicolor/512x512/apps/ai.storyteller.solidcraft.png differ
diff --git a/assets/icons/app/linux/exports/hicolor/64x64/apps/ai.storyteller.solidcraft.png b/assets/icons/app/linux/exports/hicolor/64x64/apps/ai.storyteller.solidcraft.png
new file mode 100644
index 00000000..26abe266
Binary files /dev/null and b/assets/icons/app/linux/exports/hicolor/64x64/apps/ai.storyteller.solidcraft.png differ
diff --git a/assets/Assets.car b/assets/icons/app/macos/exports/Assets.car
similarity index 100%
rename from assets/Assets.car
rename to assets/icons/app/macos/exports/Assets.car
diff --git a/assets/icon.icns b/assets/icons/app/macos/exports/icon.icns
similarity index 100%
rename from assets/icon.icns
rename to assets/icons/app/macos/exports/icon.icns
diff --git a/assets/SolidCraft.icon/Assets/02-grid-dark.svg b/assets/icons/app/macos/source/SolidCraft.icon/Assets/02-grid-dark.svg
similarity index 100%
rename from assets/SolidCraft.icon/Assets/02-grid-dark.svg
rename to assets/icons/app/macos/source/SolidCraft.icon/Assets/02-grid-dark.svg
diff --git a/assets/SolidCraft.icon/Assets/02-grid.svg b/assets/icons/app/macos/source/SolidCraft.icon/Assets/02-grid.svg
similarity index 100%
rename from assets/SolidCraft.icon/Assets/02-grid.svg
rename to assets/icons/app/macos/source/SolidCraft.icon/Assets/02-grid.svg
diff --git a/assets/SolidCraft.icon/Assets/03-solid-blue-cutout.svg b/assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg
similarity index 100%
rename from assets/SolidCraft.icon/Assets/03-solid-blue-cutout.svg
rename to assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg
diff --git a/assets/SolidCraft.icon/icon.json b/assets/icons/app/macos/source/SolidCraft.icon/icon.json
similarity index 100%
rename from assets/SolidCraft.icon/icon.json
rename to assets/icons/app/macos/source/SolidCraft.icon/icon.json
diff --git a/apps/app_icon.rs b/assets/icons/app/shared/source/app_icon.rs
similarity index 100%
rename from apps/app_icon.rs
rename to assets/icons/app/shared/source/app_icon.rs
diff --git a/assets/icons/app/shared/source/windows_ico.rs b/assets/icons/app/shared/source/windows_ico.rs
new file mode 100644
index 00000000..bec41fcb
--- /dev/null
+++ b/assets/icons/app/shared/source/windows_ico.rs
@@ -0,0 +1,48 @@
+//! Original executable ICO encoder: DIB images and transparency masks.
+use super::app_icon;
+
+pub fn encode() -> Result, Box> {
+ let sizes = [16usize, 24, 32, 48, 64, 128, 256];
+ let mut images = Vec::new();
+ for size in sizes {
+ let rgba = app_icon::rgba(size)?;
+ // ICO DIB: bottom-up BGRA plus a padded one-bit transparency mask.
+ let mask_stride = size.div_ceil(32) * 4;
+ let mut dib = Vec::new();
+ dib.extend_from_slice(&40u32.to_le_bytes());
+ dib.extend_from_slice(&(size as i32).to_le_bytes());
+ dib.extend_from_slice(&((size * 2) as i32).to_le_bytes());
+ dib.extend_from_slice(&1u16.to_le_bytes());
+ dib.extend_from_slice(&32u16.to_le_bytes());
+ dib.extend_from_slice(&[0; 24]);
+ for row in rgba.chunks_exact(size * 4).rev() {
+ for p in row.as_chunks::<4>().0.iter() {
+ dib.extend_from_slice(&[p[2], p[1], p[0], p[3]]);
+ }
+ }
+ for row in rgba.chunks_exact(size * 4).rev() {
+ let mut mask = vec![0u8; mask_stride];
+ for (x, p) in row.as_chunks::<4>().0.iter().enumerate() {
+ if p[3] == 0 {
+ mask[x / 8] |= 0x80 >> (x % 8);
+ }
+ }
+ dib.extend_from_slice(&mask);
+ }
+ images.push((size, dib));
+ }
+ let mut ico = vec![0, 0, 1, 0];
+ ico.extend_from_slice(&(images.len() as u16).to_le_bytes());
+ let mut offset = 6 + 16 * images.len();
+ for (size, data) in &images {
+ let dim = if *size == 256 { 0 } else { *size as u8 };
+ ico.extend_from_slice(&[dim, dim, 0, 0, 1, 0, 32, 0]);
+ ico.extend_from_slice(&(data.len() as u32).to_le_bytes());
+ ico.extend_from_slice(&(offset as u32).to_le_bytes());
+ offset += data.len();
+ }
+ for (_, data) in images {
+ ico.extend_from_slice(&data);
+ }
+ Ok(ico)
+}
diff --git a/assets/icons/app/windows/exports/solidcraft-executable.ico b/assets/icons/app/windows/exports/solidcraft-executable.ico
new file mode 100644
index 00000000..92f61f58
Binary files /dev/null and b/assets/icons/app/windows/exports/solidcraft-executable.ico differ
diff --git a/assets/icons/app/windows/exports/solidcraft.ico b/assets/icons/app/windows/exports/solidcraft.ico
new file mode 100644
index 00000000..2e2ccc76
Binary files /dev/null and b/assets/icons/app/windows/exports/solidcraft.ico differ
diff --git a/assets/icons/ui/README.md b/assets/icons/ui/README.md
new file mode 100644
index 00000000..572d6925
--- /dev/null
+++ b/assets/icons/ui/README.md
@@ -0,0 +1,22 @@
+# UI artwork
+
+Edit the Rust drawing masters in `source/`; the UI crate compiles them through
+`crates/ui-egui/src/icons.rs`. Use the 24 × 24 grid and semantic ink/fill/accent
+colours. Icon IDs and their geometry live together in `source/icons.rs`;
+unknown IDs retain the existing fallback. Add new artwork there without
+maintaining a separate ID registry.
+
+| Source | Artwork | Consumers |
+| --- | --- | --- |
+| `icons.rs` | Toolbar, browser, menu and constraint icons | `icons::paint` |
+| `glyphs.rs` | Brand mark, captions, transport, badges, pin, caret, disclosure, radial centre and removal cross | toolbar, titlebar, timeline, browser, menus |
+| `navigation.rs` | Signed axes, cube faces/labels and arrows | viewport/navigation cube |
+| `markers.rs` | Constraint mappings/letters, snap markers and dimension arrowheads | sketch, assembly and dimensions |
+| `pointer.rs` | Agent pointer and tag | agent cursor |
+
+Widget state, layout, projection, picking and commands stay with their widgets.
+Review drawing changes in the actual application in both light and dark themes.
+No separate native design projects were available for this set. Preserve any
+future native work and linked inputs under `source/work/`, with attribution.
+Original artwork is MIT OR Apache-2.0; caption geometry retains VectorCraft
+attribution in the root `ATTRIBUTION.md`.
diff --git a/assets/icons/ui/source/glyphs.rs b/assets/icons/ui/source/glyphs.rs
new file mode 100644
index 00000000..828e8e34
--- /dev/null
+++ b/assets/icons/ui/source/glyphs.rs
@@ -0,0 +1,158 @@
+//! Collected glyph artwork. Caption geometry adapted from VectorCraft (MIT OR Apache-2.0).
+use crate::titlebar::Caption;
+use egui::{Color32, Pos2, Rect, Stroke, Ui, pos2, vec2};
+
+pub(crate) fn transport_glyph(p: &egui::Painter, c: egui::Pos2, what: &str, col: Color32) {
+ let tri = |dir: f32, dx: f32| {
+ let pts = vec![pos2(c.x + dx - 4.0 * dir, c.y - 5.0), pos2(c.x + dx + 4.0 * dir, c.y), pos2(c.x + dx - 4.0 * dir, c.y + 5.0)];
+ p.add(egui::Shape::convex_polygon(pts, col, Stroke::NONE));
+ };
+ let bar = |dx: f32| p.line_segment([pos2(c.x + dx, c.y - 5.0), pos2(c.x + dx, c.y + 5.0)], Stroke::new(1.6, col));
+ match what {
+ "first" => {
+ bar(-5.0);
+ tri(-1.0, 1.0);
+ }
+ "back" => tri(-1.0, 0.0),
+ "forward" => tri(1.0, 0.0),
+ _ => {
+ tri(1.0, -1.0);
+ bar(5.0);
+ }
+ }
+}
+
+pub(crate) fn paint_glyph(ui: &Ui, c: Caption, maximized: bool, at: egui::Pos2, s: Stroke) {
+ let p = ui.painter();
+ let g = Rect::from_center_size(at, vec2(10.0, 10.0));
+ match c {
+ Caption::Minimize => {
+ p.line_segment([g.left_center(), g.right_center()], s);
+ }
+ // Restore: a front square with the corner of the one behind it.
+ Caption::Maximize if maximized => {
+ let front = Rect::from_min_max(g.min + vec2(0.0, 2.0), g.max - vec2(2.0, 0.0));
+ p.rect_stroke(front, 0.0, s, egui::StrokeKind::Middle);
+ p.line(vec![g.min + vec2(2.0, 2.0), g.min + vec2(2.0, 0.0), g.right_top(), g.max - vec2(0.0, 2.0), g.max - vec2(2.0, 2.0)], s);
+ }
+ Caption::Maximize => {
+ p.rect_stroke(g, 0.0, s, egui::StrokeKind::Middle);
+ }
+ Caption::Close => {
+ p.line_segment([g.left_top(), g.right_bottom()], s);
+ p.line_segment([g.right_top(), g.left_bottom()], s);
+ }
+ }
+}
+
+pub(crate) fn badge(p: &egui::Painter, r: Rect, name: &str, c: Color32) {
+ let s = Stroke::new(1.3, c);
+ match name {
+ "lock" => {
+ let body = Rect::from_min_max(pos2(r.left() + 1.5, r.center().y - 0.5), pos2(r.right() - 1.5, r.bottom() - 0.5));
+ p.rect_filled(body, 1.5, c);
+ let top = pos2(r.center().x, r.top() + 4.0);
+ p.add(egui::Shape::line(
+ (0..=8)
+ .map(|k| {
+ let a = std::f32::consts::PI * (k as f32 / 8.0);
+ pos2(top.x - 3.2 * a.cos(), top.y - 3.2 * a.sin() + 1.0)
+ })
+ .chain([pos2(top.x + 3.2, body.top())])
+ .collect(),
+ s,
+ ));
+ p.line_segment([pos2(top.x - 3.2, top.y + 1.0), pos2(top.x - 3.2, body.top())], s);
+ }
+ "pin" => {
+ let head = pos2(r.center().x + 2.0, r.top() + 4.0);
+ p.circle_filled(head, 3.2, c);
+ p.line_segment([head, pos2(r.left() + 1.5, r.bottom() - 1.0)], Stroke::new(1.6, c));
+ }
+ _ => {}
+ }
+}
+
+pub(crate) fn pin_icon(p: &egui::Painter, r: Rect, on: bool, col: Color32) {
+ let c = r.center();
+ let (sin, cos) = if on { (0.0, 1.0) } else { std::f32::consts::FRAC_PI_4.sin_cos() };
+ // Local coordinates: the pin points down, its head on top.
+ let at = |x: f32, y: f32| c + vec2(x * cos - y * sin, x * sin + y * cos);
+ let head = vec![at(-2.5, -7.0), at(2.5, -7.0), at(3.0, -2.0), at(-3.0, -2.0)];
+ let stroke = Stroke::new(1.4, col);
+ if on {
+ p.add(egui::Shape::convex_polygon(head, col, stroke));
+ } else {
+ p.add(egui::Shape::closed_line(head, stroke));
+ }
+ p.line_segment([at(-5.5, -2.0), at(5.5, -2.0)], stroke);
+ p.line_segment([at(0.0, -2.0), at(0.0, 7.0)], stroke);
+}
+
+pub(crate) fn caret(p: &egui::Painter, c: egui::Pos2, col: Color32) {
+ p.add(egui::Shape::convex_polygon(vec![pos2(c.x - 3.5, c.y - 2.0), pos2(c.x + 3.5, c.y - 2.0), pos2(c.x, c.y + 2.5)], col, Stroke::NONE));
+}
+
+pub(crate) fn brand_mark(p: &egui::Painter, c: Pos2) {
+ let s = 20.0 / 412.217;
+ let m = |pts: &[(f32, f32)]| -> Vec { pts.iter().map(|&(x, y)| pos2(c.x + (x - 178.5) * s, c.y + (y - 206.1) * s)).collect() };
+ // egui only fills convex shapes, so each face is a few overlapping convex pieces.
+ let face = |col: Color32, pts: &[(f32, f32)]| {
+ p.add(egui::Shape::convex_polygon(m(pts), col, Stroke::NONE));
+ };
+ let (light, mid, dark) = (Color32::from_rgb(96, 158, 236), Color32::from_rgb(58, 136, 238), Color32::from_rgb(55, 108, 197));
+ face(light, &[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)]);
+ face(light, &[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)]);
+ face(mid, &[(3.5, 105.9), (177.0, 206.1), (177.0, 406.4)]);
+ face(mid, &[(3.5, 105.9), (3.5, 205.7), (90.3, 256.2), (177.0, 206.1)]);
+ face(mid, &[(177.0, 206.1), (90.3, 256.2), (90.0, 356.2), (177.0, 406.4)]);
+ face(dark, &[(264.1, 155.8), (177.0, 206.1), (177.0, 406.4), (263.8, 256.2)]);
+ face(dark, &[(263.8, 256.2), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)]);
+ face(dark, &[(177.0, 206.1), (177.0, 406.4), (350.5, 306.3)]);
+ face(dark, &[(263.8, 55.9), (263.8, 156.0), (177.1, 105.9)]);
+ // White edge lines.
+ let edge = Stroke::new(10.0 * s, Color32::WHITE);
+ let line = |pts: &[(f32, f32)]| {
+ p.add(egui::Shape::line(m(pts), edge));
+ };
+ line(&[(263.8, 55.9), (177.1, 105.9), (263.8, 156.0), (263.8, 256.2), (350.6, 206.1)]);
+ line(&[(5.0, 105.9), (178.5, 206.1), (178.5, 406.4)]);
+ line(&[(177.0, 206.1), (263.8, 156.0)]);
+ p.add(egui::Shape::closed_line(
+ m(&[
+ (178.5, 5.8),
+ (5.0, 105.9),
+ (5.0, 206.1),
+ (91.7, 256.2),
+ (91.7, 356.4),
+ (178.5, 406.4),
+ (352.0, 306.3),
+ (352.0, 206.1),
+ (265.2, 156.0),
+ (265.2, 55.9),
+ ]),
+ edge,
+ ));
+}
+
+pub(crate) fn cross(p: &egui::Painter, r: Rect, s: Stroke) {
+ p.line_segment([r.left_top(), r.right_bottom()], s);
+ p.line_segment([r.right_top(), r.left_bottom()], s);
+}
+pub(crate) fn radial_centre(p: &egui::Painter, c: Pos2, direction: Option<(egui::Vec2, bool)>, t: crate::theme::Tokens) {
+ p.circle(c, 15.0, t.panel.gamma_multiply(0.92), Stroke::new(1.0, t.border));
+ if let Some((d, enabled)) = direction {
+ p.line_segment([c + d * 6.0, c + d * 15.0], Stroke::new(3.0, if enabled { t.accent } else { t.border }));
+ } else {
+ p.circle_filled(c, 3.0, t.text_dim);
+ }
+}
+
+pub(crate) fn disclosure(p: &egui::Painter, ar: Rect, open: bool, ink: Color32) {
+ let pts = if open {
+ vec![pos2(ar.left() + 2.0, ar.top() + 4.0), pos2(ar.right() - 2.0, ar.top() + 4.0), pos2(ar.center().x, ar.bottom() - 3.0)]
+ } else {
+ vec![pos2(ar.left() + 4.0, ar.top() + 2.0), pos2(ar.right() - 3.0, ar.center().y), pos2(ar.left() + 4.0, ar.bottom() - 2.0)]
+ };
+ p.add(egui::Shape::convex_polygon(pts, ink, Stroke::NONE));
+}
diff --git a/assets/icons/ui/source/icons.rs b/assets/icons/ui/source/icons.rs
new file mode 100644
index 00000000..83cfbde4
--- /dev/null
+++ b/assets/icons/ui/source/icons.rs
@@ -0,0 +1,1510 @@
+//! Toolbar and browser icons, drawn in code (no image assets). Each icon is designed on a
+//! 24 × 24 grid and scaled to the target rectangle.
+
+use egui::{Color32, Mesh, Painter, Pos2, Rect, Shape, Stroke, pos2, vec2};
+
+/// Surface sheets: a sheet, a second sheet beside it, a lighter sheet (an opening, a blend or
+/// round strip), and the iso lines and comb teeth drawn on sheets.
+const SHEET: Color32 = Color32::from_rgb(170, 200, 240);
+const SHEET_2: Color32 = Color32::from_rgb(150, 185, 235);
+const SHEET_LIGHT: Color32 = Color32::from_rgb(214, 229, 248);
+const SHEET_LINE: Color32 = Color32::from_rgb(127, 166, 218);
+
+/// `n + 1` points along the cubic Bézier curve with control points `c`.
+fn bezier(c: [(f32, f32); 4], n: usize) -> Vec<(f32, f32)> {
+ (0..=n)
+ .map(|i| i as f32 / n as f32)
+ .map(|t| {
+ let u = 1.0 - t;
+ let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t];
+ (w.iter().zip(&c).map(|(w, q)| w * q.0).sum(), w.iter().zip(&c).map(|(w, q)| w * q.1).sum())
+ })
+ .collect()
+}
+
+/// `n + 1` points along an ellipse from angle `a0` to `a1` (y down: angles below the centre
+/// first).
+fn ellipse(x: f32, y: f32, rx: f32, ry: f32, a0: f32, a1: f32, n: usize) -> Vec<(f32, f32)> {
+ (0..=n).map(|i| a0 + (a1 - a0) * i as f32 / n as f32).map(|a| (x + rx * a.cos(), y + ry * a.sin())).collect()
+}
+
+struct Pen<'a> {
+ p: &'a Painter,
+ r: Rect,
+ ink: Color32,
+ fill: Color32,
+ accent: Color32,
+ w: f32,
+}
+
+impl Pen<'_> {
+ fn at(&self, x: f32, y: f32) -> Pos2 {
+ pos2(self.r.left() + x / 24.0 * self.r.width(), self.r.top() + y / 24.0 * self.r.height())
+ }
+ fn s(&self) -> f32 {
+ self.r.width() / 24.0
+ }
+ fn line(&self, pts: &[(f32, f32)], c: Color32) {
+ let v: Vec = pts.iter().map(|(x, y)| self.at(*x, *y)).collect();
+ self.p.add(Shape::line(v, Stroke::new(self.w, c)));
+ }
+ fn poly(&self, pts: &[(f32, f32)], fill: Color32, c: Color32) {
+ let v: Vec = pts.iter().map(|(x, y)| self.at(*x, *y)).collect();
+ self.p.add(Shape::convex_polygon(v, fill, Stroke::new(self.w, c)));
+ }
+ fn closed(&self, pts: &[(f32, f32)], c: Color32) {
+ let v: Vec = pts.iter().map(|(x, y)| self.at(*x, *y)).collect();
+ self.p.add(Shape::closed_line(v, Stroke::new(self.w, c)));
+ }
+ fn circle(&self, x: f32, y: f32, r: f32, fill: Color32, c: Color32) {
+ self.p.circle(self.at(x, y), r * self.s(), fill, Stroke::new(self.w, c));
+ }
+ fn dot(&self, x: f32, y: f32, c: Color32) {
+ self.p.circle_filled(self.at(x, y), 1.6 * self.s(), c);
+ }
+ fn arc(&self, x: f32, y: f32, r: f32, a0: f32, a1: f32, c: Color32) {
+ let n = 24;
+ let pts: Vec<(f32, f32)> = (0..=n).map(|i| a0 + (a1 - a0) * i as f32 / n as f32).map(|a| (x + r * a.cos(), y - r * a.sin())).collect();
+ self.line(&pts, c);
+ }
+ fn arrow(&self, from: (f32, f32), to: (f32, f32), c: Color32) {
+ self.line(&[from, to], c);
+ let d = vec2(to.0 - from.0, to.1 - from.1).normalized();
+ let n = vec2(-d.y, d.x);
+ let tip = vec2(to.0, to.1);
+ let a = tip - d * 3.5 + n * 2.2;
+ let b = tip - d * 3.5 - n * 2.2;
+ self.poly(&[to, (a.x, a.y), (b.x, b.y)], c, c);
+ }
+ /// Isometric box from a 2D footprint height.
+ fn iso_box(&self, x0: f32, y0: f32, w: f32, d: f32, h: f32) {
+ let top = [(x0, y0), (x0 + w, y0 - d * 0.5), (x0 + w + d, y0), (x0 + d, y0 + d * 0.5)];
+ let front = [(x0, y0), (x0 + d, y0 + d * 0.5), (x0 + d, y0 + d * 0.5 + h), (x0, y0 + h)];
+ let side = [(x0 + d, y0 + d * 0.5), (x0 + w + d, y0), (x0 + w + d, y0 + h), (x0 + d, y0 + d * 0.5 + h)];
+ self.poly(&front, self.fill.gamma_multiply(0.85), self.ink);
+ self.poly(&side, self.fill.gamma_multiply(0.7), self.ink);
+ self.poly(&top, self.fill, self.ink);
+ }
+ /// A cubic Bézier curve through control points `c`.
+ fn curve(&self, c: [(f32, f32); 4], col: Color32) {
+ self.line(&bezier(c, 16), col);
+ }
+ fn dashed(&self, pts: &[(f32, f32)], c: Color32) {
+ let v: Vec = pts.iter().map(|(x, y)| self.at(*x, *y)).collect();
+ self.p.add(Shape::dashed_line(&v, Stroke::new(self.w, c), 2.0 * self.s(), 1.6 * self.s()));
+ }
+ /// Fill the strip between two polylines that run the same way, such as the front and back
+ /// edges of a curved sheet. Unlike `poly`, the strip need not be convex.
+ fn band(&self, a: &[(f32, f32)], b: &[(f32, f32)], fill: Color32) {
+ let mut m = Mesh::default();
+ for (p, q) in a.iter().zip(b) {
+ m.colored_vertex(self.at(p.0, p.1), fill);
+ m.colored_vertex(self.at(q.0, q.1), fill);
+ }
+ let n = a.len().min(b.len()) as u32;
+ for i in 1..n {
+ let k = 2 * i;
+ m.add_triangle(k - 2, k - 1, k);
+ m.add_triangle(k - 1, k + 1, k);
+ }
+ self.p.add(Shape::mesh(m));
+ }
+}
+
+/// The stroke width for the Normal/Construction/Centerline line-type icons. It is heavier than
+/// the generic icon weight (`Pen::w`). It is sized from whichever side of `r` is smaller. A
+/// short, wide button and a square toolbar slot both get a stroke their gaps can survive next
+/// to.
+fn line_type_stroke(r: Rect) -> f32 {
+ (r.width().min(r.height()) / 24.0 * 2.0).max(1.6)
+}
+
+/// The Centerline icon's dot radius. It is sized from `r`. The stroke width plays no part in it.
+/// It stays a round mark even next to a heavy stroke.
+fn line_type_dot_radius(r: Rect) -> f32 {
+ (r.width().min(r.height()) * 0.16).max(2.4)
+}
+
+/// Paint the icon `name` into `r`. `accent` highlights the "action" part.
+pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, accent: Color32) {
+ let pen = Pen { p, r, ink, fill, accent, w: (r.width() / 24.0 * 1.4).max(1.0) };
+ let a = pen.accent;
+ match name {
+ "sketch" => {
+ pen.poly(&[(3.0, 14.0), (13.0, 9.0), (21.0, 13.0), (11.0, 18.0)], pen.fill, ink);
+ pen.line(&[(7.0, 13.0), (16.0, 4.0), (18.0, 6.0), (9.0, 15.0), (6.5, 15.5), (7.0, 13.0)], a);
+ }
+ "finish" => {
+ pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, pen.accent);
+ pen.line(&[(7.0, 12.5), (10.5, 16.0), (17.0, 8.5)], a);
+ }
+ "extrude" => {
+ pen.iso_box(4.0, 11.0, 9.0, 7.0, 7.0);
+ pen.arrow((12.0, 9.0), (12.0, 2.5), a);
+ }
+ "revolve" => {
+ pen.line(&[(12.0, 2.0), (12.0, 22.0)], ink);
+ pen.poly(&[(13.0, 7.0), (19.0, 7.0), (19.0, 17.0), (13.0, 17.0)], pen.fill, ink);
+ pen.arc(12.0, 12.0, 9.0, 3.6, 5.8, a);
+ pen.arrow((15.0, 20.4), (18.5, 19.0), a);
+ }
+ "box" => pen.iso_box(3.0, 9.0, 10.0, 8.0, 9.0),
+ "cylinder" => {
+ pen.poly(&[(5.0, 6.0), (19.0, 6.0), (19.0, 18.0), (5.0, 18.0)], pen.fill.gamma_multiply(0.8), Color32::TRANSPARENT);
+ pen.line(&[(5.0, 6.0), (5.0, 18.0)], ink);
+ pen.line(&[(19.0, 6.0), (19.0, 18.0)], ink);
+ let e = |y: f32, fillc: Color32| {
+ let pts: Vec =
+ (0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 7.0 * t.cos(), y + 2.5 * t.sin())).collect();
+ p.add(Shape::convex_polygon(pts, fillc, Stroke::new(pen.w, ink)));
+ };
+ pen.arc(12.0, 18.0, 7.0, std::f32::consts::PI, std::f32::consts::TAU, ink);
+ e(6.0, pen.fill);
+ }
+ "sphere" => {
+ pen.circle(12.0, 12.0, 8.5, pen.fill, ink);
+ pen.arc(12.0, 12.0, 8.5, 3.3, 6.1, ink.gamma_multiply(0.5));
+ p.circle_filled(pen.at(9.0, 8.5), 2.0 * pen.s(), Color32::from_white_alpha(160));
+ }
+ "torus" => {
+ let pts: Vec =
+ (0..40).map(|i| i as f32 / 40.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 9.0 * t.cos(), 12.0 + 5.5 * t.sin())).collect();
+ p.add(Shape::convex_polygon(pts, pen.fill, Stroke::new(pen.w, ink)));
+ let pts: Vec =
+ (0..40).map(|i| i as f32 / 40.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 3.5 * t.cos(), 11.0 + 1.8 * t.sin())).collect();
+ p.add(Shape::convex_polygon(pts, Color32::WHITE, Stroke::new(pen.w, ink)));
+ }
+ "fillet" => {
+ pen.poly(&[(4.0, 20.0), (4.0, 4.0), (10.0, 4.0), (10.0, 14.0), (20.0, 14.0), (20.0, 20.0)], pen.fill, Color32::TRANSPARENT);
+ pen.line(&[(4.0, 20.0), (4.0, 4.0)], ink);
+ pen.line(&[(4.0, 20.0), (20.0, 20.0)], ink);
+ pen.arc(4.0, 4.0, 16.0, -std::f32::consts::FRAC_PI_2, 0.0, ink);
+ pen.arc(20.0, 4.0, 10.0, std::f32::consts::PI, std::f32::consts::PI * 1.5, a);
+ }
+ "chamfer" => {
+ pen.poly(&[(4.0, 20.0), (4.0, 4.0), (12.0, 4.0), (20.0, 12.0), (20.0, 20.0)], pen.fill, ink);
+ pen.line(&[(12.0, 4.0), (20.0, 12.0)], a);
+ }
+ "combine" => {
+ pen.poly(&[(3.0, 8.0), (14.0, 8.0), (14.0, 19.0), (3.0, 19.0)], pen.fill, ink);
+ pen.poly(&[(9.0, 4.0), (21.0, 4.0), (21.0, 15.0), (9.0, 15.0)], pen.fill.gamma_multiply(0.75), a);
+ }
+ "pattern_rect" => {
+ for (x, y) in [(4.0, 4.0), (14.0, 4.0), (4.0, 14.0), (14.0, 14.0)] {
+ pen.poly(
+ &[(x, y), (x + 6.0, y), (x + 6.0, y + 6.0), (x, y + 6.0)],
+ if x == 4.0 && y == 4.0 { pen.fill } else { Color32::TRANSPARENT },
+ if x == 4.0 && y == 4.0 { ink } else { a },
+ );
+ }
+ }
+ "pattern_circ" => {
+ for i in 0..6 {
+ let t = i as f32 / 6.0 * std::f32::consts::TAU;
+ pen.circle(
+ 12.0 + 7.5 * t.cos(),
+ 12.0 + 7.5 * t.sin(),
+ 2.4,
+ if i == 0 { pen.fill } else { Color32::TRANSPARENT },
+ if i == 0 { ink } else { a },
+ );
+ }
+ }
+ "hole" => {
+ pen.poly(&[(3.0, 8.0), (21.0, 8.0), (21.0, 20.0), (3.0, 20.0)], pen.fill, ink);
+ let pts: Vec =
+ (0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 5.0 * t.cos(), 8.0 + 2.0 * t.sin())).collect();
+ p.add(Shape::convex_polygon(pts, Color32::WHITE, Stroke::new(pen.w, a)));
+ pen.line(&[(7.0, 8.0), (7.0, 16.0)], a);
+ pen.line(&[(17.0, 8.0), (17.0, 16.0)], a);
+ }
+ "sweep" => {
+ pen.arc(20.0, 20.0, 14.0, std::f32::consts::FRAC_PI_2, std::f32::consts::PI, ink);
+ pen.circle(6.0, 20.0, 3.0, pen.fill, a);
+ pen.circle(20.0, 6.0, 3.0, pen.fill, a);
+ }
+ "loft" => {
+ pen.poly(&[(3.0, 20.0), (15.0, 20.0), (21.0, 15.0), (9.0, 15.0)], pen.fill, ink);
+ pen.circle(12.0, 6.0, 3.5, Color32::TRANSPARENT, a);
+ pen.line(&[(3.0, 20.0), (8.5, 6.0)], a);
+ pen.line(&[(21.0, 15.0), (15.5, 6.0)], a);
+ }
+ "shell" => {
+ pen.iso_box(3.0, 9.0, 10.0, 8.0, 9.0);
+ pen.poly(&[(6.0, 9.0), (13.0, 5.5), (18.0, 8.0), (11.0, 11.5)], Color32::WHITE, a);
+ }
+ "draft" => {
+ pen.poly(&[(4.0, 20.0), (20.0, 20.0), (17.0, 5.0), (7.0, 5.0)], pen.fill, ink);
+ pen.line(&[(20.0, 20.0), (20.0, 5.0)], a);
+ }
+ "split" => {
+ pen.poly(&[(3.0, 6.0), (21.0, 6.0), (21.0, 18.0), (3.0, 18.0)], pen.fill, ink);
+ pen.line(&[(8.0, 2.0), (16.0, 22.0)], a);
+ }
+ "mirror" => {
+ pen.line(&[(12.0, 2.0), (12.0, 22.0)], ink);
+ pen.poly(&[(3.0, 7.0), (9.0, 10.0), (9.0, 17.0), (3.0, 17.0)], pen.fill, ink);
+ pen.poly(&[(21.0, 7.0), (15.0, 10.0), (15.0, 17.0), (21.0, 17.0)], Color32::TRANSPARENT, a);
+ }
+ "lookat" => {
+ pen.poly(&[(4.0, 6.0), (16.0, 6.0), (16.0, 18.0), (4.0, 18.0)], pen.fill, ink);
+ pen.arrow((22.0, 12.0), (13.0, 12.0), a);
+ }
+ "style" => {
+ pen.poly(&[(12.0, 3.0), (20.0, 7.5), (12.0, 12.0), (4.0, 7.5)], pen.fill, ink);
+ pen.poly(&[(4.0, 7.5), (12.0, 12.0), (12.0, 21.0), (4.0, 16.5)], a, ink);
+ pen.poly(&[(12.0, 12.0), (20.0, 7.5), (20.0, 16.5), (12.0, 21.0)], Color32::TRANSPARENT, ink);
+ }
+ // Original bug glyph: rounded body, antennae and three pairs of legs.
+ "bug_report" => {
+ pen.circle(12.0, 13.5, 5.0, Color32::TRANSPARENT, ink);
+ pen.line(&[(12.0, 9.0), (12.0, 18.0)], ink);
+ pen.line(&[(9.5, 8.5), (8.0, 5.0)], ink);
+ pen.line(&[(14.5, 8.5), (16.0, 5.0)], ink);
+ for y in [10.0, 13.5, 17.0] {
+ pen.line(&[(3.0, y), (7.0, y)], ink);
+ pen.line(&[(17.0, y), (21.0, y)], ink);
+ }
+ }
+ // Help: a question mark in a ring.
+ "help" => {
+ pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink);
+ pen.arc(12.0, 9.5, 3.2, -0.6, 3.4, ink);
+ pen.line(&[(13.9, 12.0), (12.0, 13.6), (12.0, 15.0)], ink);
+ pen.dot(12.0, 17.8, ink);
+ }
+ "sun" => {
+ pen.circle(12.0, 12.0, 4.5, a, ink);
+ for i in 0..8 {
+ let t = i as f32 / 8.0 * std::f32::consts::TAU;
+ pen.line(&[(12.0 + 7.0 * t.cos(), 12.0 + 7.0 * t.sin()), (12.0 + 9.5 * t.cos(), 12.0 + 9.5 * t.sin())], ink);
+ }
+ }
+ "moon" => {
+ let outer: Vec =
+ (0..=24).map(|i| -0.6 + i as f32 / 24.0 * 4.4).map(|t| pen.at(12.0 + 8.0 * t.cos(), 12.0 + 8.0 * t.sin())).collect();
+ let inner: Vec =
+ (0..=24).rev().map(|i| -0.9 + i as f32 / 24.0 * 3.9).map(|t| pen.at(15.5 + 6.5 * t.cos(), 9.5 + 6.5 * t.sin())).collect();
+ let mut pts = outer;
+ pts.extend(inner);
+ p.add(Shape::closed_line(pts, Stroke::new(pen.w, ink)));
+ }
+ "section" => {
+ pen.poly(&[(4.0, 8.0), (14.0, 4.0), (20.0, 8.0), (10.0, 12.0)], pen.fill, ink);
+ pen.poly(&[(4.0, 8.0), (10.0, 12.0), (10.0, 20.0), (4.0, 16.0)], pen.fill, ink);
+ pen.poly(&[(10.0, 12.0), (20.0, 8.0), (20.0, 16.0), (10.0, 20.0)], a, ink);
+ }
+ "presspull" => {
+ pen.poly(&[(3.0, 15.0), (12.0, 11.0), (21.0, 15.0), (12.0, 19.0)], pen.fill, ink);
+ pen.arrow((12.0, 15.0), (12.0, 3.0), a);
+ }
+ "move" => {
+ for (dx, dy) in [(0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)] {
+ pen.arrow((12.0, 12.0), (12.0 + 9.0 * dx, 12.0 + 9.0 * dy), a);
+ }
+ }
+ "configuration_table" => {
+ pen.poly(&[(3.0, 4.0), (21.0, 4.0), (21.0, 20.0), (3.0, 20.0)], pen.fill, ink);
+ pen.line(&[(3.0, 9.0), (21.0, 9.0)], a);
+ pen.line(&[(3.0, 14.0), (21.0, 14.0)], ink);
+ pen.line(&[(9.0, 4.0), (9.0, 20.0)], ink);
+ }
+ "params" => {
+ pen.line(&[(8.0, 20.0), (9.0, 8.0), (10.0, 5.0), (13.0, 4.0)], ink);
+ pen.line(&[(6.0, 11.0), (12.0, 11.0)], ink);
+ pen.line(&[(14.0, 13.0), (20.0, 20.0)], a);
+ pen.line(&[(20.0, 13.0), (14.0, 20.0)], a);
+ }
+ "compute" => {
+ pen.arc(12.0, 12.0, 7.5, 0.4, 3.0, ink);
+ pen.arc(12.0, 12.0, 7.5, 3.5, 6.0, a);
+ pen.arrow((5.0, 9.0), (4.6, 13.0), ink);
+ pen.arrow((19.0, 15.0), (19.4, 11.0), a);
+ }
+ "delete" => {
+ pen.line(&[(6.0, 6.0), (18.0, 18.0)], pen.accent);
+ pen.line(&[(18.0, 6.0), (6.0, 18.0)], pen.accent);
+ }
+ "measure" => {
+ pen.poly(&[(2.0, 15.0), (15.0, 2.0), (22.0, 9.0), (9.0, 22.0)], pen.fill, ink);
+ for i in 0..5 {
+ let t = 4.0 + i as f32 * 2.6;
+ pen.line(&[(t, 15.0 - (t - 2.0)), (t + 2.0, 15.0 - (t - 2.0) + 2.0)], ink);
+ }
+ }
+ "line" => {
+ pen.line(&[(4.0, 19.0), (20.0, 5.0)], a);
+ pen.dot(4.0, 19.0, ink);
+ pen.dot(20.0, 5.0, ink);
+ }
+ "rect" | "rect3" | "rect_center" => {
+ pen.closed(&[(4.0, 6.0), (20.0, 6.0), (20.0, 18.0), (4.0, 18.0)], a);
+ match name {
+ "rect" => {
+ pen.dot(4.0, 18.0, ink);
+ pen.dot(20.0, 6.0, ink);
+ }
+ "rect3" => {
+ pen.dot(4.0, 18.0, ink);
+ pen.dot(20.0, 18.0, ink);
+ pen.dot(20.0, 6.0, ink);
+ }
+ _ => {
+ pen.dot(12.0, 12.0, ink);
+ pen.dot(20.0, 6.0, ink);
+ }
+ }
+ }
+ "rect_rounded" => {
+ // Straight sides between quarter arcs of radius 3 at the corners.
+ pen.line(&[(7.0, 6.0), (17.0, 6.0)], a);
+ pen.line(&[(20.0, 9.0), (20.0, 15.0)], a);
+ pen.line(&[(17.0, 18.0), (7.0, 18.0)], a);
+ pen.line(&[(4.0, 15.0), (4.0, 9.0)], a);
+ let q = std::f32::consts::FRAC_PI_2;
+ pen.arc(17.0, 9.0, 3.0, 0.0, q, a);
+ pen.arc(7.0, 9.0, 3.0, q, 2.0 * q, a);
+ pen.arc(7.0, 15.0, 3.0, 2.0 * q, 3.0 * q, a);
+ pen.arc(17.0, 15.0, 3.0, 3.0 * q, 4.0 * q, a);
+ pen.dot(4.0, 18.0, ink);
+ pen.dot(20.0, 6.0, ink);
+ }
+ "circle" | "circle2" | "circle3" => {
+ pen.circle(12.0, 12.0, 8.0, Color32::TRANSPARENT, a);
+ match name {
+ "circle" => {
+ pen.dot(12.0, 12.0, ink);
+ pen.line(&[(12.0, 12.0), (17.6, 6.4)], ink);
+ }
+ "circle2" => {
+ pen.dot(4.0, 12.0, ink);
+ pen.dot(20.0, 12.0, ink);
+ }
+ _ => {
+ pen.dot(4.0, 12.0, ink);
+ pen.dot(12.0, 4.0, ink);
+ pen.dot(20.0, 12.0, ink);
+ }
+ }
+ }
+ "arc3" | "arc_center" => {
+ pen.arc(12.0, 16.0, 9.0, 0.0, std::f32::consts::PI, a);
+ pen.dot(3.0, 16.0, ink);
+ pen.dot(21.0, 16.0, ink);
+ if name == "arc3" {
+ pen.dot(12.0, 7.0, ink);
+ } else {
+ pen.dot(12.0, 16.0, ink);
+ }
+ }
+ "polygon" => {
+ let pts: Vec<(f32, f32)> =
+ (0..6).map(|i| i as f32 / 6.0 * std::f32::consts::TAU + 0.52).map(|t| (12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())).collect();
+ pen.closed(&pts, a);
+ pen.dot(12.0, 12.0, ink);
+ }
+ "slot" => {
+ pen.arc(8.0, 12.0, 5.0, std::f32::consts::FRAC_PI_2, std::f32::consts::PI * 1.5, a);
+ pen.arc(16.0, 12.0, 5.0, -std::f32::consts::FRAC_PI_2, std::f32::consts::FRAC_PI_2, a);
+ pen.line(&[(8.0, 7.0), (16.0, 7.0)], a);
+ pen.line(&[(8.0, 17.0), (16.0, 17.0)], a);
+ pen.dot(8.0, 12.0, ink);
+ pen.dot(16.0, 12.0, ink);
+ }
+ "point" => {
+ pen.dot(12.0, 12.0, a);
+ pen.circle(12.0, 12.0, 4.0, Color32::TRANSPARENT, ink);
+ }
+ "dimension" => {
+ pen.line(&[(4.0, 18.0), (4.0, 8.0)], ink);
+ pen.line(&[(20.0, 18.0), (20.0, 8.0)], ink);
+ pen.arrow((12.0, 11.0), (4.5, 11.0), a);
+ pen.arrow((12.0, 11.0), (19.5, 11.0), a);
+ pen.line(&[(9.0, 15.0), (15.0, 15.0)], ink);
+ }
+ // The Sketch Palette's line type selector (normal, construction, centerline) paints all
+ // three on the same diagonal, from (3, 19) to (19, 3). Only the dash pattern changes
+ // between them. Normal draws in `ink` and ignores `accent`. It is a plain line. It is
+ // never construction geometry. Its colour must stay fixed even if a caller passes the
+ // construction orange for it. Construction and centerline draw only in `accent` and
+ // ignore `ink`. The caller decides the colour. The palette and the toolbar both pass
+ // the theme's construction orange.
+ //
+ // These three are drawn small. The Sketch Palette draws them down to about 18 px
+ // square. At that size a repeating dash pattern loses its gaps. It reads as a solid
+ // line. Two patterns that only differ in dash phase read as the same texture too.
+ // Normal, Construction and Centerline instead get three different silhouettes. One is
+ // an unbroken line. One is a line broken by a single gap. One is a line broken by a
+ // single gap with a bold dot sitting in it. A viewer can name the shape on sight. No
+ // rhythm needs measuring. The stroke width and the dot radius both come from the rect
+ // actually passed in. That keeps the shapes legible in the palette, the toolbar and the
+ // command palette, at every size any of them uses.
+ "normal" => {
+ let w = line_type_stroke(r);
+ p.add(Shape::line(vec![pen.at(3.0, 19.0), pen.at(19.0, 3.0)], Stroke::new(w, ink)));
+ }
+ "construction" => {
+ // One gap, no dot. It is an unmistakably broken line. Nothing sits in the break.
+ let w = line_type_stroke(r);
+ let at = |t: f32| pen.at(3.0 + 16.0 * t, 19.0 - 16.0 * t);
+ for (t0, t1) in [(0.0, 0.35), (0.65, 1.0)] {
+ p.add(Shape::line(vec![at(t0), at(t1)], Stroke::new(w, a)));
+ }
+ }
+ "centerline" => {
+ // The same gap as Construction, with a bold dot centred in it. The silhouette is
+ // line, dot, line. The dot's radius comes from the rect. It reads as a distinct
+ // round mark on its own, never as a thicker stretch of the stroke. The dashes stop
+ // well short of it on both sides. The dot never touches either stroke. It always
+ // reads as a separate shape.
+ let w = line_type_stroke(r);
+ let dot_r = line_type_dot_radius(r);
+ let at = |t: f32| pen.at(3.0 + 16.0 * t, 19.0 - 16.0 * t);
+ for (t0, t1) in [(0.0, 0.26), (0.74, 1.0)] {
+ p.add(Shape::line(vec![at(t0), at(t1)], Stroke::new(w, a)));
+ }
+ p.circle_filled(at(0.5), dot_r, a);
+ }
+ "c_hv" => {
+ pen.line(&[(4.0, 18.0), (20.0, 18.0)], a);
+ pen.line(&[(6.0, 4.0), (6.0, 14.0)], a);
+ }
+ "c_coincident" => {
+ pen.line(&[(3.0, 18.0), (12.0, 12.0)], ink);
+ pen.line(&[(12.0, 12.0), (21.0, 16.0)], ink);
+ pen.circle(12.0, 12.0, 2.6, Color32::WHITE, a);
+ }
+ "c_tangent" => {
+ pen.circle(12.0, 14.0, 6.0, Color32::TRANSPARENT, ink);
+ pen.line(&[(3.0, 8.0), (21.0, 8.0)], a);
+ }
+ "c_equal" => {
+ pen.line(&[(5.0, 9.0), (19.0, 9.0)], a);
+ pen.line(&[(5.0, 15.0), (19.0, 15.0)], a);
+ }
+ "c_parallel" => {
+ pen.line(&[(5.0, 19.0), (13.0, 5.0)], a);
+ pen.line(&[(11.0, 19.0), (19.0, 5.0)], a);
+ }
+ "c_perpendicular" => {
+ pen.line(&[(4.0, 19.0), (20.0, 19.0)], a);
+ pen.line(&[(12.0, 19.0), (12.0, 5.0)], a);
+ pen.line(&[(12.0, 15.0), (16.0, 15.0), (16.0, 19.0)], ink);
+ }
+ "c_fix" => {
+ pen.circle(12.0, 9.0, 4.0, Color32::TRANSPARENT, a);
+ pen.line(&[(12.0, 13.0), (12.0, 21.0)], a);
+ pen.line(&[(9.0, 21.0), (15.0, 21.0)], ink);
+ }
+ "c_midpoint" => {
+ pen.line(&[(3.0, 15.0), (21.0, 9.0)], ink);
+ pen.poly(&[(12.0, 9.0), (15.0, 12.0), (12.0, 15.0), (9.0, 12.0)], a, a);
+ }
+ "c_concentric" => {
+ pen.circle(12.0, 12.0, 8.5, Color32::TRANSPARENT, a);
+ pen.circle(12.0, 12.0, 4.0, Color32::TRANSPARENT, a);
+ }
+ "c_collinear" => {
+ pen.line(&[(3.0, 19.0), (10.0, 12.0)], a);
+ pen.line(&[(14.0, 8.0), (21.0, 1.0)], a);
+ }
+ "c_symmetry" => {
+ pen.line(&[(12.0, 3.0), (12.0, 21.0)], ink);
+ pen.poly(&[(4.0, 8.0), (9.0, 12.0), (4.0, 16.0)], a, a);
+ pen.poly(&[(20.0, 8.0), (15.0, 12.0), (20.0, 16.0)], a, a);
+ }
+ "undo" | "redo" => {
+ let (x0, x1) = if name == "undo" { (19.0, 5.0) } else { (5.0, 19.0) };
+ pen.line(&[(x0, 18.0), (x0, 12.0), (12.0, 9.0), (x1, 9.0)], ink);
+ pen.arrow((x1 + (x0 - x1) * 0.15, 9.0), (x1, 9.0), ink);
+ }
+ "new" => {
+ pen.poly(&[(6.0, 3.0), (15.0, 3.0), (19.0, 7.0), (19.0, 21.0), (6.0, 21.0)], Color32::WHITE, ink);
+ pen.line(&[(15.0, 3.0), (15.0, 7.0), (19.0, 7.0)], ink);
+ }
+ "open" => {
+ pen.poly(&[(3.0, 7.0), (9.0, 7.0), (11.0, 9.0), (20.0, 9.0), (20.0, 19.0), (3.0, 19.0)], pen.fill, ink);
+ }
+ "save" => {
+ pen.poly(&[(4.0, 4.0), (17.0, 4.0), (20.0, 7.0), (20.0, 20.0), (4.0, 20.0)], pen.fill, ink);
+ pen.poly(&[(8.0, 4.0), (16.0, 4.0), (16.0, 9.0), (8.0, 9.0)], Color32::WHITE, ink);
+ }
+ "export" => {
+ pen.poly(&[(4.0, 10.0), (4.0, 20.0), (16.0, 20.0), (16.0, 10.0)], pen.fill, ink);
+ pen.arrow((10.0, 14.0), (20.0, 4.0), a);
+ }
+ "import" => {
+ pen.iso_box(4.0, 12.0, 12.0, 7.0, 7.0);
+ pen.arrow((20.0, 3.0), (12.0, 11.0), a);
+ }
+ "home" => {
+ pen.poly(&[(4.0, 12.0), (12.0, 4.0), (20.0, 12.0)], pen.fill, ink);
+ pen.poly(&[(6.0, 12.0), (18.0, 12.0), (18.0, 20.0), (6.0, 20.0)], pen.fill, ink);
+ }
+ "fit" => {
+ for (cx, cy, dx, dy) in [(4.0, 4.0, 1.0, 1.0), (20.0, 4.0, -1.0, 1.0), (4.0, 20.0, 1.0, -1.0), (20.0, 20.0, -1.0, -1.0)] {
+ pen.line(&[(cx + dx * 5.0, cy), (cx, cy), (cx, cy + dy * 5.0)], ink);
+ }
+ pen.poly(&[(9.0, 9.0), (15.0, 9.0), (15.0, 15.0), (9.0, 15.0)], pen.fill, ink);
+ }
+ "orbit" => {
+ pen.arc(12.0, 12.0, 8.0, 0.3, 5.5, ink);
+ pen.arrow((17.0, 17.5), (19.6, 14.5), a);
+ pen.dot(12.0, 12.0, ink);
+ }
+ "spin" => {
+ // A turntable: an ellipse with an arrow running round it, a post in the middle.
+ let pts: Vec = (0..=28).map(|i| 0.2 + i as f32 / 28.0 * 5.6).map(|t| pen.at(12.0 + 9.0 * t.cos(), 16.0 + 3.5 * t.sin())).collect();
+ p.add(Shape::line(pts, Stroke::new(pen.w, ink)));
+ pen.arrow((17.0, 19.0), (20.5, 17.0), a);
+ pen.line(&[(12.0, 15.5), (12.0, 4.0)], ink);
+ pen.poly(&[(9.0, 9.0), (15.0, 9.0), (15.0, 4.0), (9.0, 4.0)], Color32::TRANSPARENT, ink);
+ }
+ "pan" => {
+ for (dx, dy) in [(0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)] {
+ pen.arrow((12.0 + 3.0 * dx, 12.0 + 3.0 * dy), (12.0 + 9.0 * dx, 12.0 + 9.0 * dy), ink);
+ }
+ }
+ "zoom" => {
+ pen.circle(10.0, 10.0, 6.0, Color32::WHITE, ink);
+ pen.line(&[(14.5, 14.5), (20.0, 20.0)], ink);
+ }
+ "perspective" => {
+ pen.closed(&[(4.0, 6.0), (20.0, 4.0), (20.0, 20.0), (4.0, 18.0)], ink);
+ pen.closed(&[(9.0, 9.0), (15.0, 8.5), (15.0, 15.5), (9.0, 15.0)], ink);
+ }
+ "eye" => {
+ pen.arc(12.0, 18.0, 10.0, 0.6, 2.54, ink);
+ pen.arc(12.0, 6.0, 10.0, 3.74, 5.68, ink);
+ pen.circle(12.0, 12.0, 2.6, ink, ink);
+ }
+ "body" => pen.iso_box(4.0, 10.0, 8.0, 6.0, 6.0),
+ // Component: a box with a smaller one stacked on it.
+ "component" => {
+ pen.iso_box(2.0, 13.0, 9.0, 6.0, 6.0);
+ pen.iso_box(9.0, 6.0, 5.0, 4.0, 4.0);
+ }
+ "folder" => pen.poly(&[(3.0, 7.0), (9.0, 7.0), (11.0, 9.0), (21.0, 9.0), (21.0, 19.0), (3.0, 19.0)], Color32::from_rgb(236, 200, 110), ink),
+ "origin" => {
+ pen.arrow((6.0, 18.0), (20.0, 18.0), Color32::from_rgb(210, 60, 60));
+ pen.arrow((6.0, 18.0), (6.0, 4.0), Color32::from_rgb(60, 100, 220));
+ pen.arrow((6.0, 18.0), (15.0, 9.0), Color32::from_rgb(60, 160, 60));
+ }
+ "plane" => pen.poly(&[(3.0, 15.0), (12.0, 8.0), (21.0, 11.0), (12.0, 18.0)], Color32::from_rgb(244, 214, 150), ink),
+ "axis" => {
+ pen.line(&[(4.0, 20.0), (20.0, 4.0)], ink);
+ pen.circle(4.0, 20.0, 2.0, ink, ink);
+ }
+ "settings" => {
+ // A gear: a solid ring with six chunky, square-ish teeth and a see-through centre
+ // hole (#290; thin rays read as a sun).
+ let (teeth, root, tip, hole) = (6usize, 7.0f32, 10.5f32, 3.2f32);
+ let pitch = std::f32::consts::TAU / teeth as f32;
+ // Half-widths (radians) of a tooth at its root and at its tip: nearly parallel sides.
+ let (half_root, half_tip) = (pitch * 0.3, pitch * 0.22);
+ let polar = |a: f32, r: f32| (12.0 + r * a.cos(), 12.0 + r * a.sin());
+ let start = -std::f32::consts::FRAC_PI_2;
+ let mut outline = Vec::new();
+ for i in 0..teeth {
+ let c = start + i as f32 * pitch;
+ // The tooth, filled; it reaches into the ring so no seam shows.
+ let quad = [polar(c - half_root, root - 1.0), polar(c - half_tip, tip), polar(c + half_tip, tip), polar(c + half_root, root - 1.0)];
+ let v: Vec = quad.iter().map(|(x, y)| pen.at(*x, *y)).collect();
+ p.add(Shape::convex_polygon(v, pen.fill, Stroke::NONE));
+ outline.extend([polar(c - half_root, root), polar(c - half_tip, tip), polar(c + half_tip, tip), polar(c + half_root, root)]);
+ // The root circle up to the next tooth.
+ for k in 1..4 {
+ let a = c + half_root + (pitch - 2.0 * half_root) * k as f32 / 4.0;
+ outline.push(polar(a, root));
+ }
+ }
+ // The ring: a thick stroke, so the hole stays transparent on any background.
+ p.circle_stroke(pen.at(12.0, 12.0), (root + hole) * 0.5 * pen.s(), Stroke::new((root - hole) * pen.s(), pen.fill));
+ pen.closed(&outline, ink);
+ pen.circle(12.0, 12.0, hole, Color32::TRANSPARENT, ink);
+ }
+ // Sketch tools (sketch-style: thin outlines, accent for what the tool makes).
+ "canvas" | "decal" => {
+ if name == "canvas" {
+ pen.poly(&[(3.0, 5.0), (21.0, 5.0), (21.0, 19.0), (3.0, 19.0)], pen.fill, ink);
+ } else {
+ pen.poly(&[(3.0, 5.0), (21.0, 5.0), (21.0, 13.0), (15.0, 19.0), (3.0, 19.0)], pen.fill, ink);
+ pen.line(&[(21.0, 13.0), (15.0, 13.0), (15.0, 19.0)], ink);
+ }
+ pen.line(&[(5.0, 17.0), (9.5, 10.5), (12.5, 14.5), (14.5, 12.0), (18.0, 16.5)], a);
+ pen.circle(16.5, 8.5, 1.6, a, a);
+ }
+ "dxf" | "svg" => {
+ pen.poly(&[(5.0, 2.5), (15.0, 2.5), (19.5, 7.0), (19.5, 21.5), (5.0, 21.5)], pen.fill, ink);
+ pen.line(&[(15.0, 2.5), (15.0, 7.0), (19.5, 7.0)], ink);
+ let label = if name == "dxf" { "DXF" } else { "SVG" };
+ p.text(pen.at(12.2, 15.0), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(6.4 * pen.s()), a);
+ }
+ "c_smooth" => {
+ let pts: Vec<(f32, f32)> =
+ (0..=24).map(|i| i as f32 / 24.0).map(|t| (3.0 + 18.0 * t, 12.0 - 7.0 * (t * std::f32::consts::TAU).sin())).collect();
+ let (l, r) = pts.split_at(13);
+ pen.line(l, ink);
+ let mut r2 = vec![pts[12]];
+ r2.extend_from_slice(r);
+ pen.line(&r2, a);
+ pen.dot(12.0, 12.0, ink);
+ }
+ "c_polygon" => {
+ let pts: Vec<(f32, f32)> =
+ (0..6).map(|i| i as f32 / 6.0 * std::f32::consts::TAU).map(|t| (12.0 + 8.5 * t.cos(), 12.0 + 8.5 * t.sin())).collect();
+ pen.closed(&pts, a);
+ pen.line(&[(12.0, 12.0), (20.5, 12.0)], ink);
+ pen.dot(12.0, 12.0, ink);
+ }
+ "auto_constrain" | "constrainer" => {
+ pen.closed(&[(3.0, 8.0), (15.0, 8.0), (15.0, 20.0), (3.0, 20.0)], ink);
+ if name == "auto_constrain" {
+ for (dx, dy) in [(0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0), (0.7, 0.7), (-0.7, -0.7), (0.7, -0.7), (-0.7, 0.7)] {
+ let k = if dx * dy == 0.0 { 4.0 } else { 2.5 };
+ pen.line(&[(18.0, 6.0), (18.0 + k * dx, 6.0 + k * dy)], a);
+ }
+ } else {
+ pen.line(&[(13.0, 6.0), (16.0, 9.0), (21.5, 2.5)], a);
+ }
+ pen.line(&[(6.0, 17.0), (12.0, 17.0)], a);
+ pen.line(&[(6.0, 11.0), (6.0, 14.0)], a);
+ }
+ "project" | "project_surface" => {
+ if name == "project" {
+ pen.poly(&[(2.0, 18.0), (12.0, 14.0), (22.0, 18.0), (12.0, 22.0)], pen.fill, ink);
+ pen.closed(&[(6.0, 18.0), (12.0, 15.6), (18.0, 18.0), (12.0, 20.4)], a);
+ } else {
+ pen.line(&[(2.0, 19.0), (7.0, 16.0), (12.0, 17.5), (17.0, 15.5), (22.0, 18.0)], ink);
+ pen.line(&[(6.0, 19.5), (12.0, 20.5), (18.0, 18.5)], a);
+ }
+ pen.iso_box(7.0, 5.0, 5.0, 5.0, 4.0);
+ pen.arrow((12.0, 10.5), (12.0, 15.0), a);
+ }
+ "intersect" | "intersection_curve" => {
+ pen.iso_box(6.0, 6.0, 7.0, 5.0, 11.0);
+ if name == "intersect" {
+ pen.line(&[(2.0, 13.0), (14.0, 8.0), (22.0, 12.0)], ink.gamma_multiply(0.6));
+ pen.line(&[(6.0, 13.0), (11.0, 15.5), (18.0, 12.0)], a);
+ } else {
+ pen.arc(12.0, 26.0, 13.0, 0.6, 2.55, ink.gamma_multiply(0.6));
+ pen.line(&[(6.0, 14.5), (11.0, 16.5), (18.0, 13.5)], a);
+ }
+ }
+ "include" => {
+ pen.iso_box(4.0, 9.0, 10.0, 7.0, 9.0);
+ pen.line(&[(4.0, 9.0), (11.0, 12.5), (21.0, 7.5)], a);
+ pen.dot(11.0, 12.5, a);
+ }
+ "spun" => {
+ pen.line(&[(12.0, 2.0), (12.0, 22.0)], ink);
+ pen.line(&[(14.0, 5.0), (18.0, 9.0), (18.0, 15.0), (14.0, 19.0)], ink);
+ pen.line(&[(10.0, 5.0), (6.0, 9.0), (6.0, 15.0), (10.0, 19.0)], a);
+ pen.arc(12.0, 12.0, 6.0, 0.3, 2.8, ink.gamma_multiply(0.6));
+ }
+ "iso_curve" => {
+ pen.poly(&[(3.0, 16.0), (10.0, 6.0), (21.0, 8.0), (14.0, 19.0)], pen.fill, ink);
+ pen.line(&[(6.5, 11.0), (17.5, 13.5)], ink.gamma_multiply(0.5));
+ pen.line(&[(7.7, 17.3), (14.8, 6.8)], a);
+ }
+ "fit_section" => {
+ pen.closed(&[(3.0, 18.0), (8.0, 6.0), (14.0, 15.0), (19.0, 5.0), (21.0, 18.0)], ink.gamma_multiply(0.6));
+ pen.line(&[(8.0, 6.0), (3.0, 18.0)], ink.gamma_multiply(0.6));
+ pen.line(&[(14.0, 15.0), (3.0, 18.0)], ink.gamma_multiply(0.6));
+ pen.line(&[(14.0, 15.0), (21.0, 18.0)], ink.gamma_multiply(0.6));
+ pen.line(&[(2.0, 12.0), (7.0, 10.5), (12.0, 12.5), (17.0, 10.0), (22.0, 11.5)], a);
+ }
+ "sketch_mirror" => {
+ p.add(Shape::dashed_line(&[pen.at(12.0, 2.0), pen.at(12.0, 22.0)], Stroke::new(pen.w, ink), 2.5 * pen.s(), 1.8 * pen.s()));
+ pen.closed(&[(3.0, 6.0), (9.0, 10.0), (9.0, 18.0), (3.0, 18.0)], ink);
+ pen.closed(&[(21.0, 6.0), (15.0, 10.0), (15.0, 18.0), (21.0, 18.0)], a);
+ }
+ "sketch_circ_pattern" => paint(p, r, "pattern_circ", ink, Color32::TRANSPARENT, a),
+ "sketch_rect_pattern" => paint(p, r, "pattern_rect", ink, Color32::TRANSPARENT, a),
+ "line_mid" => {
+ pen.line(&[(3.0, 19.0), (21.0, 5.0)], a);
+ pen.dot(12.0, 12.0, ink);
+ pen.dot(21.0, 5.0, ink);
+ pen.circle(12.0, 12.0, 3.0, Color32::TRANSPARENT, ink);
+ }
+ "arc_tangent" => {
+ pen.line(&[(2.0, 18.0), (11.0, 18.0)], ink);
+ pen.arc(11.0, 10.0, 8.0, -std::f32::consts::FRAC_PI_2, 0.6, a);
+ pen.dot(11.0, 18.0, ink);
+ }
+ "circle_tt" | "circle_ttt" => {
+ if name == "circle_ttt" {
+ pen.closed(&[(3.0, 20.0), (21.0, 20.0), (12.0, 4.0)], ink);
+ } else {
+ pen.line(&[(21.0, 20.0), (3.0, 20.0), (12.0, 4.0)], ink);
+ }
+ pen.circle(12.0, 14.74, 5.26, Color32::TRANSPARENT, a);
+ }
+ "arc_slot" => {
+ let (t0, t1) = (0.75_f32, 2.4_f32);
+ pen.arc(12.0, 21.0, 13.0, t0, t1, a);
+ pen.arc(12.0, 21.0, 7.0, t0, t1, a);
+ pen.arc(12.0 + 10.0 * t0.cos(), 21.0 - 10.0 * t0.sin(), 3.0, t0, t0 - std::f32::consts::PI, a);
+ pen.arc(12.0 + 10.0 * t1.cos(), 21.0 - 10.0 * t1.sin(), 3.0, t1, t1 + std::f32::consts::PI, a);
+ pen.dot(12.0, 21.0, ink);
+ }
+ "ellipse" => {
+ let pts: Vec<(f32, f32)> =
+ (0..40).map(|i| i as f32 / 40.0 * std::f32::consts::TAU).map(|t| (12.0 + 9.5 * t.cos(), 12.0 + 5.5 * t.sin())).collect();
+ pen.closed(&pts, a);
+ pen.dot(12.0, 12.0, ink);
+ pen.line(&[(12.0, 12.0), (21.5, 12.0)], ink);
+ }
+ "spline" | "spline_cv" => {
+ let c = [(3.0_f32, 18.0_f32), (8.0, 3.0), (16.0, 21.0), (21.0, 6.0)];
+ let pts: Vec<(f32, f32)> = (0..=24)
+ .map(|i| i as f32 / 24.0)
+ .map(|t| {
+ let u = 1.0 - t;
+ let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t];
+ (w.iter().zip(&c).map(|(w, q)| w * q.0).sum(), w.iter().zip(&c).map(|(w, q)| w * q.1).sum())
+ })
+ .collect();
+ if name == "spline_cv" {
+ pen.line(&c, ink.gamma_multiply(0.6));
+ for q in c {
+ pen.dot(q.0, q.1, ink);
+ }
+ } else {
+ for i in [0, 8, 16, 24] {
+ if let Some(q) = pts.get(i) {
+ pen.dot(q.0, q.1, ink);
+ }
+ }
+ }
+ pen.line(&pts, a);
+ }
+ "conic" => {
+ pen.line(&[(3.0, 20.0), (12.0, 3.0), (21.0, 20.0)], ink.gamma_multiply(0.6));
+ let pts: Vec<(f32, f32)> =
+ (0..=20).map(|i| i as f32 / 20.0).map(|t| (3.0 + 18.0 * t, 20.0 - 4.0 * 0.78 * 17.0 * t * (1.0 - t))).collect();
+ pen.line(&pts, a);
+ pen.dot(3.0, 20.0, ink);
+ pen.dot(21.0, 20.0, ink);
+ }
+ "text" => {
+ p.text(pen.at(12.0, 11.5), egui::Align2::CENTER_CENTER, "A", egui::FontId::proportional(17.0 * pen.s()), a);
+ pen.line(&[(4.0, 20.5), (20.0, 20.5)], ink);
+ }
+ "curvature_comb" => {
+ let f = |x: f32| 16.0 - 6.0 * ((x - 3.0) / 18.0 * std::f32::consts::PI).sin();
+ let mut env = Vec::new();
+ for i in 0..=8 {
+ let x = 3.0 + 18.0 * i as f32 / 8.0;
+ let k = 1.0 + 5.0 * ((x - 3.0) / 18.0 * std::f32::consts::PI).sin();
+ pen.line(&[(x, f(x)), (x, f(x) - k)], a.gamma_multiply(0.7));
+ env.push((x, f(x) - k));
+ }
+ pen.line(&env, a);
+ let pts: Vec<(f32, f32)> = (0..=24).map(|i| 3.0 + 18.0 * i as f32 / 24.0).map(|x| (x, f(x))).collect();
+ pen.line(&pts, ink);
+ }
+ "min_radius" => {
+ pen.line(&[(3.0, 20.0), (7.0, 9.0), (12.0, 5.5), (17.0, 9.0), (21.0, 20.0)], ink);
+ pen.circle(12.0, 10.5, 5.0, Color32::TRANSPARENT, a);
+ pen.arrow((12.0, 10.5), (15.5, 7.0), a);
+ pen.dot(12.0, 10.5, ink);
+ }
+ "iso_analysis" => {
+ pen.poly(&[(3.0, 17.0), (9.0, 5.0), (21.0, 7.0), (15.0, 19.0)], pen.fill, ink);
+ for k in 1..4 {
+ let t = k as f32 / 4.0;
+ pen.line(&[(3.0 + 6.0 * t, 17.0 - 12.0 * t), (15.0 + 6.0 * t, 19.0 - 12.0 * t)], a);
+ pen.line(&[(3.0 + 12.0 * t, 17.0 + 2.0 * t), (9.0 + 12.0 * t, 5.0 + 2.0 * t)], a);
+ }
+ }
+ "accessibility" => {
+ pen.poly(&[(2.0, 20.0), (22.0, 20.0), (22.0, 15.0), (2.0, 15.0)], Color32::from_rgb(70, 180, 90), ink);
+ pen.poly(&[(8.0, 15.0), (16.0, 15.0), (16.0, 17.5), (8.0, 17.5)], Color32::from_rgb(220, 70, 60), Color32::TRANSPARENT);
+ pen.poly(&[(6.0, 10.0), (18.0, 10.0), (18.0, 12.5), (6.0, 12.5)], pen.fill, ink);
+ pen.arrow((4.0, 2.0), (4.0, 13.0), a);
+ pen.arrow((20.0, 2.0), (20.0, 13.0), a);
+ }
+ "environment_map" => {
+ let pts: Vec =
+ (0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())).collect();
+ p.add(Shape::convex_polygon(pts, Color32::from_rgb(190, 205, 230), Stroke::new(pen.w, ink)));
+ pen.poly(&[(3.6, 13.0), (20.4, 13.0), (18.5, 17.5), (5.5, 17.5)], Color32::from_rgb(60, 62, 70), Color32::TRANSPARENT);
+ pen.line(&[(3.2, 12.6), (20.8, 12.6)], Color32::WHITE);
+ p.circle_filled(pen.at(9.0, 8.0), 2.2 * pen.s(), Color32::WHITE);
+ pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink);
+ }
+ "zebra" | "draft_analysis" | "curvature_map" => {
+ let pts: Vec =
+ (0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())).collect();
+ match name {
+ "zebra" => {
+ p.add(Shape::convex_polygon(pts, Color32::WHITE, Stroke::new(pen.w, ink)));
+ for k in 0..3 {
+ let y = 6.5 + k as f32 * 4.5;
+ let h = (81.0 - (y + 1.0 - 12.0) * (y + 1.0 - 12.0)).max(0.0).sqrt();
+ pen.poly(
+ &[(12.0 - h, y), (12.0 + h, y), (12.0 + h, y + 2.2), (12.0 - h, y + 2.2)],
+ Color32::from_gray(30),
+ Color32::TRANSPARENT,
+ );
+ }
+ pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink);
+ }
+ "draft_analysis" => {
+ pen.poly(&[(4.0, 20.0), (20.0, 20.0), (17.0, 5.0), (7.0, 5.0)], Color32::from_rgb(70, 180, 90), ink);
+ pen.poly(&[(7.0, 5.0), (17.0, 5.0), (16.0, 9.0), (8.0, 9.0)], Color32::from_rgb(235, 200, 50), Color32::TRANSPARENT);
+ pen.arrow((12.0, 16.0), (12.0, 1.5), a);
+ }
+ _ => {
+ p.add(Shape::convex_polygon(pts, Color32::from_rgb(60, 110, 230), Stroke::new(pen.w, ink)));
+ pen.circle(14.0, 10.0, 5.0, Color32::from_rgb(70, 190, 90), Color32::TRANSPARENT);
+ pen.circle(15.5, 8.5, 2.2, Color32::from_rgb(225, 60, 45), Color32::TRANSPARENT);
+ }
+ }
+ }
+ "center_of_mass" => {
+ pen.circle(12.0, 12.0, 8.0, Color32::TRANSPARENT, ink);
+ let q = |a0: f32| {
+ let pts: Vec = std::iter::once(pen.at(12.0, 12.0))
+ .chain(
+ (0..=8)
+ .map(|i| a0 + i as f32 / 8.0 * std::f32::consts::FRAC_PI_2)
+ .map(|t| pen.at(12.0 + 8.0 * t.cos(), 12.0 - 8.0 * t.sin())),
+ )
+ .collect();
+ p.add(Shape::convex_polygon(pts, a, Stroke::NONE));
+ };
+ q(0.0);
+ q(std::f32::consts::PI);
+ pen.circle(12.0, 12.0, 8.0, Color32::TRANSPARENT, ink);
+ }
+ "sketch_fillet" => {
+ pen.line(&[(4.0, 21.0), (4.0, 12.0)], ink);
+ pen.line(&[(12.0, 4.0), (21.0, 4.0)], ink);
+ pen.arc(12.0, 12.0, 8.0, std::f32::consts::FRAC_PI_2, std::f32::consts::PI, a);
+ p.add(Shape::dashed_line(
+ &[pen.at(4.0, 12.0), pen.at(4.0, 4.0), pen.at(12.0, 4.0)],
+ Stroke::new(pen.w * 0.7, ink.gamma_multiply(0.5)),
+ 2.0 * pen.s(),
+ 1.5 * pen.s(),
+ ));
+ }
+ "sketch_chamfer" => {
+ pen.line(&[(4.0, 21.0), (4.0, 12.0)], ink);
+ pen.line(&[(12.0, 4.0), (21.0, 4.0)], ink);
+ pen.line(&[(4.0, 12.0), (12.0, 4.0)], a);
+ p.add(Shape::dashed_line(
+ &[pen.at(4.0, 12.0), pen.at(4.0, 4.0), pen.at(12.0, 4.0)],
+ Stroke::new(pen.w * 0.7, ink.gamma_multiply(0.5)),
+ 2.0 * pen.s(),
+ 1.5 * pen.s(),
+ ));
+ }
+ // The three chamfer types, for the Chamfer Type row of the corner tool window. Each
+ // draws the cut it makes: equal legs with a tick on each, two unequal legs, and one leg
+ // with the angle the cut leaves it at.
+ "sketch_chamfer_equal" => {
+ pen.line(&[(4.0, 21.0), (4.0, 12.0)], ink);
+ pen.line(&[(12.0, 4.0), (21.0, 4.0)], ink);
+ pen.line(&[(4.0, 12.0), (12.0, 4.0)], a);
+ pen.line(&[(2.5, 9.5), (5.5, 6.5)], a);
+ pen.line(&[(9.5, 2.5), (6.5, 5.5)], a);
+ p.add(Shape::dashed_line(
+ &[pen.at(4.0, 12.0), pen.at(4.0, 4.0), pen.at(12.0, 4.0)],
+ Stroke::new(pen.w * 0.7, ink.gamma_multiply(0.5)),
+ 2.0 * pen.s(),
+ 1.5 * pen.s(),
+ ));
+ }
+ "sketch_chamfer_two" => {
+ pen.line(&[(4.0, 21.0), (4.0, 10.0)], ink);
+ pen.line(&[(18.0, 4.0), (21.0, 4.0)], ink);
+ pen.line(&[(4.0, 10.0), (18.0, 4.0)], a);
+ p.add(Shape::dashed_line(
+ &[pen.at(4.0, 10.0), pen.at(4.0, 4.0), pen.at(18.0, 4.0)],
+ Stroke::new(pen.w * 0.7, ink.gamma_multiply(0.5)),
+ 2.0 * pen.s(),
+ 1.5 * pen.s(),
+ ));
+ }
+ "sketch_chamfer_angle" => {
+ pen.line(&[(4.0, 21.0), (4.0, 13.0)], ink);
+ pen.line(&[(14.0, 4.0), (21.0, 4.0)], ink);
+ pen.line(&[(4.0, 13.0), (14.0, 4.0)], a);
+ pen.arc(4.0, 13.0, 6.0, -std::f32::consts::FRAC_PI_2, 0.73, a);
+ p.add(Shape::dashed_line(
+ &[pen.at(4.0, 13.0), pen.at(4.0, 4.0), pen.at(14.0, 4.0)],
+ Stroke::new(pen.w * 0.7, ink.gamma_multiply(0.5)),
+ 2.0 * pen.s(),
+ 1.5 * pen.s(),
+ ));
+ }
+ "offset" => {
+ pen.closed(&[(8.0, 9.0), (16.0, 9.0), (16.0, 15.0), (8.0, 15.0)], ink);
+ pen.line(&[(8.0, 4.0), (16.0, 4.0)], a);
+ pen.line(&[(21.0, 9.0), (21.0, 15.0)], a);
+ pen.line(&[(16.0, 20.0), (8.0, 20.0)], a);
+ pen.line(&[(3.0, 15.0), (3.0, 9.0)], a);
+ pen.arc(16.0, 9.0, 5.0, 0.0, std::f32::consts::FRAC_PI_2, a);
+ pen.arc(16.0, 15.0, 5.0, -std::f32::consts::FRAC_PI_2, 0.0, a);
+ pen.arc(8.0, 15.0, 5.0, std::f32::consts::PI, std::f32::consts::PI * 1.5, a);
+ pen.arc(8.0, 9.0, 5.0, std::f32::consts::FRAC_PI_2, std::f32::consts::PI, a);
+ }
+ // Offset mode icons, drawn with the house `dashed` helper. The source is the solid
+ // line and each result a dashed one. One Side has a single result; Two Sides has one
+ // each way at two different gaps; Symmetric has one each way at the same gap.
+ "offset_one_side" => {
+ pen.line(&[(8.0, 4.0), (8.0, 20.0)], ink);
+ pen.dashed(&[(18.0, 4.0), (18.0, 20.0)], a);
+ }
+ "offset_two_sides" => {
+ pen.line(&[(13.0, 4.0), (13.0, 20.0)], ink);
+ pen.dashed(&[(3.0, 4.0), (3.0, 20.0)], a);
+ pen.dashed(&[(20.0, 4.0), (20.0, 20.0)], a);
+ }
+ "offset_symmetric" => {
+ pen.line(&[(12.0, 4.0), (12.0, 20.0)], ink);
+ pen.dashed(&[(4.0, 4.0), (4.0, 20.0)], a);
+ pen.dashed(&[(20.0, 4.0), (20.0, 20.0)], a);
+ }
+ "trim" => {
+ pen.line(&[(12.0, 3.0), (12.0, 21.0)], ink);
+ pen.line(&[(3.0, 12.0), (12.0, 12.0)], ink);
+ p.add(Shape::dashed_line(&[pen.at(12.0, 12.0), pen.at(21.0, 12.0)], Stroke::new(pen.w, a), 2.0 * pen.s(), 1.6 * pen.s()));
+ pen.line(&[(15.0, 15.0), (19.0, 19.0)], a);
+ pen.line(&[(19.0, 15.0), (15.0, 19.0)], a);
+ }
+ "patch" => {
+ // A wire loop with a surface spanned across it.
+ pen.poly(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0)], SHEET, ink);
+ pen.line(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0), (4.0, 16.0)], a);
+ }
+ "stitch" => {
+ // Two surface patches sewn along their shared edge.
+ pen.poly(&[(3.0, 18.0), (11.0, 14.0), (11.0, 4.0), (3.0, 8.0)], SHEET, ink);
+ pen.poly(&[(13.0, 14.0), (21.0, 18.0), (21.0, 8.0), (13.0, 4.0)], SHEET_2, ink);
+ for y in [6.0, 9.0, 12.0] {
+ pen.line(&[(10.0, y), (14.0, y + 1.5)], a);
+ }
+ }
+ "thicken" => {
+ // A wavy sheet (accent edges) grown downward into a slab, shaded like `iso_box`.
+ let f = [(2.0, 12.5), (7.0, 9.5), (12.0, 15.5), (17.0, 12.5)];
+ let b = [(6.4, 8.9), (11.4, 5.9), (16.4, 11.9), (21.4, 8.9)];
+ let d = f.map(|(x, y)| (x, y + 6.0));
+ pen.band(&bezier(b, 16), &bezier(f, 16), SHEET);
+ pen.band(&bezier(f, 16), &bezier(d, 16), pen.fill.gamma_multiply(0.85));
+ pen.poly(&[(17.0, 12.5), (21.4, 8.9), (21.4, 14.9), (17.0, 18.5)], pen.fill.gamma_multiply(0.7), ink);
+ pen.line(&[(2.0, 12.5), (2.0, 18.5)], ink);
+ pen.line(&[(17.0, 12.5), (17.0, 18.5)], ink);
+ pen.line(&[(21.4, 8.9), (21.4, 14.9)], ink);
+ pen.curve(d, ink);
+ pen.line(&[(6.4, 8.9), (2.0, 12.5)], ink);
+ pen.line(&[(21.4, 8.9), (17.0, 12.5)], a);
+ pen.curve(b, a);
+ pen.curve(f, a);
+ }
+ "extend" => {
+ pen.line(&[(20.0, 3.0), (20.0, 21.0)], ink);
+ pen.line(&[(3.0, 12.0), (10.0, 12.0)], ink);
+ pen.dot(10.0, 12.0, ink);
+ pen.arrow((10.0, 12.0), (19.0, 12.0), a);
+ }
+ "break" => {
+ pen.line(&[(3.0, 17.0), (10.5, 11.0)], ink);
+ pen.line(&[(13.5, 10.0), (21.0, 5.0)], a);
+ pen.dot(12.0, 10.5, a);
+ pen.line(&[(9.0, 5.0), (15.0, 16.0)], ink.gamma_multiply(0.5));
+ }
+ "scale" => {
+ pen.closed(&[(3.0, 13.0), (11.0, 13.0), (11.0, 21.0), (3.0, 21.0)], ink);
+ pen.closed(&[(3.0, 3.0), (21.0, 3.0), (21.0, 21.0), (3.0, 21.0)], a);
+ pen.dot(3.0, 21.0, ink);
+ pen.arrow((11.0, 13.0), (18.0, 6.0), a);
+ }
+ "blend" => {
+ pen.line(&[(2.0, 19.0), (8.0, 15.0)], ink);
+ pen.line(&[(16.0, 9.0), (22.0, 5.0)], ink);
+ pen.curve([(8.0, 15.0), (12.5, 12.0), (11.5, 12.0), (16.0, 9.0)], a);
+ pen.dot(8.0, 15.0, ink);
+ pen.dot(16.0, 9.0, ink);
+ }
+ // Surface modelling: open sheets (no caps) in the sheet colours, the input or the result
+ // in the accent.
+ "surface_extrude" => {
+ // An open profile (accent) pulled up into a sheet.
+ let top = [(4.0, 11.0), (8.0, 8.0), (13.0, 14.0), (20.0, 10.0)];
+ let bottom = [(4.0, 20.0), (8.0, 17.0), (13.0, 23.0), (20.0, 19.0)];
+ pen.band(&bezier(top, 16), &bezier(bottom, 16), SHEET);
+ pen.line(&[(4.0, 11.0), (4.0, 20.0)], ink);
+ pen.line(&[(20.0, 10.0), (20.0, 19.0)], ink);
+ pen.curve(top, ink);
+ pen.curve(bottom, a);
+ pen.arrow((12.0, 8.5), (12.0, 2.5), a);
+ }
+ "surface_revolve" => {
+ // An open profile (accent) turned about the axis into a hollow band.
+ let left = [(5.0, 6.0), (7.0, 10.0), (5.0, 14.0), (6.0, 18.0)];
+ let right = [(19.0, 6.0), (17.0, 10.0), (19.0, 14.0), (18.0, 18.0)];
+ pen.band(&bezier(left, 16), &bezier(right, 16), SHEET);
+ let front = ellipse(12.0, 18.0, 6.0, 2.2, 0.0, std::f32::consts::PI, 16);
+ pen.poly(&front, SHEET, Color32::TRANSPARENT);
+ // A closed fill takes each point once: a repeated end point spikes `poly`'s edge.
+ let mut opening = ellipse(12.0, 6.0, 7.0, 2.2, 0.0, std::f32::consts::TAU, 32);
+ opening.pop();
+ pen.poly(&opening, SHEET_LIGHT, ink);
+ pen.line(&front, ink);
+ pen.curve(left, ink);
+ pen.line(&[(12.0, 1.5), (12.0, 3.8)], ink);
+ pen.line(&[(12.0, 20.2), (12.0, 22.5)], ink);
+ pen.curve(right, a);
+ }
+ "surface_sweep" => {
+ // A straight profile (accent) carried along a curved path: a ribbon.
+ let near = [(4.0, 13.0), (8.0, 5.0), (14.0, 5.0), (20.0, 9.0)];
+ let far = [(4.0, 20.0), (8.0, 12.0), (14.0, 12.0), (20.0, 16.0)];
+ pen.band(&bezier(near, 16), &bezier(far, 16), SHEET);
+ pen.curve(near, ink);
+ pen.curve(far, ink);
+ pen.line(&[(4.0, 13.0), (4.0, 20.0)], a);
+ pen.line(&[(20.0, 9.0), (20.0, 16.0)], ink);
+ }
+ "surface_loft" => {
+ // Two open sections (accent) joined by a sheet.
+ let top = [(5.0, 6.0), (9.0, 3.0), (14.0, 8.0), (19.0, 5.0)];
+ let bottom = [(3.0, 19.0), (8.0, 15.0), (14.0, 22.0), (21.0, 18.0)];
+ pen.band(&bezier(top, 16), &bezier(bottom, 16), SHEET);
+ pen.line(&[(5.0, 6.0), (3.0, 19.0)], ink);
+ pen.line(&[(19.0, 5.0), (21.0, 18.0)], ink);
+ pen.curve(top, a);
+ pen.curve(bottom, a);
+ }
+ "surface_offset" => {
+ // A wavy sheet and its offset copy (dashed), the distance shown by the arrow.
+ let back = [(6.0, 14.0), (10.0, 11.0), (15.0, 17.0), (21.0, 14.0)];
+ let front = [(3.0, 20.0), (7.0, 17.0), (12.0, 23.0), (18.0, 20.0)];
+ pen.band(&bezier(back, 16), &bezier(front, 16), SHEET);
+ pen.curve(back, ink);
+ pen.curve(front, ink);
+ pen.line(&[(6.0, 14.0), (3.0, 20.0)], ink);
+ pen.line(&[(21.0, 14.0), (18.0, 20.0)], ink);
+ pen.dashed(&bezier(back.map(|(x, y)| (x, y - 9.0)), 16), a);
+ pen.dashed(&bezier(front.map(|(x, y)| (x, y - 9.0)), 16), a);
+ pen.dashed(&[(6.0, 5.0), (3.0, 11.0)], a);
+ pen.dashed(&[(21.0, 5.0), (18.0, 11.0)], a);
+ pen.arrow((12.0, 17.0), (12.0, 8.5), a);
+ }
+ "ruled" => {
+ // A strip hanging off a block's edge (accent), ruled by straight lines.
+ pen.poly(&[(2.0, 12.0), (12.0, 12.0), (12.0, 22.0), (2.0, 22.0)], pen.fill, ink);
+ pen.poly(&[(12.0, 12.0), (21.0, 4.0), (21.0, 14.0), (12.0, 22.0)], SHEET, ink);
+ pen.line(&[(15.0, 9.3), (15.0, 19.3)], a);
+ pen.line(&[(18.0, 6.7), (18.0, 16.7)], a);
+ pen.line(&[(12.0, 12.0), (12.0, 22.0)], a);
+ }
+ "boundary" => {
+ // Four boundary curves (accent) meeting at corners, the patch between them a Coons
+ // patch with its iso lines.
+ let (c00, c10, c11, c01) = ((4.0, 17.0), (10.0, 5.0), (20.0, 7.0), (17.0, 20.0));
+ let left = [c00, (5.0, 12.0), (8.0, 8.0), c10];
+ let top = [c10, (13.0, 4.0), (17.0, 4.0), c11];
+ let right = [c01, (19.0, 16.0), (20.0, 11.0), c11];
+ let bottom = [c00, (8.0, 20.0), (13.0, 21.0), c01];
+ let n = 12;
+ let lerp = |p: (f32, f32), q: (f32, f32), s: f32| (p.0 + (q.0 - p.0) * s, p.1 + (q.1 - p.1) * s);
+ let (b, t) = (bezier(bottom, n), bezier(top, n));
+ // Each row runs across the patch (u) at one height (v) up the left and right sides.
+ let rows: Vec> = bezier(left, n)
+ .into_iter()
+ .zip(bezier(right, n))
+ .enumerate()
+ .map(|(j, (lv, rv))| {
+ let v = j as f32 / n as f32;
+ b.iter()
+ .zip(&t)
+ .enumerate()
+ .map(|(i, (bu, tu))| {
+ let u = i as f32 / n as f32;
+ let corners = lerp(lerp(c00, c01, u), lerp(c10, c11, u), v);
+ let (ruled_v, ruled_u) = (lerp(*bu, *tu, v), lerp(lv, rv, u));
+ (ruled_v.0 + ruled_u.0 - corners.0, ruled_v.1 + ruled_u.1 - corners.1)
+ })
+ .collect()
+ })
+ .collect();
+ for (lo, hi) in rows.iter().zip(rows.iter().skip(1)) {
+ pen.band(lo, hi, SHEET);
+ }
+ for j in [n / 3, 2 * n / 3] {
+ if let Some(r) = rows.get(j) {
+ pen.line(r, SHEET_LINE);
+ }
+ pen.line(&rows.iter().filter_map(|r| r.get(j).copied()).collect::>(), SHEET_LINE);
+ }
+ for c in [left, top, right, bottom] {
+ pen.curve(c, a);
+ }
+ for (x, y) in [c00, c10, c11, c01] {
+ pen.dot(x, y, ink);
+ }
+ }
+ "surface_blend" => {
+ // Two separate sheets at different heights bridged by a smooth strip (accent) that
+ // leaves both edges tangent.
+ let near = [(13.0, 20.0), (8.5, 20.0), (10.5, 11.0), (6.0, 11.0)];
+ let far = near.map(|(x, y)| (x + 4.4, y - 3.6));
+ pen.poly(&[(2.0, 11.0), (6.0, 11.0), (10.4, 7.4), (6.4, 7.4)], SHEET, ink);
+ pen.band(&bezier(near, 16), &bezier(far, 16), SHEET_LIGHT);
+ pen.curve(far, a);
+ pen.poly(&[(13.0, 20.0), (17.0, 20.0), (21.4, 16.4), (17.4, 16.4)], SHEET, ink);
+ pen.curve(near, a);
+ }
+ "untrim" => {
+ // A trimmed sheet whose hole (dashed, accent) is filled back in.
+ pen.poly(&[(3.0, 17.0), (9.0, 5.0), (21.0, 7.0), (16.0, 20.0)], SHEET, ink);
+ let hole = ellipse(12.2, 12.3, 4.2, 3.2, 0.0, std::f32::consts::TAU, 32);
+ pen.poly(hole.get(1..).unwrap_or_default(), SHEET_LIGHT, Color32::TRANSPARENT);
+ pen.dashed(&hole, a);
+ }
+ "unstitch" => {
+ // Two sheets pulled apart from their shared edge.
+ pen.poly(&[(2.0, 18.0), (9.0, 14.0), (9.0, 4.0), (2.0, 8.0)], SHEET, ink);
+ pen.poly(&[(15.0, 14.0), (22.0, 18.0), (22.0, 8.0), (15.0, 4.0)], SHEET_2, ink);
+ pen.arrow((10.8, 11.0), (7.0, 11.0), a);
+ pen.arrow((13.2, 11.0), (17.0, 11.0), a);
+ }
+ "reverse_normal" => {
+ // A sheet with its old normal (faded) and the flipped one (accent).
+ pen.poly(&[(2.0, 14.0), (14.0, 10.0), (22.0, 13.0), (10.0, 18.0)], SHEET, ink);
+ pen.arrow((12.0, 14.0), (12.0, 3.5), ink.gamma_multiply(0.5));
+ pen.arrow((12.0, 14.0), (12.0, 22.5), a);
+ }
+ "surface_fillet" => {
+ // A wall sheet and a floor sheet joined by a round strip (accent edges).
+ let near = [(3.0, 16.0), (3.0, 18.8), (5.2, 21.0), (8.0, 21.0)];
+ let far = near.map(|(x, y)| (x + 4.4, y - 3.6));
+ pen.poly(&[(3.0, 16.0), (3.0, 8.0), (7.4, 4.4), (7.4, 12.4)], SHEET_2, ink);
+ pen.poly(&[(8.0, 21.0), (16.0, 21.0), (20.4, 17.4), (12.4, 17.4)], SHEET, ink);
+ pen.band(&bezier(near, 16), &bezier(far, 16), SHEET_LIGHT);
+ pen.line(&[(4.5, 19.5), (8.9, 15.9)], SHEET_LINE);
+ pen.curve(far, a);
+ pen.curve(near, a);
+ }
+ "continuity" => {
+ // A straight curve joining a curved one (accent) at a point, with the curved one's
+ // curvature comb: teeth that grow as the curve tightens.
+ pen.line(&[(2.0, 17.0), (12.0, 13.0)], ink);
+ let c = [(12.0, 13.0), (16.5, 11.2), (20.0, 8.0), (21.0, 3.0)];
+ let pts = bezier(c, 10);
+ let mut tips = Vec::new();
+ for (i, ((x, y), (x1, y1))) in pts.iter().zip(pts.iter().skip(1)).enumerate() {
+ let len = (x1 - x).hypot(y1 - y).max(1e-6);
+ let h = 0.8 + 4.2 * i as f32 / 10.0;
+ let tip = (x + (y1 - y) / len * h, y - (x1 - x) / len * h);
+ if i % 2 == 0 {
+ pen.line(&[(*x, *y), tip], SHEET_LINE);
+ }
+ tips.push(tip);
+ }
+ pen.line(&tips, SHEET_LINE);
+ pen.curve(c, a);
+ pen.dot(12.0, 13.0, a);
+ }
+ // Assembly: joints (accent: the joint itself).
+ "joint" => {
+ pen.iso_box(2.0, 13.0, 7.0, 5.0, 5.0);
+ pen.iso_box(12.0, 4.0, 7.0, 5.0, 5.0);
+ pen.line(&[(9.0, 15.0), (15.0, 9.0)], a);
+ pen.circle(12.0, 12.0, 2.6, pen.fill, a);
+ }
+ "as_built" => {
+ pen.iso_box(1.0, 10.0, 8.0, 5.0, 8.0);
+ pen.iso_box(10.0, 10.0, 8.0, 5.0, 8.0);
+ pen.circle(13.0, 15.0, 2.6, pen.fill, a);
+ }
+ "joint_origin" => {
+ pen.arrow((8.0, 17.0), (20.0, 17.0), ink);
+ pen.arrow((8.0, 17.0), (14.0, 11.0), ink);
+ pen.arrow((8.0, 17.0), (8.0, 3.0), a);
+ pen.circle(8.0, 17.0, 2.4, pen.fill, a);
+ }
+ "rigid_group" => {
+ pen.iso_box(3.0, 13.0, 5.0, 3.0, 4.0);
+ pen.iso_box(13.0, 13.0, 5.0, 3.0, 4.0);
+ pen.iso_box(8.0, 4.0, 5.0, 3.0, 4.0);
+ pen.closed(&[(2.0, 3.0), (22.0, 3.0), (22.0, 21.0), (2.0, 21.0)], a);
+ }
+ "drive" => {
+ pen.circle(12.0, 12.0, 4.0, pen.fill, ink);
+ pen.dot(12.0, 12.0, ink);
+ pen.arc(12.0, 12.0, 8.5, 0.3, 4.6, a);
+ pen.arrow((11.0, 20.4), (14.5, 20.3), a);
+ }
+ "motion_link" => {
+ pen.circle(7.0, 14.0, 4.5, pen.fill, ink);
+ pen.circle(17.5, 9.0, 3.0, pen.fill, ink);
+ pen.line(&[(7.0, 14.0), (17.5, 9.0)], a);
+ pen.dot(7.0, 14.0, a);
+ pen.dot(17.5, 9.0, a);
+ }
+ "interference" => {
+ pen.closed(&[(3.0, 5.0), (14.0, 5.0), (14.0, 16.0), (3.0, 16.0)], ink);
+ pen.poly(&[(9.0, 10.0), (14.0, 10.0), (14.0, 16.0), (9.0, 16.0)], Color32::from_rgb(225, 70, 60), Color32::from_rgb(225, 70, 60));
+ pen.closed(&[(9.0, 10.0), (21.0, 10.0), (21.0, 20.0), (9.0, 20.0)], ink);
+ }
+ // Sheet metal: thin plates (fill), the made or changed part in the accent.
+ "flange" => {
+ pen.poly(&[(2.0, 17.0), (12.0, 13.0), (18.0, 16.0), (8.0, 20.0)], pen.fill, ink);
+ pen.poly(&[(12.0, 13.0), (12.0, 5.0), (18.0, 8.0), (18.0, 16.0)], pen.fill.gamma_multiply(0.8), a);
+ pen.arrow((20.5, 15.0), (20.5, 4.0), a);
+ }
+ "hem" => {
+ pen.line(&[(2.0, 16.0), (16.0, 16.0)], ink);
+ pen.line(&[(2.0, 13.0), (16.0, 13.0)], ink);
+ pen.arc(16.0, 11.5, 4.5, -1.57, 1.57, a);
+ pen.line(&[(16.0, 7.0), (8.0, 7.0)], a);
+ }
+ // Fastener: a socket head screw seen from the side.
+ "fastener" => {
+ pen.poly(&[(6.0, 3.0), (18.0, 3.0), (18.0, 9.0), (6.0, 9.0)], pen.fill, ink);
+ pen.line(&[(10.0, 3.0), (10.0, 6.0), (14.0, 6.0), (14.0, 3.0)], a);
+ pen.poly(&[(9.0, 9.0), (15.0, 9.0), (15.0, 21.0), (9.0, 21.0)], pen.fill.gamma_multiply(0.85), ink);
+ for y in [12.0, 15.0, 18.0] {
+ pen.line(&[(9.0, y), (15.0, y + 1.5)], ink);
+ }
+ }
+ "fold" => {
+ pen.line(&[(2.0, 18.0), (12.0, 18.0)], ink);
+ pen.line(&[(12.0, 18.0), (19.0, 6.0)], a);
+ pen.line(&[(9.0, 21.0), (15.0, 15.0)], Color32::from_rgb(130, 140, 160));
+ pen.arc(12.0, 18.0, 6.0, 0.2, 1.0, a);
+ pen.arrow((18.0, 16.0), (17.0, 12.5), a);
+ }
+ "unfold" => {
+ pen.line(&[(3.0, 18.0), (12.0, 18.0), (12.0, 7.0)], ink);
+ pen.line(&[(12.0, 18.0), (22.0, 18.0)], a);
+ pen.arc(12.0, 18.0, 7.0, 0.3, 1.4, a);
+ pen.arrow((17.5, 14.0), (19.0, 16.0), a);
+ }
+ "refold" => {
+ pen.line(&[(3.0, 18.0), (12.0, 18.0), (21.0, 18.0)], ink);
+ pen.line(&[(12.0, 18.0), (12.0, 6.0)], a);
+ pen.arc(12.0, 18.0, 7.0, 0.3, 1.4, a);
+ pen.arrow((15.0, 11.5), (13.5, 10.5), a);
+ }
+ "flat" => {
+ pen.poly(&[(3.0, 6.0), (21.0, 6.0), (21.0, 18.0), (3.0, 18.0)], pen.fill, ink);
+ for x in [9.0, 15.0] {
+ for k in 0..3 {
+ let y = 7.5 + k as f32 * 4.0;
+ pen.line(&[(x, y), (x, y + 2.2)], a);
+ }
+ }
+ }
+ "convert_sheet" => {
+ pen.iso_box(2.0, 9.0, 6.0, 4.0, 6.0);
+ pen.arrow((11.0, 12.0), (15.0, 12.0), a);
+ pen.poly(&[(15.0, 15.0), (21.0, 12.0), (23.0, 13.0), (17.0, 16.0)], pen.fill, a);
+ }
+ "sheet_rules" => {
+ pen.poly(&[(2.0, 15.0), (11.0, 11.0), (17.0, 14.0), (8.0, 18.0)], pen.fill, ink);
+ for y in [5.0, 9.0, 13.0] {
+ pen.line(&[(14.0, y), (22.0, y)], a);
+ }
+ }
+ // Plastic: a wall or plate (fill) with the feature in the accent.
+ "boss" => {
+ pen.poly(&[(2.0, 16.0), (13.0, 12.0), (22.0, 16.0), (11.0, 20.0)], pen.fill, ink);
+ pen.poly(&[(9.0, 7.0), (15.0, 7.0), (15.0, 16.0), (9.0, 16.0)], pen.fill.gamma_multiply(0.85), a);
+ pen.circle(12.0, 7.0, 3.0, pen.fill, a);
+ pen.circle(12.0, 7.0, 1.2, ink, ink);
+ }
+ "lip" => {
+ pen.poly(&[(4.0, 21.0), (4.0, 9.0), (10.0, 9.0), (10.0, 21.0)], pen.fill, ink);
+ pen.poly(&[(4.0, 9.0), (4.0, 4.0), (7.0, 4.0), (7.0, 9.0)], pen.fill, a);
+ pen.poly(&[(14.0, 21.0), (14.0, 6.0), (20.0, 6.0), (20.0, 21.0)], pen.fill, ink);
+ pen.closed(&[(17.0, 6.0), (17.0, 11.0), (20.0, 11.0), (20.0, 6.0)], a);
+ }
+ "snap" => {
+ pen.poly(&[(2.0, 20.0), (2.0, 16.0), (22.0, 16.0), (22.0, 20.0)], pen.fill, ink);
+ pen.poly(&[(9.0, 16.0), (9.0, 4.0), (12.0, 4.0), (12.0, 16.0)], pen.fill, a);
+ pen.poly(&[(12.0, 4.0), (16.0, 8.0), (12.0, 8.0)], a, a);
+ }
+ "rest" => {
+ pen.poly(&[(2.0, 16.0), (13.0, 12.0), (22.0, 16.0), (11.0, 20.0)], pen.fill, ink);
+ pen.poly(&[(7.0, 14.0), (13.0, 11.5), (17.0, 13.5), (11.0, 16.0)], a, a);
+ pen.line(&[(7.0, 14.0), (7.0, 11.5), (13.0, 9.0), (17.0, 11.0), (17.0, 13.5)], a);
+ }
+ "plastic_rule" => {
+ pen.circle(7.0, 8.0, 3.2, pen.fill, a);
+ pen.circle(11.0, 13.0, 3.2, pen.fill, a);
+ pen.circle(5.5, 16.0, 3.2, pen.fill, a);
+ for y in [6.0, 11.0, 16.0] {
+ pen.line(&[(15.0, y), (22.0, y)], ink);
+ }
+ }
+ "plastic_assign" => {
+ pen.iso_box(2.0, 12.0, 7.0, 5.0, 6.0);
+ pen.arrow((19.0, 6.0), (13.0, 11.0), a);
+ pen.circle(19.0, 6.0, 3.0, pen.fill, a);
+ }
+ // Appearance: a shaded ball with three paint dabs.
+ "appearance" => {
+ pen.circle(10.0, 13.0, 7.5, pen.fill, ink);
+ pen.arc(10.0, 13.0, 4.5, 1.8, 3.2, Color32::WHITE);
+ pen.circle(18.0, 5.0, 2.6, Color32::from_rgb(220, 70, 60), ink);
+ pen.circle(20.5, 11.0, 2.6, Color32::from_rgb(70, 150, 230), ink);
+ pen.circle(19.0, 17.5, 2.6, Color32::from_rgb(240, 200, 60), ink);
+ }
+ "warning" => {
+ pen.poly(&[(12.0, 3.0), (21.0, 20.0), (3.0, 20.0)], Color32::from_rgb(250, 200, 60), ink);
+ pen.line(&[(12.0, 9.0), (12.0, 14.0)], ink);
+ pen.dot(12.0, 17.0, ink);
+ }
+ _ => {
+ pen.poly(&[(5.0, 5.0), (19.0, 5.0), (19.0, 19.0), (5.0, 19.0)], pen.fill, ink);
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// A debug dump of everything `paint` draws for `name`, for comparing two colour choices
+ /// without depending on epaint's internal shape fields.
+ fn drawn(name: &str, ink: Color32, fill: Color32, accent: Color32) -> String {
+ let ctx = egui::Context::default();
+ let screen = Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0));
+ let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
+ let out = ctx.run_ui(input, |ui| {
+ let r = Rect::from_min_size(Pos2::ZERO, vec2(24.0, 24.0));
+ paint(ui.painter(), r, name, ink, fill, accent);
+ });
+ format!("{:?}", out.shapes)
+ }
+
+ /// Command icons not drawn yet (the fallback square) or drawn past their square, from before
+ /// this check. Remove a name when its icon is fixed; never add one.
+ const KNOWN_GAPS: &[&str] =
+ &["cylinder", "pipe", "offset_face", "thread", "boundary_fill", "coil", "rib", "emboss", "capture", "revert", "camera", "material"];
+
+ /// What is wrong with the icon `name`: not drawn (the fallback square), or drawn past its
+ /// square by more than a stroke width.
+ fn icon_problem(name: &str) -> Option {
+ if drawn(name, Color32::BLACK, Color32::WHITE, Color32::BLUE) == drawn("", Color32::BLACK, Color32::WHITE, Color32::BLUE) {
+ return Some("no icon drawn".into());
+ }
+ let side = 48.0;
+ let r = Rect::from_min_size(Pos2::ZERO, vec2(side, side));
+ let ctx = egui::Context::default();
+ let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0))), ..Default::default() };
+ let out = ctx.run_ui(input, |ui| paint(ui.painter(), r, name, Color32::BLACK, Color32::WHITE, Color32::BLUE));
+ let used = out.shapes.iter().fold(Rect::NOTHING, |b, s| b.union(s.shape.visual_bounding_rect()));
+ (!r.expand(side / 24.0 * 1.4).contains_rect(used)).then(|| format!("drawn over {used:?}"))
+ }
+
+ /// Every command's icon is drawn and stays inside its square.
+ #[test]
+ fn every_command_icon_is_drawn_inside_its_square() {
+ let bad: Vec = solidcraft_engine::command_specs()
+ .into_iter()
+ .filter(|c| !c.icon.is_empty() && !KNOWN_GAPS.contains(&c.icon))
+ .filter_map(|c| icon_problem(c.icon).map(|why| format!("{} ({}): {why}", c.id, c.icon)))
+ .collect();
+ assert!(bad.is_empty(), "{}", bad.join("\n"));
+ for gap in KNOWN_GAPS {
+ assert!(icon_problem(gap).is_some(), "`{gap}` is fixed now: take it off KNOWN_GAPS");
+ }
+ }
+
+ /// The Sketch Palette and the toolbar both rely on this contract. Construction and
+ /// centerline must track whatever accent colour the caller passes (both callers pass the
+ /// theme's construction orange) and must not change with `ink`.
+ #[test]
+ fn construction_and_centerline_icons_follow_the_callers_accent_only() {
+ for name in ["construction", "centerline"] {
+ let base = drawn(name, Color32::BLACK, Color32::WHITE, Color32::from_rgb(1, 2, 3));
+ let other_accent = drawn(name, Color32::BLACK, Color32::WHITE, Color32::from_rgb(4, 5, 6));
+ assert_ne!(base, other_accent, "{name} icon must change with the caller's accent colour");
+ let other_ink = drawn(name, Color32::from_rgb(9, 9, 9), Color32::WHITE, Color32::from_rgb(1, 2, 3));
+ assert_eq!(base, other_ink, "{name} icon has no ink-coloured parts: ink alone must not change it");
+ }
+ }
+
+ /// Normal is a plain line type. It is never construction geometry. Its icon must never pick
+ /// up the accent colour. It can never render construction orange even if a caller passes it
+ /// by mistake.
+ #[test]
+ fn normal_icon_never_uses_the_accent_colour() {
+ let one_accent = drawn("normal", Color32::BLACK, Color32::WHITE, Color32::from_rgb(1, 2, 3));
+ let other_accent = drawn("normal", Color32::BLACK, Color32::WHITE, Color32::from_rgb(4, 5, 6));
+ assert_eq!(one_accent, other_accent, "the Normal icon must not change with the accent colour");
+ let other_ink = drawn("normal", Color32::from_rgb(9, 9, 9), Color32::WHITE, Color32::from_rgb(1, 2, 3));
+ assert_ne!(one_accent, other_ink, "the Normal icon must change with ink");
+ }
+
+ /// The dashes (as their first and last point) and the dots (as centre and radius) `paint`
+ /// actually drew for `name`, at `side` pixels square: the Sketch Palette draws these three
+ /// icons down to about 18 px.
+ fn dashes_and_dots(name: &str, side: f32) -> (Vec<(Pos2, Pos2)>, Vec<(Pos2, f32)>) {
+ let ctx = egui::Context::default();
+ let screen = Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0));
+ let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
+ let r = Rect::from_min_size(Pos2::ZERO, vec2(side, side));
+ let out = ctx.run_ui(input, |ui| {
+ paint(ui.painter(), r, name, Color32::BLACK, Color32::WHITE, Color32::from_rgb(232, 140, 48));
+ });
+ let dashes = out
+ .shapes
+ .iter()
+ .filter_map(|s| match &s.shape {
+ Shape::Path(p) => Some((*p.points.first()?, *p.points.last()?)),
+ _ => None,
+ })
+ .collect();
+ let dots = out.shapes.iter().filter_map(|s| if let Shape::Circle(c) = &s.shape { Some((c.center, c.radius)) } else { None }).collect();
+ (dashes, dots)
+ }
+
+ /// Pins the dash count and the gap. A future tidy-up might add dashes back in. It might
+ /// narrow the gap to look more faithful to the ISO convention. Either change would flatten
+ /// the icon back into a near-solid line at the size the Sketch Palette actually draws it,
+ /// about 18 px square. That is the exact defect this fix addressed.
+ #[test]
+ fn construction_and_centerline_read_as_two_dashes_with_a_gap_wider_than_the_stroke() {
+ for (name, want_dots) in [("construction", 0), ("centerline", 1)] {
+ for side in [18.0, 26.0] {
+ let (dashes, dots) = dashes_and_dots(name, side);
+ assert_eq!(dashes.len(), 2, "{name} at {side}px must be exactly two dashes");
+ assert_eq!(dots.len(), want_dots, "{name} at {side}px has the wrong number of dots");
+ let (_, gap_start) = dashes[0];
+ let (gap_end, _) = dashes[1];
+ let gap = gap_start.distance(gap_end);
+ let stroke = line_type_stroke(Rect::from_min_size(Pos2::ZERO, vec2(side, side)));
+ assert!(
+ gap > stroke * 2.0,
+ "{name} at {side}px: the gap of {gap:.1}px must exceed the {stroke:.1}px stroke by a wide margin, or the dashes read as one solid line"
+ );
+ }
+ }
+ }
+
+ /// Pins the shape of the Centerline dot. It must be large enough to read as a round mark
+ /// beside the stroke. It must sit clear of both dashes. The silhouette must stay line, dot,
+ /// line.
+ #[test]
+ fn centerline_dot_is_a_bold_mark_clear_of_both_dashes() {
+ for side in [18.0, 26.0] {
+ let (dashes, dots) = dashes_and_dots("centerline", side);
+ let (center, radius) = dots[0];
+ let stroke = line_type_stroke(Rect::from_min_size(Pos2::ZERO, vec2(side, side)));
+ assert!(radius > stroke * 1.2, "centerline dot at {side}px: radius {radius:.1}px must exceed the {stroke:.1}px stroke by a wide margin");
+ for (a, b) in &dashes {
+ assert!(center.distance(*a) > radius, "centerline dot at {side}px must not touch a dash end");
+ assert!(center.distance(*b) > radius, "centerline dot at {side}px must not touch a dash end");
+ }
+ }
+ }
+}
diff --git a/assets/icons/ui/source/markers.rs b/assets/icons/ui/source/markers.rs
new file mode 100644
index 00000000..862b2996
--- /dev/null
+++ b/assets/icons/ui/source/markers.rs
@@ -0,0 +1,86 @@
+//! Constraint, assembly snap and dimension marker artwork.
+use crate::dialogs_assembly::SnapKind;
+use crate::theme::Tokens;
+use egui::{Color32, Pos2, Stroke, vec2};
+
+pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option, kind: SnapKind, col: Color32, tag: Option<&str>) {
+ // A dark halo under every stroke keeps the glyph readable on light and dark faces alike.
+ let halo = Stroke::new(4.5, Color32::from_black_alpha(150));
+ let white = Stroke::new(1.5, Color32::WHITE);
+ if let Some(tip) = tip {
+ let d = (tip - c).normalized();
+ let n = vec2(-d.y, d.x);
+ let head = vec![tip + d * 2.0, tip - d * 9.0 + n * 5.0, tip - d * 9.0 - n * 5.0];
+ painter.line_segment([c, tip], halo);
+ painter.add(egui::Shape::convex_polygon(head.clone(), Color32::TRANSPARENT, halo));
+ painter.line_segment([c, tip], Stroke::new(2.0, col));
+ painter.add(egui::Shape::convex_polygon(head, col, Stroke::NONE));
+ }
+ let shape = |fill: Color32, stroke: Stroke, grow: f32| -> egui::Shape {
+ match kind {
+ SnapKind::FaceCenter => egui::Shape::rect_filled(egui::Rect::from_center_size(c, vec2(12.0 + grow, 12.0 + grow)), 1.5, fill),
+ SnapKind::CircleCenter => egui::Shape::circle_filled(c, 7.0 + grow / 2.0, fill),
+ SnapKind::EdgeMid => {
+ let r = 8.0 + grow / 2.0;
+ egui::Shape::convex_polygon(vec![c + vec2(0.0, -r), c + vec2(r * 0.93, r * 0.7), c + vec2(-r * 0.93, r * 0.7)], fill, stroke)
+ }
+ SnapKind::Vertex => {
+ let r = 7.5 + grow / 2.0;
+ egui::Shape::convex_polygon(vec![c + vec2(0.0, -r), c + vec2(r, 0.0), c + vec2(0.0, r), c + vec2(-r, 0.0)], fill, stroke)
+ }
+ }
+ };
+ painter.add(shape(Color32::from_black_alpha(150), Stroke::NONE, 4.0));
+ painter.add(shape(col, Stroke::NONE, 0.0));
+ match kind {
+ SnapKind::FaceCenter => {
+ painter.rect_stroke(egui::Rect::from_center_size(c, vec2(12.0, 12.0)), 1.5, white, egui::StrokeKind::Middle);
+ }
+ SnapKind::CircleCenter => {
+ painter.circle_stroke(c, 7.0, white);
+ painter.line_segment([c - vec2(4.5, 0.0), c + vec2(4.5, 0.0)], Stroke::new(1.2, Color32::WHITE));
+ painter.line_segment([c - vec2(0.0, 4.5), c + vec2(0.0, 4.5)], Stroke::new(1.2, Color32::WHITE));
+ }
+ SnapKind::EdgeMid | SnapKind::Vertex => {
+ painter.add(shape(Color32::TRANSPARENT, white, 0.0));
+ }
+ }
+ // What it snaps to (hovered), or which component it is (picked): a small label.
+ if let Some(tag) = tag {
+ let t = Tokens::get();
+ let font = egui::FontId::proportional(11.5);
+ let g = painter.layout_no_wrap(tag.to_string(), font, t.text);
+ let at = c + vec2(12.0, 10.0);
+ let r = egui::Rect::from_min_size(at, g.size()).expand2(vec2(5.0, 2.0));
+ painter.rect(r, 3.0, t.panel.gamma_multiply(0.92), Stroke::new(1.0, col), egui::StrokeKind::Inside);
+ painter.galley(at, g, t.text);
+ }
+}
+pub(crate) fn glyph_icon(name: &str) -> Option<&'static str> {
+ Some(match name {
+ "Coincident" => "c_coincident",
+ "Parallel" => "c_parallel",
+ "Perpendicular" => "c_perpendicular",
+ "Collinear" => "c_collinear",
+ "Tangent" => "c_tangent",
+ "Smooth" => "c_smooth",
+ "Equal" => "c_equal",
+ "Concentric" => "c_concentric",
+ "MidPoint" => "c_midpoint",
+ "Symmetry" => "c_symmetry",
+ "Fix" => "c_fix",
+ _ => return None,
+ })
+}
+pub(crate) fn glyph_letter(name: &str) -> &'static str {
+ match name {
+ "Horizontal" => "H",
+ "Vertical" => "V",
+ _ => "?",
+ }
+}
+pub(crate) fn dimension_arrow(painter: &egui::Painter, t: Pos2, back: Pos2, col: Color32) {
+ let d = (t - back).normalized();
+ let n = vec2(-d.y, d.x);
+ painter.add(egui::Shape::convex_polygon(vec![t, t - d * 9.0 + n * 3.0, t - d * 9.0 - n * 3.0], col, Stroke::NONE));
+}
diff --git a/assets/icons/ui/source/navigation.rs b/assets/icons/ui/source/navigation.rs
new file mode 100644
index 00000000..fcef90a7
--- /dev/null
+++ b/assets/icons/ui/source/navigation.rs
@@ -0,0 +1,167 @@
+//! Navigation drawing definitions. Camera projection and picking remain in the UI widgets.
+use crate::theme::Tokens;
+use egui::{Align2, Color32, FontId, Pos2, Rect, Shape, Stroke, pos2, vec2};
+use solidcraft_engine::geom::Vec3;
+pub(crate) struct Facet {
+ pub(crate) direction: Vec3,
+ pub(crate) points: Vec,
+ pub(crate) outer_edges: Vec,
+ pub(crate) centre: Pos2,
+ pub(crate) label: String,
+ pub(crate) text_axes: Option<(egui::Vec2, egui::Vec2)>,
+ pub(crate) depth: f64,
+}
+// Private marker for label meshes with atlas-pixel UVs. The output hook replaces
+// it with the font texture before any renderer sees the frame.
+pub(crate) const LABEL_PIXELS: egui::TextureId = egui::TextureId::User(u64::from_le_bytes(*b"cubetext"));
+
+struct LabelAtlas;
+
+impl egui::plugin::Plugin for LabelAtlas {
+ fn debug_name(&self) -> &'static str {
+ "navigation cube label atlas"
+ }
+
+ fn output_hook(&mut self, ctx: &egui::Context, output: &mut egui::FullOutput) {
+ let size = ctx.fonts(|f| f.font_image_size());
+ let normalize = vec2(1.0 / size[0].max(1) as f32, 1.0 / size[1].max(1) as f32);
+ for shape in &mut output.shapes {
+ if let Shape::Mesh(mesh) = &mut shape.shape
+ && mesh.texture_id == LABEL_PIXELS
+ {
+ let mesh = std::sync::Arc::make_mut(mesh);
+ mesh.texture_id = egui::TextureId::default();
+ for v in &mut mesh.vertices {
+ v.uv = (v.uv.to_vec2() * normalize).to_pos2();
+ }
+ }
+ }
+ }
+}
+
+/// Warp the font mesh into the projected face, so labels turn with the model.
+pub(crate) fn face_text(painter: &egui::Painter, face: &Facet, ink: Color32, font_size: f32) {
+ let Some((r, down)) = face.text_axes else { return };
+ let opacity = ((face.depth - 0.18) / 0.17).clamp(0.0, 1.0) as f32;
+ if opacity <= 0.0 {
+ return;
+ }
+ let ink = ink.gamma_multiply(opacity);
+ let galley = painter.layout_no_wrap(face.label.clone(), FontId::proportional(font_size), ink);
+ let centre = galley.rect.center();
+ // Keep atlas-pixel UVs until the frame is complete: another label or widget
+ // can grow the atlas after this mesh is created, invalidating early UVs.
+ painter.ctx().add_plugin(LabelAtlas);
+ let text = Shape::Text(egui::epaint::TextShape::new(Pos2::ZERO, galley, ink));
+ let atlas = painter.ctx().fonts(|f| f.font_image_size());
+ let primitives =
+ painter.ctx().tessellate(vec![egui::epaint::ClippedShape { clip_rect: Rect::EVERYTHING, shape: text }], painter.ctx().pixels_per_point());
+ for primitive in primitives {
+ if let egui::epaint::Primitive::Mesh(mut mesh) = primitive.primitive {
+ mesh.texture_id = LABEL_PIXELS;
+ for v in &mut mesh.vertices {
+ v.uv = pos2(v.uv.x * atlas[0] as f32, v.uv.y * atlas[1] as f32);
+ let local = v.pos - centre;
+ v.pos = face.centre + r * local.x + down * (local.y * 1.35);
+ }
+ painter.add(Shape::mesh(mesh));
+ }
+ }
+}
+
+pub(crate) fn paint_cube(painter: &egui::Painter, faces: &[Facet], hovered: Option, t: Tokens, labels: bool, scale: f32) {
+ // A shared, opaque triangle mesh has no antialiasing fringes between facets.
+ // Per-polygon fringes and miter joins overlap as a face becomes edge-on.
+ let mut fill_mesh = egui::epaint::Mesh::default();
+ let mut edges: Vec<(Pos2, Pos2, usize, bool)> = Vec::new();
+ let hovered_direction = hovered.and_then(|i| faces.get(i)).map(|f| f.direction);
+ for face in faces {
+ let base = t.cube_face;
+ let shade = (0.72 + 0.28 * face.depth.min(1.0)) as f32;
+ let highlighted = hovered_direction == Some(face.direction);
+ let fill = if highlighted {
+ t.cube_hover
+ } else {
+ Color32::from_rgb((f32::from(base.r()) * shade) as u8, (f32::from(base.g()) * shade) as u8, (f32::from(base.b()) * shade) as u8)
+ };
+ let centre = fill_mesh.vertices.len() as u32;
+ fill_mesh.colored_vertex(face.centre, fill);
+ for &p in &face.points {
+ fill_mesh.colored_vertex(p, fill);
+ }
+ for (j, (&a, &b)) in face.points.iter().zip(face.points.iter().cycle().skip(1)).take(face.points.len()).enumerate() {
+ fill_mesh.add_triangle(centre, centre + 1 + j as u32, centre + 1 + ((j + 1) % face.points.len()) as u32);
+ // Coplanar picking regions have no visible seams. Only actual cube
+ // edges and the hovered target's boundary receive an outline.
+ let outer = face.outer_edges.get(j).copied().unwrap_or(false);
+ if !highlighted && !outer {
+ continue;
+ }
+ if let Some(edge) = edges
+ .iter_mut()
+ .find(|(p, q, _, _)| (p.distance_sq(a) < 1e-6 && q.distance_sq(b) < 1e-6) || (p.distance_sq(b) < 1e-6 && q.distance_sq(a) < 1e-6))
+ {
+ edge.2 += usize::from(highlighted);
+ edge.3 |= outer;
+ } else {
+ edges.push((a, b, usize::from(highlighted), outer));
+ }
+ }
+ }
+ painter.add(Shape::mesh(fill_mesh));
+ for (a, b, highlights, outer) in edges {
+ // Two highlighted coplanar patches share an internal subdivision, not
+ // a target boundary. Actual cube edges remain visible across faces.
+ if !outer && highlights != 1 {
+ continue;
+ }
+ let highlighted = highlights > 0;
+ painter.line_segment([a, b], Stroke::new(1.0, if highlighted { t.accent } else { t.cube_border }));
+ }
+ if labels {
+ for face in faces {
+ face_text(painter, face, t.cube_text, 14.0 * scale);
+ }
+ }
+}
+pub(crate) fn arrow(painter: &egui::Painter, at: Pos2, direction: egui::Vec2, hover: bool, t: Tokens) {
+ let side = vec2(-direction.y, direction.x);
+ painter.add(Shape::convex_polygon(
+ vec![at + direction * 7.0, at - direction * 5.0 + side * 7.0, at - direction * 5.0 - side * 7.0],
+ if hover { t.accent_soft } else { t.cube_face },
+ Stroke::new(1.0, if hover { t.accent } else { t.cube_border }),
+ ));
+}
+pub(crate) fn curved_arrow(painter: &egui::Painter, at: Pos2, clockwise: bool, hover: bool, t: Tokens, scale: f32) {
+ let (start, end) =
+ if clockwise { (-std::f32::consts::FRAC_PI_4 + 0.12, 0.0) } else { (-std::f32::consts::FRAC_PI_4 - 0.12, -std::f32::consts::FRAC_PI_2) };
+ let points: Vec<_> = (0..=16)
+ .map(|i| {
+ let angle = egui::lerp(start..=end, f32::from(i as u8) / 16.0);
+ at + vec2(angle.cos(), angle.sin()) * (32.0 * scale)
+ })
+ .collect();
+ painter.add(Shape::line(points.clone(), Stroke::new(5.0 * scale, if hover { t.accent } else { t.cube_border })));
+ painter.add(Shape::line(points.clone(), Stroke::new(3.0 * scale, if hover { t.accent_soft } else { t.cube_face })));
+ if let Some(tip) = points.last() {
+ let sign = if clockwise { 1.0 } else { -1.0 };
+ arrow(painter, *tip, vec2(-end.sin(), end.cos()) * (sign * scale), hover, t);
+ }
+}
+pub(crate) const AXIS_RADIUS: f32 = 11.0;
+pub(crate) fn axis_tip(painter: &egui::Painter, c: Pos2, name: &str, d: &Vec3, at: Pos2, depth: f64, hovered: bool, t: Tokens) {
+ let col = if d.x != 0.0 {
+ solidcraft_engine::view::colors::AXIS_X
+ } else if d.y != 0.0 {
+ solidcraft_engine::view::colors::AXIS_Y
+ } else {
+ solidcraft_engine::view::colors::AXIS_Z
+ };
+ let col = Color32::from_rgb(col.0, col.1, col.2);
+ let positive = !name.starts_with('-');
+ let ink = if depth < -0.01 { col.gamma_multiply(0.55) } else { col };
+ painter.line_segment([c, at], Stroke::new(if positive { 2.0 } else { 1.0 }, ink));
+ painter.circle_filled(at, AXIS_RADIUS, if hovered { t.accent_soft } else { t.overlay });
+ painter.circle_stroke(at, AXIS_RADIUS, Stroke::new(if hovered { 2.0 } else { 1.0 }, if hovered { t.accent } else { ink }));
+ painter.text(at, Align2::CENTER_CENTER, name.to_uppercase(), FontId::proportional(11.0), ink);
+}
diff --git a/assets/icons/ui/source/pointer.rs b/assets/icons/ui/source/pointer.rs
new file mode 100644
index 00000000..1dbb8baf
--- /dev/null
+++ b/assets/icons/ui/source/pointer.rs
@@ -0,0 +1,14 @@
+//! Original agent pointer and label. Animation stays with the UI controller.
+use egui::{Color32, FontId, Pos2, Shape, Stroke, vec2};
+pub(crate) const AGENT: Color32 = Color32::from_rgb(168, 85, 247);
+pub(crate) fn paint(painter: &egui::Painter, p: Pos2) {
+ // An arrow pointer, tip at `p`.
+ let pts: Vec =
+ [(0.0, 0.0), (0.0, 17.0), (4.5, 13.0), (7.5, 20.0), (10.5, 18.8), (7.5, 12.0), (13.0, 12.0)].iter().map(|(x, y)| p + vec2(*x, *y)).collect();
+ painter.add(Shape::convex_polygon(pts.clone(), AGENT, Stroke::NONE));
+ painter.add(Shape::closed_line(pts, Stroke::new(1.5, Color32::WHITE)));
+ let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(11.0), Color32::WHITE);
+ let r = egui::Rect::from_min_size(p + vec2(15.0, 18.0), label.size() + vec2(10.0, 4.0));
+ painter.rect_filled(r, 4.0, AGENT);
+ painter.galley(r.min + vec2(5.0, 2.0), label, Color32::WHITE);
+}
diff --git a/crates/ui-egui/src/locales/de.json b/assets/locales/de.json
similarity index 100%
rename from crates/ui-egui/src/locales/de.json
rename to assets/locales/de.json
diff --git a/crates/ui-egui/src/locales/en.json b/assets/locales/en.json
similarity index 100%
rename from crates/ui-egui/src/locales/en.json
rename to assets/locales/en.json
diff --git a/crates/ui-egui/src/locales/ja.json b/assets/locales/ja.json
similarity index 100%
rename from crates/ui-egui/src/locales/ja.json
rename to assets/locales/ja.json
diff --git a/crates/ui-egui/src/locales/ko.json b/assets/locales/ko.json
similarity index 100%
rename from crates/ui-egui/src/locales/ko.json
rename to assets/locales/ko.json
diff --git a/crates/ui-egui/src/locales/zh.json b/assets/locales/zh.json
similarity index 100%
rename from crates/ui-egui/src/locales/zh.json
rename to assets/locales/zh.json
diff --git a/assets/materials/library.rs b/assets/materials/library.rs
new file mode 100644
index 00000000..83497e97
--- /dev/null
+++ b/assets/materials/library.rs
@@ -0,0 +1,40 @@
+//! Built-in physical materials: densities in g/cm³ and sRGB colours.
+pub const DEFAULT_DENSITY: f64 = 1.29;
+
+/// Physical materials: name and density (g/cm³).
+pub const MATERIALS: [(&str, f64); 14] = [
+ ("Default", DEFAULT_DENSITY),
+ ("Steel", 7.85),
+ ("Stainless Steel", 8.0),
+ ("Aluminum", 2.70),
+ ("Brass", 8.50),
+ ("Copper", 8.96),
+ ("Titanium", 4.43),
+ ("Cast Iron", 7.20),
+ ("ABS Plastic", 1.06),
+ ("PLA", 1.24),
+ ("Nylon", 1.14),
+ ("Polycarbonate", 1.20),
+ ("Oak", 0.75),
+ ("Glass", 2.50),
+];
+
+/// How a physical material looks (sRGB); the default material has no colour of its own.
+pub fn material_color(name: &str) -> Option<[u8; 3]> {
+ Some(match name {
+ "Steel" => [158, 164, 172],
+ "Stainless Steel" => [186, 191, 198],
+ "Aluminum" => [204, 208, 214],
+ "Brass" => [208, 172, 88],
+ "Copper" => [204, 122, 80],
+ "Titanium" => [150, 148, 158],
+ "Cast Iron" => [104, 104, 110],
+ "ABS Plastic" => [222, 222, 216],
+ "PLA" => [110, 160, 222],
+ "Nylon" => [236, 230, 214],
+ "Polycarbonate" => [196, 216, 232],
+ "Oak" => [190, 146, 92],
+ "Glass" => [170, 208, 218],
+ _ => return None,
+ })
+}
diff --git a/packaging/linux/ai.storyteller.solidcraft.desktop b/assets/platforms/linux/ai.storyteller.solidcraft.desktop
similarity index 100%
rename from packaging/linux/ai.storyteller.solidcraft.desktop
rename to assets/platforms/linux/ai.storyteller.solidcraft.desktop
diff --git a/packaging/linux/ai.storyteller.solidcraft.metainfo.xml.in b/assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in
similarity index 100%
rename from packaging/linux/ai.storyteller.solidcraft.metainfo.xml.in
rename to assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in
diff --git a/packaging/linux/ai.storyteller.solidcraft.mime.xml b/assets/platforms/linux/ai.storyteller.solidcraft.mime.xml
similarity index 100%
rename from packaging/linux/ai.storyteller.solidcraft.mime.xml
rename to assets/platforms/linux/ai.storyteller.solidcraft.mime.xml
diff --git a/packaging/macos/Info.plist.in b/assets/platforms/macos/Info.plist.in
similarity index 100%
rename from packaging/macos/Info.plist.in
rename to assets/platforms/macos/Info.plist.in
diff --git a/packaging/macos/README.txt b/assets/platforms/macos/README.txt
similarity index 100%
rename from packaging/macos/README.txt
rename to assets/platforms/macos/README.txt
diff --git a/packaging/windows/solidcraft.en-us.wxl b/assets/platforms/windows/solidcraft.en-us.wxl
similarity index 100%
rename from packaging/windows/solidcraft.en-us.wxl
rename to assets/platforms/windows/solidcraft.en-us.wxl
diff --git a/packaging/windows/solidcraft.wxs b/assets/platforms/windows/solidcraft.wxs
similarity index 100%
rename from packaging/windows/solidcraft.wxs
rename to assets/platforms/windows/solidcraft.wxs
diff --git a/assets/shaders/viewport.wgsl b/assets/shaders/viewport.wgsl
new file mode 100644
index 00000000..e1d7d591
--- /dev/null
+++ b/assets/shaders/viewport.wgsl
@@ -0,0 +1,316 @@
+
+struct U {
+ vp: mat4x4,
+ back: vec4, // xyz = toward the eye, w = 1 for linear output
+ screen: vec4, // viewport width, height (px), depth bias (see `biased_z`), silhouettes on (1)
+ clip: vec4, // section plane: normal, d (zero normal: no section)
+ cap: vec4, // colour of the inside of cut bodies
+ ana: vec4, // surface analysis: mode (1 zebra, 2 draft, 3 curvature), parameter
+ pull: vec4, // draft pull direction
+ acc_u: vec4, // accessibility map axes (xyz, offset)
+ acc_v: vec4,
+ eye: vec4, // eye position, w = 1 for perspective (0: orthographic, looking along -back)
+};
+
+@group(1) @binding(2) var acc_t: texture_2d;
+
+fn clipped(p: vec3) -> bool {
+ return length(u.clip.xyz) > 0.5 && dot(u.clip.xyz, p) > u.clip.w;
+}
+@group(0) @binding(0) var u: U;
+
+fn to_linear(c: vec3) -> vec3 {
+ return select(c, pow((c + vec3(0.055)) / vec3(1.055), vec3(2.4)), c > vec3(0.04045));
+}
+
+fn out_color(c: vec4) -> vec4 {
+ if (u.back.w > 0.5) {
+ return vec4(to_linear(c.rgb), c.a);
+ }
+ return c;
+}
+
+struct TOut {
+ @builtin(position) pos: vec4,
+ @location(0) n: vec3,
+ @location(1) c: vec4,
+ @location(2) wp: vec3,
+};
+
+@vertex
+fn vs_tri(@location(0) p: vec3, @location(1) n: vec3, @location(2) c: vec4) -> TOut {
+ var o: TOut;
+ o.pos = u.vp * vec4(p, 1.0);
+ o.n = n;
+ o.c = c;
+ o.wp = p;
+ return o;
+}
+
+/// Opaque model surfaces: cut by the section plane, the inside shows as a flat cap colour.
+@fragment
+fn fs_solid(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4 {
+ let c = solid(i, front);
+ return c;
+}
+
+struct CutOut {
+ @location(0) c: vec4,
+ @builtin(frag_depth) depth: f32,
+};
+
+/// The section cap (the inside seen through the cut, drawn on the far walls' back faces where
+/// they are the visible surface) again, at the depth of the section plane rather than of the
+/// far wall, so the cut face hides what lies inside the body instead of showing it like a
+/// pane of glass.
+@fragment
+fn fs_solid_cut(i: TOut, @builtin(front_facing) front: bool) -> CutOut {
+ var o: CutOut;
+ o.c = solid(i, front);
+ o.depth = cap_depth(i.wp, i.pos.z);
+ return o;
+}
+
+/// Depth of the section plane on the view ray through `wp` (a far wall seen through the cut),
+/// never behind the wall itself (`z`).
+fn cap_depth(wp: vec3, z: f32) -> f32 {
+ var dir = -u.back.xyz;
+ if (u.eye.w > 0.5) { dir = wp - u.eye.xyz; }
+ let dn = dot(u.clip.xyz, dir);
+ if (abs(dn) < 1e-9) { return z; }
+ let p = wp + dir * ((u.clip.w - dot(u.clip.xyz, wp)) / dn);
+ let c = u.vp * vec4(p, 1.0);
+ if (c.w <= 0.0) { return z; }
+ return clamp(c.z / c.w, 0.0, z);
+}
+
+fn solid(i: TOut, front: bool) -> vec4 {
+ // Derivatives first, in uniform control flow (browsers' WGSL compilers reject them after
+ // a branch that returns; the whole shader module then fails and nothing is drawn).
+ let k = curvature(i);
+ let ndv = dot(normalize(i.n), normalize(u.back.xyz));
+ let fw = fwidth(ndv);
+ if (clipped(i.wp)) { discard; }
+ if (!front && length(u.clip.xyz) > 0.5) {
+ // The cut face in its body's colour (as Fusion's "from component"), tinted toward the
+ // cap colour so it reads as a cut, hatched at 45 degrees on screen.
+ let base = mix(i.c.rgb, u.cap.rgb, 0.35);
+ let h = fract((i.pos.x + i.pos.y) / 9.0);
+ return out_color(vec4(select(base, base * 0.6, h < 0.22), 1.0));
+ }
+ if (u.ana.x > 0.5 && length(i.n) > 0.5) {
+ return analysis(i, k);
+ }
+ let c = shade(i);
+ // Silhouettes (styles with edges): where a curved surface turns away from the eye, a line
+ // about 1.5 px wide, on the visible surface only (depth-correct).
+ if (u.screen.w > 0.5 && length(i.n) > 0.5 && fw > 1e-6) {
+ let a = 1.0 - smoothstep(fw * 0.8, fw * 2.0, abs(ndv));
+ return vec4(mix(c.rgb, out_color(vec4(0.07, 0.08, 0.10, 1.0)).rgb, a), c.a);
+ }
+ return c;
+}
+
+/// How fast the normal turns across the pixel (per mm).
+fn curvature(i: TOut) -> f32 {
+ let nn = normalize(i.n);
+ let kx = length(dpdx(nn)) / max(length(dpdx(i.wp)), 1e-6);
+ let ky = length(dpdy(nn)) / max(length(dpdy(i.wp)), 1e-6);
+ return max(kx, ky);
+}
+
+/// Surface analysis colours: zebra stripes, draft angle bands, curvature map (`k`: the
+/// surface's curvature here, worked out by the caller).
+fn analysis(i: TOut, k: f32) -> vec4 {
+ let v = normalize(u.back.xyz);
+ var n = normalize(i.n);
+ if (dot(n, v) < 0.0) { n = -n; }
+ let lit = 0.55 + 0.45 * max(dot(n, v), 0.0);
+ if (u.ana.x < 1.5) {
+ // Reflections of upright light bars standing around the viewer: the stripe follows
+ // the reflected ray's sideways direction (horizontal on screen).
+ let r = reflect(-v, n);
+ var side = cross(v, vec3(0.0, 0.0, 1.0));
+ if (length(side) < 1e-3) { side = vec3(1.0, 0.0, 0.0); }
+ let s = sin(dot(r, normalize(side)) * u.ana.y * 3.14159265);
+ let c = select(0.08, 0.95, s > 0.0);
+ return out_color(vec4(vec3(c), 1.0));
+ }
+ if (u.ana.x > 4.5) {
+ // Reachable from the access direction: facing it and nothing above.
+ let d = normalize(u.pull.xyz);
+ let size = i32(u.ana.w);
+ let px = vec2(floor(vec2(dot(i.wp, u.acc_u.xyz) + u.acc_u.w, dot(i.wp, u.acc_v.xyz) + u.acc_v.w) * u.ana.z));
+ var top = -1e30;
+ if (px.x >= 0 && px.y >= 0 && px.x < size && px.y < size) {
+ top = textureLoad(acc_t, px, 0).x;
+ }
+ let cn = dot(normalize(i.n), d);
+ let facing = cn > 0.02;
+ // Slack of a pixel and a half, more on slopes (the map holds the pixel's highest point).
+ let slack = u.ana.y * (1.0 + sqrt(max(1.0 - cn * cn, 0.0)) / max(cn, 0.05));
+ let clear = dot(i.wp, d) >= top - slack;
+ var c = vec3(0.85, 0.22, 0.20);
+ if (facing && clear) { c = vec3(0.20, 0.75, 0.30); }
+ return out_color(vec4(c * lit, 1.0));
+ }
+ if (u.ana.x > 3.5) {
+ // A studio around the model: sky above, a dark floor below, a bright horizon band and
+ // two soft light panels, seen in a mirror.
+ let r = reflect(-v, n);
+ var c = mix(vec3(0.32, 0.34, 0.38), vec3(0.80, 0.86, 0.95), smoothstep(-0.05, 0.6, r.z));
+ c = c + vec3(0.6) * exp(-abs(r.z) * 40.0);
+ let side = atan2(r.y, r.x);
+ c = c + vec3(0.9) * smoothstep(0.92, 0.98, cos(side * 2.0)) * smoothstep(0.1, 0.3, r.z) * smoothstep(0.8, 0.6, r.z);
+ if (r.z < -0.02) {
+ // The floor: lighter toward the horizon, with light-panel streaks along it.
+ c = mix(vec3(0.55, 0.56, 0.60), vec3(0.20, 0.20, 0.22), clamp(-r.z * 1.6, 0.0, 1.0));
+ c = c + vec3(0.35) * smoothstep(0.95, 0.99, cos(side * 3.0));
+ }
+ return out_color(vec4(min(c, vec3(1.0)), 1.0));
+ }
+ if (u.ana.x < 2.5) {
+ let a = degrees(asin(clamp(dot(normalize(i.n), normalize(u.pull.xyz)), -1.0, 1.0)));
+ var c = vec3(0.95, 0.80, 0.15);
+ if (a >= u.ana.y) { c = vec3(0.20, 0.75, 0.30); }
+ if (a <= -u.ana.y) { c = vec3(0.85, 0.22, 0.20); }
+ return out_color(vec4(c * lit, 1.0));
+ }
+ // Curvature from how fast the normal turns across the pixel.
+ let t = clamp(k * u.ana.y, 0.0, 1.0);
+ let c = select(mix(vec3(0.10, 0.80, 0.25), vec3(0.90, 0.15, 0.10), t * 2.0 - 1.0),
+ mix(vec3(0.15, 0.35, 0.95), vec3(0.10, 0.80, 0.25), t * 2.0), t < 0.5);
+ return out_color(vec4(c * lit, 1.0));
+}
+
+@fragment
+fn fs_tri(i: TOut) -> @location(0) vec4 {
+ if (clipped(i.wp)) { discard; }
+ return shade(i);
+}
+
+fn shade(i: TOut) -> vec4 {
+ // A zero normal marks flat colour (selection fills, translucent planes).
+ if (length(i.n) < 0.5) {
+ return out_color(i.c);
+ }
+ let v = normalize(u.back.xyz);
+ var n = normalize(i.n);
+ if (dot(n, v) < 0.0) { n = -n; }
+ let key = normalize(v * 0.8 + vec3(-0.3, 0.2, 0.6));
+ let diff = max(dot(n, key), 0.0);
+ let fill = max(dot(n, v), 0.0);
+ let h = normalize(key + v);
+ let spec = pow(max(dot(n, h), 0.0), 40.0) * 0.25;
+ let k = 0.42 + 0.38 * diff + 0.25 * fill;
+ return out_color(vec4(min(i.c.rgb * k + vec3(spec), vec3(1.0)), i.c.a));
+}
+
+/// Clip-space z of `p` (clipped to `c`) pulled `s` depth biases toward the eye (pushed away
+/// when negative). Orthographic depth is linear in distance, so the bias is a fixed step of it.
+/// Perspective depth is not: near the far plane a fixed step spans tens of millimetres (edges
+/// showed through walls), so the point moves toward the eye by a fraction of its distance.
+fn biased_z(p: vec3, c: vec4, s: f32) -> f32 {
+ if (u.eye.w > 0.5) {
+ let q = u.vp * vec4(mix(p, u.eye.xyz, u.screen.z * 8.0 * s), 1.0);
+ return q.z / max(q.w, 1e-6) * c.w;
+ }
+ return c.z - u.screen.z * s * c.w;
+}
+
+@vertex
+fn vs_ghost(@location(0) p: vec3, @location(1) n: vec3, @location(2) c: vec4) -> TOut {
+ var o: TOut;
+ o.pos = u.vp * vec4(p, 1.0);
+ o.pos.z = biased_z(p, o.pos, -4.0);
+ o.n = n;
+ o.c = c;
+ o.wp = p;
+ return o;
+}
+
+struct LOut {
+ @builtin(position) pos: vec4,
+ @location(0) c: vec4,
+ @location(1) wp: vec3,
+};
+
+@vertex
+fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3, @location(1) b: vec3, @location(2) c: vec4, @location(3) w: f32) -> LOut {
+ var pa = a;
+ var pb = b;
+ var ca = u.vp * vec4(a, 1.0);
+ var cb = u.vp * vec4(b, 1.0);
+ // An end behind the near plane (clip z < 0) has no screen point: its divide flips the
+ // line or drops it whole (ground grid lines running past the eye at a low angle). Cut
+ // the segment at the plane instead; clip space is linear in the world point.
+ if (ca.z < 0.0 && cb.z > 0.0) {
+ let k = ca.z / (ca.z - cb.z);
+ ca = mix(ca, cb, k);
+ pa = mix(a, b, k);
+ } else if (cb.z < 0.0 && ca.z > 0.0) {
+ let k = cb.z / (cb.z - ca.z);
+ cb = mix(cb, ca, k);
+ pb = mix(b, a, k);
+ }
+ let half = u.screen.xy * 0.5;
+ let sa = ca.xy / max(ca.w, 1e-6) * half;
+ let sb = cb.xy / max(cb.w, 1e-6) * half;
+ var dir = sb - sa;
+ let len = length(dir);
+ if (len < 1e-6) { dir = vec2(1.0, 0.0); } else { dir = dir / len; }
+ let nrm = vec2(-dir.y, dir.x);
+ // Two triangles: corners (t, side).
+ var ts = array(0.0, 1.0, 1.0, 0.0, 1.0, 0.0);
+ var ss = array(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0);
+ let t = ts[vi];
+ let s = ss[vi];
+ let base = select(ca, cb, t > 0.5);
+ let off = nrm * s * w * 0.5 + dir * (t * 2.0 - 1.0) * w * 0.5;
+ var o: LOut;
+ o.pos = vec4(base.xy + off / half * base.w, biased_z(select(pa, pb, t > 0.5), base, 1.0), base.w);
+ o.c = c;
+ o.wp = select(pa, pb, t > 0.5);
+ return o;
+}
+
+@fragment
+fn fs_line(i: LOut) -> @location(0) vec4 {
+ if (clipped(i.wp)) { discard; }
+ return out_color(i.c);
+}
+
+struct IOut {
+ @builtin(position) pos: vec4,
+ @location(0) uv: vec2,
+ @location(1) a: f32,
+ @location(2) wp: vec3,
+};
+
+@group(1) @binding(0) var img_t: texture_2d;
+@group(1) @binding(1) var img_s: sampler;
+
+@vertex
+fn vs_img(@location(0) p: vec3, @location(1) uv: vec2, @location(2) a: f32) -> IOut {
+ var o: IOut;
+ o.pos = u.vp * vec4(p, 1.0);
+ // Pulled toward the eye like lines, so a decal on a face is not lost in it.
+ o.pos.z = biased_z(p, o.pos, 1.0);
+ o.uv = uv;
+ o.a = a;
+ o.wp = p;
+ return o;
+}
+
+@fragment
+fn fs_img(i: IOut) -> @location(0) vec4 {
+ let c = textureSample(img_t, img_s, i.uv);
+ if (clipped(i.wp)) { discard; }
+ return out_color(vec4(c.rgb, c.a * i.a));
+}
+
+/// Lines the section never cuts (the grid, highlights drawn on top).
+@fragment
+fn fs_line_all(i: LOut) -> @location(0) vec4 {
+ return out_color(i.c);
+}
diff --git a/assets/styles/ui/palettes.rs b/assets/styles/ui/palettes.rs
new file mode 100644
index 00000000..ecbb249d
--- /dev/null
+++ b/assets/styles/ui/palettes.rs
@@ -0,0 +1,137 @@
+//! Original light and dark palette values. Compiled by the UI theme boundary.
+use crate::theme::Tokens;
+use egui::Color32;
+impl Tokens {
+ pub const fn light() -> Tokens {
+ Tokens {
+ app_bar: Color32::from_rgb(52, 56, 64),
+ toolbar: Color32::from_rgb(246, 247, 249),
+ panel: Color32::from_rgb(250, 251, 252),
+ panel_header: Color32::from_rgb(236, 238, 242),
+ border: Color32::from_rgb(208, 212, 220),
+ text: Color32::from_rgb(38, 42, 50),
+ text_dim: Color32::from_rgb(110, 116, 128),
+ accent: Color32::from_rgb(38, 120, 218),
+ accent_soft: Color32::from_rgb(214, 230, 250),
+ sketch_accent: Color32::from_rgb(46, 150, 90),
+ error: Color32::from_rgb(214, 54, 54),
+ warning: Color32::from_rgb(222, 150, 30),
+ icon: Color32::from_rgb(70, 78, 92),
+ icon_fill: Color32::from_rgb(178, 196, 222),
+ timeline: Color32::from_rgb(232, 235, 240),
+ hover: Color32::from_rgb(226, 234, 246),
+ sel_face: Color32::from_rgb(76, 127, 203),
+ sel_edge: Color32::from_rgb(0, 127, 255),
+ sel_edge_rim: Color32::from_rgb(127, 191, 255),
+ hover_edge: Color32::from_rgb(0, 0, 0),
+ hover_edge_halo: Color32::from_rgb(203, 203, 203),
+ hover_face_lift: 40,
+ sel_profile: Color32::from_rgb(26, 84, 167),
+ sel_profile_edge: Color32::from_rgb(47, 109, 198),
+ hover_profile: Color32::from_rgba_unmultiplied_const(75, 129, 253, 48),
+ hover_profile_edge: Color32::from_rgb(75, 129, 253),
+ sel_vertex: Color32::from_rgb(70, 110, 170),
+ origin_plane: Color32::from_rgba_unmultiplied_const(249, 184, 134, 94),
+ origin_plane_edge: Color32::from_rgba_unmultiplied_const(214, 140, 84, 200),
+ origin_plane_hover: Color32::from_rgba_unmultiplied_const(120, 130, 230, 150),
+ origin_point: Color32::from_rgb(214, 215, 218),
+ axis_x: Color32::from_rgb(255, 0, 0),
+ axis_y: Color32::from_rgb(0, 255, 0),
+ axis_z: Color32::from_rgb(2, 2, 248),
+ construction_plane: Color32::from_rgba_unmultiplied_const(249, 184, 134, 70),
+ construction_geom: Color32::from_rgb(232, 140, 48),
+ body: Color32::from_rgb(176, 186, 198),
+ body_edge: Color32::from_rgb(40, 44, 52),
+ manipulator: Color32::from_rgb(40, 110, 220),
+ preview_add: Color32::from_rgb(112, 160, 226),
+ preview_cut: Color32::from_rgba_unmultiplied_const(232, 84, 44, 110),
+ cube_face: Color32::from_rgb(236, 239, 244),
+ cube_border: Color32::from_rgb(85, 94, 110),
+ cube_text: Color32::from_rgb(38, 42, 50),
+ cube_hover: Color32::from_rgb(190, 220, 252),
+ viewport_top: Color32::from_rgb(240, 243, 247),
+ viewport_bottom: Color32::from_rgb(203, 210, 221),
+ grid: Color32::from_rgb(196, 202, 212),
+ grid_major: Color32::from_rgb(170, 178, 190),
+ body_selected: Color32::from_rgb(110, 170, 235),
+ sketch: Color32::from_rgb(30, 90, 200),
+ sketch_fixed: Color32::from_rgb(20, 20, 20),
+ sketch_point: Color32::BLACK,
+ sketch_locked: Color32::from_rgb(0, 140, 60),
+ dimension: Color32::from_rgb(36, 40, 48),
+ dimension_driven: Color32::from_rgb(120, 126, 138),
+ sketch_point_free: Color32::from_rgb(30, 90, 200),
+ section_cap: Color32::from_rgb(214, 128, 72),
+ dialog_bg: Color32::from_rgb(250, 251, 252),
+ dialog_border: Color32::from_rgb(208, 212, 220),
+ chip: Color32::from_rgb(38, 120, 218),
+ heading_bg: Color32::from_rgb(218, 223, 231),
+ subheading_bg: Color32::from_rgb(234, 237, 242),
+ profile_fill: Color32::from_rgba_unmultiplied_const(255, 196, 120, 70),
+ glyph_bg: Color32::WHITE,
+ glyph_edge: Color32::from_rgb(190, 110, 100),
+ glyph_text: Color32::from_rgb(150, 40, 35),
+ overlay: Color32::from_rgba_unmultiplied_const(255, 255, 255, 235),
+ rubber_band: Color32::from_rgb(30, 120, 230),
+ app_bar_text: Color32::from_rgb(230, 234, 242),
+ timeline_item: Color32::WHITE,
+ timeline_item_error: Color32::from_rgb(250, 220, 220),
+ timeline_marker: Color32::from_rgb(70, 76, 88),
+ field: Color32::WHITE,
+ box_window: Color32::from_rgb(38, 120, 218),
+ box_crossing: Color32::from_rgb(40, 160, 80),
+ }
+ }
+ pub const fn dark() -> Tokens {
+ let mut t = Tokens::light();
+ t.app_bar = Color32::from_rgb(28, 31, 38);
+ t.toolbar = Color32::from_rgb(37, 41, 49);
+ t.panel = Color32::from_rgb(39, 43, 52);
+ t.panel_header = Color32::from_rgb(47, 52, 62);
+ t.border = Color32::from_rgb(63, 69, 82);
+ t.text = Color32::from_rgb(222, 226, 232);
+ t.text_dim = Color32::from_rgb(148, 155, 168);
+ t.accent = Color32::from_rgb(58, 136, 238);
+ t.accent_soft = Color32::from_rgb(42, 70, 110);
+ t.sketch_accent = Color32::from_rgb(110, 200, 140);
+ t.error = Color32::from_rgb(240, 100, 96);
+ t.warning = Color32::from_rgb(236, 170, 70);
+ t.icon = Color32::from_rgb(208, 214, 226);
+ t.icon_fill = Color32::from_rgb(86, 112, 150);
+ t.timeline = Color32::from_rgb(33, 37, 45);
+ t.hover = Color32::from_rgb(54, 61, 74);
+ t.hover_edge_halo = Color32::from_rgb(203, 203, 203);
+ t.origin_point = Color32::from_rgb(214, 215, 218);
+ t.viewport_top = Color32::from_rgb(46, 52, 64);
+ t.viewport_bottom = Color32::from_rgb(46, 52, 64);
+ t.grid = Color32::from_rgb(59, 65, 76);
+ t.grid_major = Color32::from_rgb(73, 79, 90);
+ t.body = Color32::from_rgb(152, 156, 162);
+ t.body_edge = Color32::from_rgb(16, 16, 18);
+ t.sketch = Color32::from_rgb(92, 150, 255);
+ t.sketch_fixed = Color32::from_rgb(232, 234, 238);
+ t.sketch_point = Color32::from_rgb(232, 234, 238);
+ t.sketch_locked = Color32::from_rgb(96, 204, 116);
+ t.dimension = Color32::from_rgb(224, 228, 234);
+ t.dimension_driven = Color32::from_rgb(142, 150, 164);
+ t.sketch_point_free = Color32::from_rgb(92, 150, 255);
+ t.profile_fill = Color32::from_rgba_unmultiplied_const(120, 150, 200, 40);
+ t.dialog_bg = Color32::from_rgb(59, 68, 83);
+ t.dialog_border = Color32::from_rgb(72, 83, 99);
+ t.chip = Color32::from_rgb(59, 101, 168);
+ t.heading_bg = Color32::from_rgb(38, 44, 55);
+ t.subheading_bg = Color32::from_rgb(48, 55, 68);
+ t.glyph_bg = Color32::from_rgb(44, 50, 60);
+ t.glyph_edge = Color32::from_rgb(200, 130, 120);
+ t.glyph_text = Color32::from_rgb(240, 165, 155);
+ t.overlay = Color32::from_rgba_unmultiplied_const(34, 38, 46, 240);
+ t.rubber_band = Color32::from_rgb(92, 160, 255);
+ t.timeline_item = Color32::from_rgb(52, 58, 70);
+ t.timeline_item_error = Color32::from_rgb(110, 50, 54);
+ t.timeline_marker = Color32::from_rgb(170, 178, 192);
+ t.field = Color32::from_rgb(30, 34, 41);
+ t.box_window = Color32::from_rgb(70, 150, 255);
+ t.box_crossing = Color32::from_rgb(80, 200, 120);
+ t
+ }
+}
diff --git a/assets/styles/web/loading.css b/assets/styles/web/loading.css
new file mode 100644
index 00000000..9d11f792
--- /dev/null
+++ b/assets/styles/web/loading.css
@@ -0,0 +1,4 @@
+ html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #e8ebf0; }
+ #solidcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
+ #solidcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
+ color: #4a5060; font: 14px system-ui, sans-serif; pointer-events: none; }
diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs
index f43eb22d..15977dd7 100644
--- a/crates/doc/src/appearance.rs
+++ b/crates/doc/src/appearance.rs
@@ -103,28 +103,9 @@ impl Appearances {
}
/// The built-in appearance library: (name, colour, opacity).
-pub const LIBRARY: [(&str, [u8; 3], f64); 20] = [
- ("Aluminum - Satin", [204, 208, 214], 1.0),
- ("Aluminum - Anodized Black", [44, 46, 50], 1.0),
- ("Aluminum - Anodized Blue", [52, 92, 160], 1.0),
- ("Steel - Satin", [158, 164, 172], 1.0),
- ("Stainless Steel - Brushed", [186, 191, 198], 1.0),
- ("Cast Iron", [104, 104, 110], 1.0),
- ("Brass - Polished", [208, 172, 88], 1.0),
- ("Copper", [204, 122, 80], 1.0),
- ("Titanium", [150, 148, 158], 1.0),
- ("ABS - White", [236, 236, 230], 1.0),
- ("ABS - Black", [36, 36, 38], 1.0),
- ("ABS - Red", [196, 40, 40], 1.0),
- ("ABS - Blue", [40, 90, 186], 1.0),
- ("ABS - Yellow", [236, 196, 40], 1.0),
- ("ABS - Green", [52, 150, 80], 1.0),
- ("Rubber - Black", [28, 28, 28], 1.0),
- ("Glass - Clear", [190, 220, 228], 0.25),
- ("Glass - Smoked", [90, 96, 104], 0.45),
- ("Wood - Oak", [190, 146, 92], 1.0),
- ("Paint - Gloss White", [246, 246, 244], 1.0),
-];
+#[path = "../../../assets/appearances/library.rs"]
+mod defaults;
+pub use defaults::LIBRARY;
/// A library appearance by name (case-insensitive).
pub fn library(name: &str) -> Option {
diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs
index a4a03a9f..ab74d864 100644
--- a/crates/doc/src/document.rs
+++ b/crates/doc/src/document.rs
@@ -2672,45 +2672,9 @@ impl Document {
}
/// Density of the default material (g/cm³), as Fusion's default.
-pub const DEFAULT_DENSITY: f64 = 1.29;
-
-/// Physical materials: name and density (g/cm³).
-pub const MATERIALS: [(&str, f64); 14] = [
- ("Default", DEFAULT_DENSITY),
- ("Steel", 7.85),
- ("Stainless Steel", 8.0),
- ("Aluminum", 2.70),
- ("Brass", 8.50),
- ("Copper", 8.96),
- ("Titanium", 4.43),
- ("Cast Iron", 7.20),
- ("ABS Plastic", 1.06),
- ("PLA", 1.24),
- ("Nylon", 1.14),
- ("Polycarbonate", 1.20),
- ("Oak", 0.75),
- ("Glass", 2.50),
-];
-
-/// How a physical material looks (sRGB); the default material has no colour of its own.
-pub fn material_color(name: &str) -> Option<[u8; 3]> {
- Some(match name {
- "Steel" => [158, 164, 172],
- "Stainless Steel" => [186, 191, 198],
- "Aluminum" => [204, 208, 214],
- "Brass" => [208, 172, 88],
- "Copper" => [204, 122, 80],
- "Titanium" => [150, 148, 158],
- "Cast Iron" => [104, 104, 110],
- "ABS Plastic" => [222, 222, 216],
- "PLA" => [110, 160, 222],
- "Nylon" => [236, 230, 214],
- "Polycarbonate" => [196, 216, 232],
- "Oak" => [190, 146, 92],
- "Glass" => [170, 208, 218],
- _ => return None,
- })
-}
+#[path = "../../../assets/materials/library.rs"]
+mod materials;
+pub use materials::{DEFAULT_DENSITY, MATERIALS, material_color};
/// A physical material defined in a design (`material.create`): its name, its density and,
/// optionally, the look it gives bodies without an appearance of their own.
diff --git a/crates/engine/src/cmd/sketch_modify_tests.rs b/crates/engine/src/cmd/sketch_modify_tests.rs
index eecfc65a..37004cf7 100644
--- a/crates/engine/src/cmd/sketch_modify_tests.rs
+++ b/crates/engine/src/cmd/sketch_modify_tests.rs
@@ -500,7 +500,7 @@ fn text_takes_a_font_and_edit_text_keeps_or_changes_it() {
// The repository's second font is BIZ UDPGothic. Its "I" is a polygon of two 606 x 145 bars and a 188 x 1369
// stem in font units, with a capital height of 1567 units, so at 10 mm its area is
// (2 x 606 x 145 + 188 x 1369) x (10 / 1567)^2 = 17.6385 mm². The built-in Liberation Sans "I" is 13.5557 mm².
- let font = concat!(env!("CARGO_MANIFEST_DIR"), "/../../apps/solidcraft-web/fonts/BIZUDPGothic-Regular.ttf");
+ let font = concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf");
let near = |s: &Session, want: f64| {
let a = profiles(s);
assert!(a.len() == 1 && (a[0] - want).abs() < 1e-3, "{a:?} against {want}");
diff --git a/crates/sketch/src/font_tests.rs b/crates/sketch/src/font_tests.rs
index c384d06b..d5076966 100644
--- a/crates/sketch/src/font_tests.rs
+++ b/crates/sketch/src/font_tests.rs
@@ -1,5 +1,5 @@
//! Font choice for sketch text. A small TrueType font is built here, so the outlines it must give are
-//! known by hand; the repository's second font (BIZ UDPGothic, `apps/solidcraft-web/fonts`) stands for
+//! known by hand; the repository's second font (BIZ UDPGothic, `assets/fonts/biz-udp-gothic`) stands for
//! a real one.
use std::path::PathBuf;
@@ -164,7 +164,7 @@ impl Drop for Folder {
}
fn web_fonts() -> PathBuf {
- PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..").join("..").join("apps").join("solidcraft-web").join("fonts")
+ PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/biz-udp-gothic")
}
/// The corner points of the lines of `geometry`, as (min, max) over x and y.
@@ -321,7 +321,11 @@ fn installed_families_are_listed_by_name_once_without_the_built_in_one() {
// A second copy of the tiny font, the built-in font and the repository's second font: each family once,
// sorted; the built-in family is not among the installed ones, broken files and other files are skipped.
std::fs::write(folder.0.join("copy.ttf"), tiny_font()).unwrap();
- std::fs::copy(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fonts").join("LiberationSans-Regular.ttf"), folder.0.join("lib.ttf")).unwrap();
+ std::fs::copy(
+ PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/liberation-sans/LiberationSans-Regular.ttf"),
+ folder.0.join("lib.ttf"),
+ )
+ .unwrap();
std::fs::copy(web_fonts().join("BIZUDPGothic-Regular.ttf"), folder.0.join("biz.ttf")).unwrap();
let families = crate::families_in(std::slice::from_ref(&folder.0));
assert_eq!(families, ["BIZ UDPGothic", "Tiny Block"]);
diff --git a/crates/sketch/src/fonts.rs b/crates/sketch/src/fonts.rs
index 14682007..5843ce19 100644
--- a/crates/sketch/src/fonts.rs
+++ b/crates/sketch/src/fonts.rs
@@ -8,7 +8,7 @@ use std::sync::{Arc, Mutex, OnceLock};
use crate::model::{Result, SketchError};
-static DEFAULT_FONT: &[u8] = include_bytes!("../fonts/LiberationSans-Regular.ttf");
+static DEFAULT_FONT: &[u8] = include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/liberation-sans/LiberationSans-Regular.ttf"));
/// Family name of the built-in font (also what an empty or absent font name means).
pub const DEFAULT_FONT_FAMILY: &str = "Liberation Sans";
diff --git a/crates/sketch/src/text.rs b/crates/sketch/src/text.rs
index 7f001a8e..d5388758 100644
--- a/crates/sketch/src/text.rs
+++ b/crates/sketch/src/text.rs
@@ -3,7 +3,7 @@
//! OpenType (CFF) cubic Béziers are cubic control splines, both exact. A glyph's counters (the
//! holes of A, B, 8…) are loops inside its outline; profile detection makes them holes.
//!
-//! The default font is embedded (Liberation Sans, SIL OFL 1.1; see `fonts/OFL.txt` and
+//! The default font is embedded (Liberation Sans, SIL OFL 1.1; see `assets/fonts/liberation-sans/OFL.txt` and
//! ATTRIBUTION.md); [`crate::fonts`] finds others by file path or installed family name.
use serde::{Deserialize, Serialize};
@@ -435,7 +435,7 @@ mod tests {
#[test]
fn a_font_file_gives_the_same_outlines_and_unknown_fonts_are_errors() {
- let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fonts").join("LiberationSans-Regular.ttf");
+ let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/liberation-sans/LiberationSans-Regular.ttf");
let by_file = text_geometry_in(Some(&path.to_string_lossy()), "A7_L", Vec2::ZERO, 8.0, 0.0).unwrap();
assert_eq!(by_file, text_geometry("A7_L", Vec2::ZERO, 8.0, 0.0).unwrap());
let e = text_geometry_in(Some("No Such Family 12345"), "A", Vec2::ZERO, 8.0, 0.0).unwrap_err().to_string();
diff --git a/crates/ui-egui/src/agent_cursor.rs b/crates/ui-egui/src/agent_cursor.rs
index 5f76a728..daf27398 100644
--- a/crates/ui-egui/src/agent_cursor.rs
+++ b/crates/ui-egui/src/agent_cursor.rs
@@ -13,7 +13,9 @@
use std::collections::VecDeque;
use std::sync::mpsc::Sender;
-use egui::{Color32, FontId, Pos2, Shape, Stroke, pos2, vec2};
+#[cfg(test)]
+use egui::vec2;
+use egui::{Pos2, Stroke, pos2};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use solidcraft_engine::geom::{Vec2, Vec3};
@@ -332,7 +334,7 @@ fn finish(app: &mut SolveApp, ctx: &egui::Context) {
}
/// The pointer's colour: a violet no part of the UI uses.
-const AGENT: Color32 = Color32::from_rgb(168, 85, 247);
+use crate::icons::pointer::AGENT;
/// Draw the pointer, its "Agent" tag and the click ripples over everything.
pub fn paint(app: &SolveApp, ctx: &egui::Context) {
@@ -345,15 +347,7 @@ pub fn paint(app: &SolveApp, ctx: &egui::Context) {
let k = ((app.now - t0) / 0.6).clamp(0.0, 1.0) as f32;
painter.circle_stroke(*at, 6.0 + 22.0 * k, Stroke::new(3.0 * (1.0 - k) + 0.5, AGENT.gamma_multiply(1.0 - k)));
}
- // An arrow pointer, tip at `p`.
- let pts: Vec =
- [(0.0, 0.0), (0.0, 17.0), (4.5, 13.0), (7.5, 20.0), (10.5, 18.8), (7.5, 12.0), (13.0, 12.0)].iter().map(|(x, y)| p + vec2(*x, *y)).collect();
- painter.add(Shape::convex_polygon(pts.clone(), AGENT, Stroke::NONE));
- painter.add(Shape::closed_line(pts, Stroke::new(1.5, Color32::WHITE)));
- let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(11.0), Color32::WHITE);
- let r = egui::Rect::from_min_size(p + vec2(15.0, 18.0), label.size() + vec2(10.0, 4.0));
- painter.rect_filled(r, 4.0, AGENT);
- painter.galley(r.min + vec2(5.0, 2.0), label, Color32::WHITE);
+ crate::icons::pointer::paint(&painter, p);
}
#[cfg(test)]
diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs
index c2544ef0..7e6ff5d0 100644
--- a/crates/ui-egui/src/browser.rs
+++ b/crates/ui-egui/src/browser.rs
@@ -10,6 +10,7 @@
#[path = "occurrence_ui_tests.rs"]
mod occurrence_ui_tests;
+use crate::icons::glyphs::badge;
use std::collections::BTreeSet;
use egui::{Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, pos2, vec2};
@@ -338,13 +339,8 @@ pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
// Fold arrow.
if let Some(open) = row.fold {
let ar = Rect::from_center_size(pos2(x + 6.0, cy), vec2(12.0, 12.0));
- let pts = if open {
- vec![pos2(ar.left() + 2.0, ar.top() + 4.0), pos2(ar.right() - 2.0, ar.top() + 4.0), pos2(ar.center().x, ar.bottom() - 3.0)]
- } else {
- vec![pos2(ar.left() + 4.0, ar.top() + 2.0), pos2(ar.right() - 3.0, ar.center().y), pos2(ar.left() + 4.0, ar.bottom() - 2.0)]
- };
let ink = if hover_on == 1 { t.text } else { t.text_dim };
- ui.painter().add(egui::Shape::convex_polygon(pts, ink, Stroke::NONE));
+ crate::icons::glyphs::disclosure(ui.painter(), ar, open, ink);
out.fold = resp.clicked() && click_on == 1;
crate::scenario::publish_handle(&format!("fold:{}", row.label), fold_r.center());
}
@@ -407,35 +403,6 @@ pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
out
}
-/// Small glyphs after a row's label: a padlock, a pin (grounded).
-fn badge(p: &egui::Painter, r: Rect, name: &str, c: Color32) {
- let s = Stroke::new(1.3, c);
- match name {
- "lock" => {
- let body = Rect::from_min_max(pos2(r.left() + 1.5, r.center().y - 0.5), pos2(r.right() - 1.5, r.bottom() - 0.5));
- p.rect_filled(body, 1.5, c);
- let top = pos2(r.center().x, r.top() + 4.0);
- p.add(egui::Shape::line(
- (0..=8)
- .map(|k| {
- let a = std::f32::consts::PI * (k as f32 / 8.0);
- pos2(top.x - 3.2 * a.cos(), top.y - 3.2 * a.sin() + 1.0)
- })
- .chain([pos2(top.x + 3.2, body.top())])
- .collect(),
- s,
- ));
- p.line_segment([pos2(top.x - 3.2, top.y + 1.0), pos2(top.x - 3.2, body.top())], s);
- }
- "pin" => {
- let head = pos2(r.center().x + 2.0, r.top() + 4.0);
- p.circle_filled(head, 3.2, c);
- p.line_segment([head, pos2(r.left() + 1.5, r.bottom() - 1.0)], Stroke::new(1.6, c));
- }
- _ => {}
- }
-}
-
/// A Ctrl/Shift-aware click on a selectable row.
fn click_select(app: &mut SolveApp, ui: &egui::Ui, key: String, sel: Option) {
let mods = ui.input(|i| i.modifiers);
@@ -1860,9 +1827,7 @@ pub fn dims_overlay(app: &SolveApp, ctx: &egui::Context) {
}
for (tip, dir) in &lay.arrows {
if let (Some(t), Some(back)) = (to(*tip), to(*tip - *dir * px)) {
- let d = (t - back).normalized();
- let n = vec2(-d.y, d.x);
- painter.add(egui::Shape::convex_polygon(vec![t, t - d * 9.0 + n * 3.0, t - d * 9.0 - n * 3.0], col, Stroke::NONE));
+ crate::icons::markers::dimension_arrow(&painter, t, back, col);
}
}
let Some(tc) = to(lay.text) else { continue };
diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs
index 466b6d6a..28a5d7c1 100644
--- a/crates/ui-egui/src/context_menu.rs
+++ b/crates/ui-egui/src/context_menu.rs
@@ -1439,16 +1439,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let painter = ui.painter_at(r.expand(4.0));
let font = FontId::proportional(13.0);
// Centre: a ring with a pointer toward the hot direction.
- painter.circle(c, 15.0, t.panel.gamma_multiply(0.92), Stroke::new(1.0, t.border));
- if let (Some(k), Some(p)) = (hot, pointer) {
- let d = (p - c).normalized();
- painter.line_segment(
- [c + d * 6.0, c + d * 15.0],
- Stroke::new(3.0, if radial.get(k).is_some_and(|i| i.enabled) { t.accent } else { t.border }),
- );
- } else {
- painter.circle_filled(c, 3.0, t.text_dim);
- }
+ let direction = hot.zip(pointer).map(|(k, p)| ((p - c).normalized(), radial.get(k).is_some_and(|i| i.enabled)));
+ crate::icons::glyphs::radial_centre(&painter, c, direction, t);
for (k, item) in radial.iter().enumerate() {
let display_label = crate::i18n::tr_label(&item.label);
let galley = painter.layout_no_wrap(display_label.to_string(), font.clone(), if item.enabled { t.text } else { t.text_dim });
diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs
index 732b58be..1db5b1e8 100644
--- a/crates/ui-egui/src/dialogs_assembly.rs
+++ b/crates/ui-egui/src/dialogs_assembly.rs
@@ -921,57 +921,7 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia
fn glyph(painter: &egui::Painter, proj: &Proj, s: &SnapPt, col: Color32, half_height: f64, tag: Option<&str>) {
let Some(c) = proj.to_screen(s.at) else { return };
let len = half_height * 2.0 * 0.06;
- // A dark halo under every stroke keeps the glyph readable on light and dark faces alike.
- let halo = Stroke::new(4.5, Color32::from_black_alpha(150));
- let white = Stroke::new(1.5, Color32::WHITE);
- if let Some(tip) = proj.to_screen(s.at + s.z * len) {
- let d = (tip - c).normalized();
- let n = vec2(-d.y, d.x);
- let head = vec![tip + d * 2.0, tip - d * 9.0 + n * 5.0, tip - d * 9.0 - n * 5.0];
- painter.line_segment([c, tip], halo);
- painter.add(egui::Shape::convex_polygon(head.clone(), Color32::TRANSPARENT, halo));
- painter.line_segment([c, tip], Stroke::new(2.0, col));
- painter.add(egui::Shape::convex_polygon(head, col, Stroke::NONE));
- }
- let shape = |fill: Color32, stroke: Stroke, grow: f32| -> egui::Shape {
- match s.kind {
- SnapKind::FaceCenter => egui::Shape::rect_filled(egui::Rect::from_center_size(c, vec2(12.0 + grow, 12.0 + grow)), 1.5, fill),
- SnapKind::CircleCenter => egui::Shape::circle_filled(c, 7.0 + grow / 2.0, fill),
- SnapKind::EdgeMid => {
- let r = 8.0 + grow / 2.0;
- egui::Shape::convex_polygon(vec![c + vec2(0.0, -r), c + vec2(r * 0.93, r * 0.7), c + vec2(-r * 0.93, r * 0.7)], fill, stroke)
- }
- SnapKind::Vertex => {
- let r = 7.5 + grow / 2.0;
- egui::Shape::convex_polygon(vec![c + vec2(0.0, -r), c + vec2(r, 0.0), c + vec2(0.0, r), c + vec2(-r, 0.0)], fill, stroke)
- }
- }
- };
- painter.add(shape(Color32::from_black_alpha(150), Stroke::NONE, 4.0));
- painter.add(shape(col, Stroke::NONE, 0.0));
- match s.kind {
- SnapKind::FaceCenter => {
- painter.rect_stroke(egui::Rect::from_center_size(c, vec2(12.0, 12.0)), 1.5, white, egui::StrokeKind::Middle);
- }
- SnapKind::CircleCenter => {
- painter.circle_stroke(c, 7.0, white);
- painter.line_segment([c - vec2(4.5, 0.0), c + vec2(4.5, 0.0)], Stroke::new(1.2, Color32::WHITE));
- painter.line_segment([c - vec2(0.0, 4.5), c + vec2(0.0, 4.5)], Stroke::new(1.2, Color32::WHITE));
- }
- SnapKind::EdgeMid | SnapKind::Vertex => {
- painter.add(shape(Color32::TRANSPARENT, white, 0.0));
- }
- }
- // What it snaps to (hovered), or which component it is (picked): a small label.
- if let Some(tag) = tag {
- let t = Tokens::get();
- let font = egui::FontId::proportional(11.5);
- let g = painter.layout_no_wrap(tag.to_string(), font, t.text);
- let at = c + vec2(12.0, 10.0);
- let r = egui::Rect::from_min_size(at, g.size()).expand2(vec2(5.0, 2.0));
- painter.rect(r, 3.0, t.panel.gamma_multiply(0.92), Stroke::new(1.0, col), egui::StrokeKind::Inside);
- painter.galley(at, g, t.text);
- }
+ crate::icons::markers::snap(painter, c, proj.to_screen(s.at + s.z * len), s.kind, col, tag);
}
/// What a snap is called (the hover label).
diff --git a/crates/ui-egui/src/dim_view.rs b/crates/ui-egui/src/dim_view.rs
index b57c77aa..a596b081 100644
--- a/crates/ui-egui/src/dim_view.rs
+++ b/crates/ui-egui/src/dim_view.rs
@@ -528,9 +528,7 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
}
for (tip, dir) in &lay.arrows {
if let (Some(t), Some(back)) = (to(*tip), to(*tip - *dir * px)) {
- let d = (t - back).normalized();
- let n = vec2(-d.y, d.x);
- painter.add(Shape::convex_polygon(vec![t, t - d * 9.0 + n * 3.0, t - d * 9.0 - n * 3.0], col, Stroke::NONE));
+ crate::icons::markers::dimension_arrow(painter, t, back, col);
}
}
let Some(tc) = to(lay.text) else { continue };
diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs
index 75b64842..62fa2935 100644
--- a/crates/ui-egui/src/gpu.rs
+++ b/crates/ui-egui/src/gpu.rs
@@ -371,323 +371,7 @@ fn access_texture(device: &wgpu::Device, queue: Option<&wgpu::Queue>, bgl: &wgpu
})
}
-const SHADER: &str = r#"
-struct U {
- vp: mat4x4,
- back: vec4, // xyz = toward the eye, w = 1 for linear output
- screen: vec4, // viewport width, height (px), depth bias (see `biased_z`), silhouettes on (1)
- clip: vec4, // section plane: normal, d (zero normal: no section)
- cap: vec4, // colour of the inside of cut bodies
- ana: vec4, // surface analysis: mode (1 zebra, 2 draft, 3 curvature), parameter
- pull: vec4, // draft pull direction
- acc_u: vec4, // accessibility map axes (xyz, offset)
- acc_v: vec4,
- eye: vec4, // eye position, w = 1 for perspective (0: orthographic, looking along -back)
-};
-
-@group(1) @binding(2) var acc_t: texture_2d;
-
-fn clipped(p: vec3) -> bool {
- return length(u.clip.xyz) > 0.5 && dot(u.clip.xyz, p) > u.clip.w;
-}
-@group(0) @binding(0) var u: U;
-
-fn to_linear(c: vec3) -> vec3 {
- return select(c, pow((c + vec3(0.055)) / vec3(1.055), vec3(2.4)), c > vec3(0.04045));
-}
-
-fn out_color(c: vec4) -> vec4 {
- if (u.back.w > 0.5) {
- return vec4(to_linear(c.rgb), c.a);
- }
- return c;
-}
-
-struct TOut {
- @builtin(position) pos: vec4,
- @location(0) n: vec3,
- @location(1) c: vec4,
- @location(2) wp: vec3,
-};
-
-@vertex
-fn vs_tri(@location(0) p: vec3, @location(1) n: vec3, @location(2) c: vec4) -> TOut {
- var o: TOut;
- o.pos = u.vp * vec4(p, 1.0);
- o.n = n;
- o.c = c;
- o.wp = p;
- return o;
-}
-
-/// Opaque model surfaces: cut by the section plane, the inside shows as a flat cap colour.
-@fragment
-fn fs_solid(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4 {
- let c = solid(i, front);
- return c;
-}
-
-struct CutOut {
- @location(0) c: vec4,
- @builtin(frag_depth) depth: f32,
-};
-
-/// The section cap (the inside seen through the cut, drawn on the far walls' back faces where
-/// they are the visible surface) again, at the depth of the section plane rather than of the
-/// far wall, so the cut face hides what lies inside the body instead of showing it like a
-/// pane of glass.
-@fragment
-fn fs_solid_cut(i: TOut, @builtin(front_facing) front: bool) -> CutOut {
- var o: CutOut;
- o.c = solid(i, front);
- o.depth = cap_depth(i.wp, i.pos.z);
- return o;
-}
-
-/// Depth of the section plane on the view ray through `wp` (a far wall seen through the cut),
-/// never behind the wall itself (`z`).
-fn cap_depth(wp: vec3, z: f32) -> f32 {
- var dir = -u.back.xyz;
- if (u.eye.w > 0.5) { dir = wp - u.eye.xyz; }
- let dn = dot(u.clip.xyz, dir);
- if (abs(dn) < 1e-9) { return z; }
- let p = wp + dir * ((u.clip.w - dot(u.clip.xyz, wp)) / dn);
- let c = u.vp * vec4(p, 1.0);
- if (c.w <= 0.0) { return z; }
- return clamp(c.z / c.w, 0.0, z);
-}
-
-fn solid(i: TOut, front: bool) -> vec4 {
- // Derivatives first, in uniform control flow (browsers' WGSL compilers reject them after
- // a branch that returns; the whole shader module then fails and nothing is drawn).
- let k = curvature(i);
- let ndv = dot(normalize(i.n), normalize(u.back.xyz));
- let fw = fwidth(ndv);
- if (clipped(i.wp)) { discard; }
- if (!front && length(u.clip.xyz) > 0.5) {
- // The cut face in its body's colour (as Fusion's "from component"), tinted toward the
- // cap colour so it reads as a cut, hatched at 45 degrees on screen.
- let base = mix(i.c.rgb, u.cap.rgb, 0.35);
- let h = fract((i.pos.x + i.pos.y) / 9.0);
- return out_color(vec4(select(base, base * 0.6, h < 0.22), 1.0));
- }
- if (u.ana.x > 0.5 && length(i.n) > 0.5) {
- return analysis(i, k);
- }
- let c = shade(i);
- // Silhouettes (styles with edges): where a curved surface turns away from the eye, a line
- // about 1.5 px wide, on the visible surface only (depth-correct).
- if (u.screen.w > 0.5 && length(i.n) > 0.5 && fw > 1e-6) {
- let a = 1.0 - smoothstep(fw * 0.8, fw * 2.0, abs(ndv));
- return vec4(mix(c.rgb, out_color(vec4(0.07, 0.08, 0.10, 1.0)).rgb, a), c.a);
- }
- return c;
-}
-
-/// How fast the normal turns across the pixel (per mm).
-fn curvature(i: TOut) -> f32 {
- let nn = normalize(i.n);
- let kx = length(dpdx(nn)) / max(length(dpdx(i.wp)), 1e-6);
- let ky = length(dpdy(nn)) / max(length(dpdy(i.wp)), 1e-6);
- return max(kx, ky);
-}
-
-/// Surface analysis colours: zebra stripes, draft angle bands, curvature map (`k`: the
-/// surface's curvature here, worked out by the caller).
-fn analysis(i: TOut, k: f32) -> vec4 {
- let v = normalize(u.back.xyz);
- var n = normalize(i.n);
- if (dot(n, v) < 0.0) { n = -n; }
- let lit = 0.55 + 0.45 * max(dot(n, v), 0.0);
- if (u.ana.x < 1.5) {
- // Reflections of upright light bars standing around the viewer: the stripe follows
- // the reflected ray's sideways direction (horizontal on screen).
- let r = reflect(-v, n);
- var side = cross(v, vec3(0.0, 0.0, 1.0));
- if (length(side) < 1e-3) { side = vec3(1.0, 0.0, 0.0); }
- let s = sin(dot(r, normalize(side)) * u.ana.y * 3.14159265);
- let c = select(0.08, 0.95, s > 0.0);
- return out_color(vec4(vec3(c), 1.0));
- }
- if (u.ana.x > 4.5) {
- // Reachable from the access direction: facing it and nothing above.
- let d = normalize(u.pull.xyz);
- let size = i32(u.ana.w);
- let px = vec2(floor(vec2(dot(i.wp, u.acc_u.xyz) + u.acc_u.w, dot(i.wp, u.acc_v.xyz) + u.acc_v.w) * u.ana.z));
- var top = -1e30;
- if (px.x >= 0 && px.y >= 0 && px.x < size && px.y < size) {
- top = textureLoad(acc_t, px, 0).x;
- }
- let cn = dot(normalize(i.n), d);
- let facing = cn > 0.02;
- // Slack of a pixel and a half, more on slopes (the map holds the pixel's highest point).
- let slack = u.ana.y * (1.0 + sqrt(max(1.0 - cn * cn, 0.0)) / max(cn, 0.05));
- let clear = dot(i.wp, d) >= top - slack;
- var c = vec3(0.85, 0.22, 0.20);
- if (facing && clear) { c = vec3(0.20, 0.75, 0.30); }
- return out_color(vec4(c * lit, 1.0));
- }
- if (u.ana.x > 3.5) {
- // A studio around the model: sky above, a dark floor below, a bright horizon band and
- // two soft light panels, seen in a mirror.
- let r = reflect(-v, n);
- var c = mix(vec3(0.32, 0.34, 0.38), vec3(0.80, 0.86, 0.95), smoothstep(-0.05, 0.6, r.z));
- c = c + vec3(0.6) * exp(-abs(r.z) * 40.0);
- let side = atan2(r.y, r.x);
- c = c + vec3(0.9) * smoothstep(0.92, 0.98, cos(side * 2.0)) * smoothstep(0.1, 0.3, r.z) * smoothstep(0.8, 0.6, r.z);
- if (r.z < -0.02) {
- // The floor: lighter toward the horizon, with light-panel streaks along it.
- c = mix(vec3(0.55, 0.56, 0.60), vec3(0.20, 0.20, 0.22), clamp(-r.z * 1.6, 0.0, 1.0));
- c = c + vec3(0.35) * smoothstep(0.95, 0.99, cos(side * 3.0));
- }
- return out_color(vec4(min(c, vec3(1.0)), 1.0));
- }
- if (u.ana.x < 2.5) {
- let a = degrees(asin(clamp(dot(normalize(i.n), normalize(u.pull.xyz)), -1.0, 1.0)));
- var c = vec3(0.95, 0.80, 0.15);
- if (a >= u.ana.y) { c = vec3(0.20, 0.75, 0.30); }
- if (a <= -u.ana.y) { c = vec3(0.85, 0.22, 0.20); }
- return out_color(vec4(c * lit, 1.0));
- }
- // Curvature from how fast the normal turns across the pixel.
- let t = clamp(k * u.ana.y, 0.0, 1.0);
- let c = select(mix(vec3(0.10, 0.80, 0.25), vec3(0.90, 0.15, 0.10), t * 2.0 - 1.0),
- mix(vec3(0.15, 0.35, 0.95), vec3(0.10, 0.80, 0.25), t * 2.0), t < 0.5);
- return out_color(vec4(c * lit, 1.0));
-}
-
-@fragment
-fn fs_tri(i: TOut) -> @location(0) vec4 {
- if (clipped(i.wp)) { discard; }
- return shade(i);
-}
-
-fn shade(i: TOut) -> vec4 {
- // A zero normal marks flat colour (selection fills, translucent planes).
- if (length(i.n) < 0.5) {
- return out_color(i.c);
- }
- let v = normalize(u.back.xyz);
- var n = normalize(i.n);
- if (dot(n, v) < 0.0) { n = -n; }
- let key = normalize(v * 0.8 + vec3(-0.3, 0.2, 0.6));
- let diff = max(dot(n, key), 0.0);
- let fill = max(dot(n, v), 0.0);
- let h = normalize(key + v);
- let spec = pow(max(dot(n, h), 0.0), 40.0) * 0.25;
- let k = 0.42 + 0.38 * diff + 0.25 * fill;
- return out_color(vec4(min(i.c.rgb * k + vec3(spec), vec3(1.0)), i.c.a));
-}
-
-/// Clip-space z of `p` (clipped to `c`) pulled `s` depth biases toward the eye (pushed away
-/// when negative). Orthographic depth is linear in distance, so the bias is a fixed step of it.
-/// Perspective depth is not: near the far plane a fixed step spans tens of millimetres (edges
-/// showed through walls), so the point moves toward the eye by a fraction of its distance.
-fn biased_z(p: vec3, c: vec4, s: f32) -> f32 {
- if (u.eye.w > 0.5) {
- let q = u.vp * vec4(mix(p, u.eye.xyz, u.screen.z * 8.0 * s), 1.0);
- return q.z / max(q.w, 1e-6) * c.w;
- }
- return c.z - u.screen.z * s * c.w;
-}
-
-@vertex
-fn vs_ghost(@location(0) p: vec3, @location(1) n: vec3, @location(2) c: vec4) -> TOut {
- var o: TOut;
- o.pos = u.vp * vec4(p, 1.0);
- o.pos.z = biased_z(p, o.pos, -4.0);
- o.n = n;
- o.c = c;
- o.wp = p;
- return o;
-}
-
-struct LOut {
- @builtin(position) pos: vec4,
- @location(0) c: vec4,
- @location(1) wp: vec3,
-};
-
-@vertex
-fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3, @location(1) b: vec3, @location(2) c: vec4, @location(3) w: f32) -> LOut {
- var pa = a;
- var pb = b;
- var ca = u.vp * vec4(a, 1.0);
- var cb = u.vp * vec4(b, 1.0);
- // An end behind the near plane (clip z < 0) has no screen point: its divide flips the
- // line or drops it whole (ground grid lines running past the eye at a low angle). Cut
- // the segment at the plane instead; clip space is linear in the world point.
- if (ca.z < 0.0 && cb.z > 0.0) {
- let k = ca.z / (ca.z - cb.z);
- ca = mix(ca, cb, k);
- pa = mix(a, b, k);
- } else if (cb.z < 0.0 && ca.z > 0.0) {
- let k = cb.z / (cb.z - ca.z);
- cb = mix(cb, ca, k);
- pb = mix(b, a, k);
- }
- let half = u.screen.xy * 0.5;
- let sa = ca.xy / max(ca.w, 1e-6) * half;
- let sb = cb.xy / max(cb.w, 1e-6) * half;
- var dir = sb - sa;
- let len = length(dir);
- if (len < 1e-6) { dir = vec2(1.0, 0.0); } else { dir = dir / len; }
- let nrm = vec2(-dir.y, dir.x);
- // Two triangles: corners (t, side).
- var ts = array(0.0, 1.0, 1.0, 0.0, 1.0, 0.0);
- var ss = array(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0);
- let t = ts[vi];
- let s = ss[vi];
- let base = select(ca, cb, t > 0.5);
- let off = nrm * s * w * 0.5 + dir * (t * 2.0 - 1.0) * w * 0.5;
- var o: LOut;
- o.pos = vec4(base.xy + off / half * base.w, biased_z(select(pa, pb, t > 0.5), base, 1.0), base.w);
- o.c = c;
- o.wp = select(pa, pb, t > 0.5);
- return o;
-}
-
-@fragment
-fn fs_line(i: LOut) -> @location(0) vec4 {
- if (clipped(i.wp)) { discard; }
- return out_color(i.c);
-}
-
-struct IOut {
- @builtin(position) pos: vec4,
- @location(0) uv: vec2,
- @location(1) a: f32,
- @location(2) wp: vec3,
-};
-
-@group(1) @binding(0) var img_t: texture_2d;
-@group(1) @binding(1) var img_s: sampler;
-
-@vertex
-fn vs_img(@location(0) p: vec3, @location(1) uv: vec2, @location(2) a: f32) -> IOut {
- var o: IOut;
- o.pos = u.vp * vec4(p, 1.0);
- // Pulled toward the eye like lines, so a decal on a face is not lost in it.
- o.pos.z = biased_z(p, o.pos, 1.0);
- o.uv = uv;
- o.a = a;
- o.wp = p;
- return o;
-}
-
-@fragment
-fn fs_img(i: IOut) -> @location(0) vec4 {
- let c = textureSample(img_t, img_s, i.uv);
- if (clipped(i.wp)) { discard; }
- return out_color(vec4(c.rgb, c.a * i.a));
-}
-
-/// Lines the section never cuts (the grid, highlights drawn on top).
-@fragment
-fn fs_line_all(i: LOut) -> @location(0) vec4 {
- return out_color(i.c);
-}
-"#;
+const SHADER: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/shaders/viewport.wgsl"));
fn tri_layout() -> wgpu::VertexBufferLayout<'static> {
const A: [wgpu::VertexAttribute; 3] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Unorm8x4];
diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs
index 283a774c..708e37be 100644
--- a/crates/ui-egui/src/i18n.rs
+++ b/crates/ui-egui/src/i18n.rs
@@ -31,11 +31,11 @@ impl Catalog {
}
}
-static ENGLISH: Catalog = Catalog::new("en", "English", include_str!("locales/en.json"));
-static JAPANESE: Catalog = Catalog::new("ja", "日本語", include_str!("locales/ja.json"));
-static GERMAN: Catalog = Catalog::new("de", "Deutsch", include_str!("locales/de.json"));
-static KOREAN: Catalog = Catalog::new("ko", "한국어", include_str!("locales/ko.json"));
-static CHINESE: Catalog = Catalog::new("zh", "中文", include_str!("locales/zh.json"));
+static ENGLISH: Catalog = Catalog::new("en", "English", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/en.json")));
+static JAPANESE: Catalog = Catalog::new("ja", "日本語", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/ja.json")));
+static GERMAN: Catalog = Catalog::new("de", "Deutsch", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/de.json")));
+static KOREAN: Catalog = Catalog::new("ko", "한국어", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/ko.json")));
+static CHINESE: Catalog = Catalog::new("zh", "中文", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/zh.json")));
pub const LOCALES: &[&Catalog] = &[&ENGLISH, &JAPANESE, &GERMAN, &KOREAN, &CHINESE];
pub fn is_supported(id: &str) -> bool {
diff --git a/crates/ui-egui/src/i18n_menu_tests.rs b/crates/ui-egui/src/i18n_menu_tests.rs
index e22d0ad8..2f292610 100644
--- a/crates/ui-egui/src/i18n_menu_tests.rs
+++ b/crates/ui-egui/src/i18n_menu_tests.rs
@@ -21,7 +21,7 @@ fn sample_app() -> SolveApp {
/// Every English text `label()` can be asked to translate.
fn english_texts() -> BTreeSet {
- const EN: &str = include_str!("locales/en.json");
+ const EN: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/en.json"));
let messages: serde_json::Map = serde_json::from_str(EN).expect("en.json is valid JSON");
messages.values().filter_map(|v| v.as_str().map(str::to_string)).collect()
}
diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs
index 83cfbde4..ded9cf4c 100644
--- a/crates/ui-egui/src/icons.rs
+++ b/crates/ui-egui/src/icons.rs
@@ -1,1510 +1,12 @@
-//! Toolbar and browser icons, drawn in code (no image assets). Each icon is designed on a
-//! 24 × 24 grid and scaled to the target rectangle.
-
-use egui::{Color32, Mesh, Painter, Pos2, Rect, Shape, Stroke, pos2, vec2};
-
-/// Surface sheets: a sheet, a second sheet beside it, a lighter sheet (an opening, a blend or
-/// round strip), and the iso lines and comb teeth drawn on sheets.
-const SHEET: Color32 = Color32::from_rgb(170, 200, 240);
-const SHEET_2: Color32 = Color32::from_rgb(150, 185, 235);
-const SHEET_LIGHT: Color32 = Color32::from_rgb(214, 229, 248);
-const SHEET_LINE: Color32 = Color32::from_rgb(127, 166, 218);
-
-/// `n + 1` points along the cubic Bézier curve with control points `c`.
-fn bezier(c: [(f32, f32); 4], n: usize) -> Vec<(f32, f32)> {
- (0..=n)
- .map(|i| i as f32 / n as f32)
- .map(|t| {
- let u = 1.0 - t;
- let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t];
- (w.iter().zip(&c).map(|(w, q)| w * q.0).sum(), w.iter().zip(&c).map(|(w, q)| w * q.1).sum())
- })
- .collect()
-}
-
-/// `n + 1` points along an ellipse from angle `a0` to `a1` (y down: angles below the centre
-/// first).
-fn ellipse(x: f32, y: f32, rx: f32, ry: f32, a0: f32, a1: f32, n: usize) -> Vec<(f32, f32)> {
- (0..=n).map(|i| a0 + (a1 - a0) * i as f32 / n as f32).map(|a| (x + rx * a.cos(), y + ry * a.sin())).collect()
-}
-
-struct Pen<'a> {
- p: &'a Painter,
- r: Rect,
- ink: Color32,
- fill: Color32,
- accent: Color32,
- w: f32,
-}
-
-impl Pen<'_> {
- fn at(&self, x: f32, y: f32) -> Pos2 {
- pos2(self.r.left() + x / 24.0 * self.r.width(), self.r.top() + y / 24.0 * self.r.height())
- }
- fn s(&self) -> f32 {
- self.r.width() / 24.0
- }
- fn line(&self, pts: &[(f32, f32)], c: Color32) {
- let v: Vec = pts.iter().map(|(x, y)| self.at(*x, *y)).collect();
- self.p.add(Shape::line(v, Stroke::new(self.w, c)));
- }
- fn poly(&self, pts: &[(f32, f32)], fill: Color32, c: Color32) {
- let v: Vec = pts.iter().map(|(x, y)| self.at(*x, *y)).collect();
- self.p.add(Shape::convex_polygon(v, fill, Stroke::new(self.w, c)));
- }
- fn closed(&self, pts: &[(f32, f32)], c: Color32) {
- let v: Vec = pts.iter().map(|(x, y)| self.at(*x, *y)).collect();
- self.p.add(Shape::closed_line(v, Stroke::new(self.w, c)));
- }
- fn circle(&self, x: f32, y: f32, r: f32, fill: Color32, c: Color32) {
- self.p.circle(self.at(x, y), r * self.s(), fill, Stroke::new(self.w, c));
- }
- fn dot(&self, x: f32, y: f32, c: Color32) {
- self.p.circle_filled(self.at(x, y), 1.6 * self.s(), c);
- }
- fn arc(&self, x: f32, y: f32, r: f32, a0: f32, a1: f32, c: Color32) {
- let n = 24;
- let pts: Vec<(f32, f32)> = (0..=n).map(|i| a0 + (a1 - a0) * i as f32 / n as f32).map(|a| (x + r * a.cos(), y - r * a.sin())).collect();
- self.line(&pts, c);
- }
- fn arrow(&self, from: (f32, f32), to: (f32, f32), c: Color32) {
- self.line(&[from, to], c);
- let d = vec2(to.0 - from.0, to.1 - from.1).normalized();
- let n = vec2(-d.y, d.x);
- let tip = vec2(to.0, to.1);
- let a = tip - d * 3.5 + n * 2.2;
- let b = tip - d * 3.5 - n * 2.2;
- self.poly(&[to, (a.x, a.y), (b.x, b.y)], c, c);
- }
- /// Isometric box from a 2D footprint height.
- fn iso_box(&self, x0: f32, y0: f32, w: f32, d: f32, h: f32) {
- let top = [(x0, y0), (x0 + w, y0 - d * 0.5), (x0 + w + d, y0), (x0 + d, y0 + d * 0.5)];
- let front = [(x0, y0), (x0 + d, y0 + d * 0.5), (x0 + d, y0 + d * 0.5 + h), (x0, y0 + h)];
- let side = [(x0 + d, y0 + d * 0.5), (x0 + w + d, y0), (x0 + w + d, y0 + h), (x0 + d, y0 + d * 0.5 + h)];
- self.poly(&front, self.fill.gamma_multiply(0.85), self.ink);
- self.poly(&side, self.fill.gamma_multiply(0.7), self.ink);
- self.poly(&top, self.fill, self.ink);
- }
- /// A cubic Bézier curve through control points `c`.
- fn curve(&self, c: [(f32, f32); 4], col: Color32) {
- self.line(&bezier(c, 16), col);
- }
- fn dashed(&self, pts: &[(f32, f32)], c: Color32) {
- let v: Vec = pts.iter().map(|(x, y)| self.at(*x, *y)).collect();
- self.p.add(Shape::dashed_line(&v, Stroke::new(self.w, c), 2.0 * self.s(), 1.6 * self.s()));
- }
- /// Fill the strip between two polylines that run the same way, such as the front and back
- /// edges of a curved sheet. Unlike `poly`, the strip need not be convex.
- fn band(&self, a: &[(f32, f32)], b: &[(f32, f32)], fill: Color32) {
- let mut m = Mesh::default();
- for (p, q) in a.iter().zip(b) {
- m.colored_vertex(self.at(p.0, p.1), fill);
- m.colored_vertex(self.at(q.0, q.1), fill);
- }
- let n = a.len().min(b.len()) as u32;
- for i in 1..n {
- let k = 2 * i;
- m.add_triangle(k - 2, k - 1, k);
- m.add_triangle(k - 1, k + 1, k);
- }
- self.p.add(Shape::mesh(m));
- }
-}
-
-/// The stroke width for the Normal/Construction/Centerline line-type icons. It is heavier than
-/// the generic icon weight (`Pen::w`). It is sized from whichever side of `r` is smaller. A
-/// short, wide button and a square toolbar slot both get a stroke their gaps can survive next
-/// to.
-fn line_type_stroke(r: Rect) -> f32 {
- (r.width().min(r.height()) / 24.0 * 2.0).max(1.6)
-}
-
-/// The Centerline icon's dot radius. It is sized from `r`. The stroke width plays no part in it.
-/// It stays a round mark even next to a heavy stroke.
-fn line_type_dot_radius(r: Rect) -> f32 {
- (r.width().min(r.height()) * 0.16).max(2.4)
-}
-
-/// Paint the icon `name` into `r`. `accent` highlights the "action" part.
-pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, accent: Color32) {
- let pen = Pen { p, r, ink, fill, accent, w: (r.width() / 24.0 * 1.4).max(1.0) };
- let a = pen.accent;
- match name {
- "sketch" => {
- pen.poly(&[(3.0, 14.0), (13.0, 9.0), (21.0, 13.0), (11.0, 18.0)], pen.fill, ink);
- pen.line(&[(7.0, 13.0), (16.0, 4.0), (18.0, 6.0), (9.0, 15.0), (6.5, 15.5), (7.0, 13.0)], a);
- }
- "finish" => {
- pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, pen.accent);
- pen.line(&[(7.0, 12.5), (10.5, 16.0), (17.0, 8.5)], a);
- }
- "extrude" => {
- pen.iso_box(4.0, 11.0, 9.0, 7.0, 7.0);
- pen.arrow((12.0, 9.0), (12.0, 2.5), a);
- }
- "revolve" => {
- pen.line(&[(12.0, 2.0), (12.0, 22.0)], ink);
- pen.poly(&[(13.0, 7.0), (19.0, 7.0), (19.0, 17.0), (13.0, 17.0)], pen.fill, ink);
- pen.arc(12.0, 12.0, 9.0, 3.6, 5.8, a);
- pen.arrow((15.0, 20.4), (18.5, 19.0), a);
- }
- "box" => pen.iso_box(3.0, 9.0, 10.0, 8.0, 9.0),
- "cylinder" => {
- pen.poly(&[(5.0, 6.0), (19.0, 6.0), (19.0, 18.0), (5.0, 18.0)], pen.fill.gamma_multiply(0.8), Color32::TRANSPARENT);
- pen.line(&[(5.0, 6.0), (5.0, 18.0)], ink);
- pen.line(&[(19.0, 6.0), (19.0, 18.0)], ink);
- let e = |y: f32, fillc: Color32| {
- let pts: Vec =
- (0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 7.0 * t.cos(), y + 2.5 * t.sin())).collect();
- p.add(Shape::convex_polygon(pts, fillc, Stroke::new(pen.w, ink)));
- };
- pen.arc(12.0, 18.0, 7.0, std::f32::consts::PI, std::f32::consts::TAU, ink);
- e(6.0, pen.fill);
- }
- "sphere" => {
- pen.circle(12.0, 12.0, 8.5, pen.fill, ink);
- pen.arc(12.0, 12.0, 8.5, 3.3, 6.1, ink.gamma_multiply(0.5));
- p.circle_filled(pen.at(9.0, 8.5), 2.0 * pen.s(), Color32::from_white_alpha(160));
- }
- "torus" => {
- let pts: Vec =
- (0..40).map(|i| i as f32 / 40.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 9.0 * t.cos(), 12.0 + 5.5 * t.sin())).collect();
- p.add(Shape::convex_polygon(pts, pen.fill, Stroke::new(pen.w, ink)));
- let pts: Vec =
- (0..40).map(|i| i as f32 / 40.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 3.5 * t.cos(), 11.0 + 1.8 * t.sin())).collect();
- p.add(Shape::convex_polygon(pts, Color32::WHITE, Stroke::new(pen.w, ink)));
- }
- "fillet" => {
- pen.poly(&[(4.0, 20.0), (4.0, 4.0), (10.0, 4.0), (10.0, 14.0), (20.0, 14.0), (20.0, 20.0)], pen.fill, Color32::TRANSPARENT);
- pen.line(&[(4.0, 20.0), (4.0, 4.0)], ink);
- pen.line(&[(4.0, 20.0), (20.0, 20.0)], ink);
- pen.arc(4.0, 4.0, 16.0, -std::f32::consts::FRAC_PI_2, 0.0, ink);
- pen.arc(20.0, 4.0, 10.0, std::f32::consts::PI, std::f32::consts::PI * 1.5, a);
- }
- "chamfer" => {
- pen.poly(&[(4.0, 20.0), (4.0, 4.0), (12.0, 4.0), (20.0, 12.0), (20.0, 20.0)], pen.fill, ink);
- pen.line(&[(12.0, 4.0), (20.0, 12.0)], a);
- }
- "combine" => {
- pen.poly(&[(3.0, 8.0), (14.0, 8.0), (14.0, 19.0), (3.0, 19.0)], pen.fill, ink);
- pen.poly(&[(9.0, 4.0), (21.0, 4.0), (21.0, 15.0), (9.0, 15.0)], pen.fill.gamma_multiply(0.75), a);
- }
- "pattern_rect" => {
- for (x, y) in [(4.0, 4.0), (14.0, 4.0), (4.0, 14.0), (14.0, 14.0)] {
- pen.poly(
- &[(x, y), (x + 6.0, y), (x + 6.0, y + 6.0), (x, y + 6.0)],
- if x == 4.0 && y == 4.0 { pen.fill } else { Color32::TRANSPARENT },
- if x == 4.0 && y == 4.0 { ink } else { a },
- );
- }
- }
- "pattern_circ" => {
- for i in 0..6 {
- let t = i as f32 / 6.0 * std::f32::consts::TAU;
- pen.circle(
- 12.0 + 7.5 * t.cos(),
- 12.0 + 7.5 * t.sin(),
- 2.4,
- if i == 0 { pen.fill } else { Color32::TRANSPARENT },
- if i == 0 { ink } else { a },
- );
- }
- }
- "hole" => {
- pen.poly(&[(3.0, 8.0), (21.0, 8.0), (21.0, 20.0), (3.0, 20.0)], pen.fill, ink);
- let pts: Vec =
- (0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 5.0 * t.cos(), 8.0 + 2.0 * t.sin())).collect();
- p.add(Shape::convex_polygon(pts, Color32::WHITE, Stroke::new(pen.w, a)));
- pen.line(&[(7.0, 8.0), (7.0, 16.0)], a);
- pen.line(&[(17.0, 8.0), (17.0, 16.0)], a);
- }
- "sweep" => {
- pen.arc(20.0, 20.0, 14.0, std::f32::consts::FRAC_PI_2, std::f32::consts::PI, ink);
- pen.circle(6.0, 20.0, 3.0, pen.fill, a);
- pen.circle(20.0, 6.0, 3.0, pen.fill, a);
- }
- "loft" => {
- pen.poly(&[(3.0, 20.0), (15.0, 20.0), (21.0, 15.0), (9.0, 15.0)], pen.fill, ink);
- pen.circle(12.0, 6.0, 3.5, Color32::TRANSPARENT, a);
- pen.line(&[(3.0, 20.0), (8.5, 6.0)], a);
- pen.line(&[(21.0, 15.0), (15.5, 6.0)], a);
- }
- "shell" => {
- pen.iso_box(3.0, 9.0, 10.0, 8.0, 9.0);
- pen.poly(&[(6.0, 9.0), (13.0, 5.5), (18.0, 8.0), (11.0, 11.5)], Color32::WHITE, a);
- }
- "draft" => {
- pen.poly(&[(4.0, 20.0), (20.0, 20.0), (17.0, 5.0), (7.0, 5.0)], pen.fill, ink);
- pen.line(&[(20.0, 20.0), (20.0, 5.0)], a);
- }
- "split" => {
- pen.poly(&[(3.0, 6.0), (21.0, 6.0), (21.0, 18.0), (3.0, 18.0)], pen.fill, ink);
- pen.line(&[(8.0, 2.0), (16.0, 22.0)], a);
- }
- "mirror" => {
- pen.line(&[(12.0, 2.0), (12.0, 22.0)], ink);
- pen.poly(&[(3.0, 7.0), (9.0, 10.0), (9.0, 17.0), (3.0, 17.0)], pen.fill, ink);
- pen.poly(&[(21.0, 7.0), (15.0, 10.0), (15.0, 17.0), (21.0, 17.0)], Color32::TRANSPARENT, a);
- }
- "lookat" => {
- pen.poly(&[(4.0, 6.0), (16.0, 6.0), (16.0, 18.0), (4.0, 18.0)], pen.fill, ink);
- pen.arrow((22.0, 12.0), (13.0, 12.0), a);
- }
- "style" => {
- pen.poly(&[(12.0, 3.0), (20.0, 7.5), (12.0, 12.0), (4.0, 7.5)], pen.fill, ink);
- pen.poly(&[(4.0, 7.5), (12.0, 12.0), (12.0, 21.0), (4.0, 16.5)], a, ink);
- pen.poly(&[(12.0, 12.0), (20.0, 7.5), (20.0, 16.5), (12.0, 21.0)], Color32::TRANSPARENT, ink);
- }
- // Original bug glyph: rounded body, antennae and three pairs of legs.
- "bug_report" => {
- pen.circle(12.0, 13.5, 5.0, Color32::TRANSPARENT, ink);
- pen.line(&[(12.0, 9.0), (12.0, 18.0)], ink);
- pen.line(&[(9.5, 8.5), (8.0, 5.0)], ink);
- pen.line(&[(14.5, 8.5), (16.0, 5.0)], ink);
- for y in [10.0, 13.5, 17.0] {
- pen.line(&[(3.0, y), (7.0, y)], ink);
- pen.line(&[(17.0, y), (21.0, y)], ink);
- }
- }
- // Help: a question mark in a ring.
- "help" => {
- pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink);
- pen.arc(12.0, 9.5, 3.2, -0.6, 3.4, ink);
- pen.line(&[(13.9, 12.0), (12.0, 13.6), (12.0, 15.0)], ink);
- pen.dot(12.0, 17.8, ink);
- }
- "sun" => {
- pen.circle(12.0, 12.0, 4.5, a, ink);
- for i in 0..8 {
- let t = i as f32 / 8.0 * std::f32::consts::TAU;
- pen.line(&[(12.0 + 7.0 * t.cos(), 12.0 + 7.0 * t.sin()), (12.0 + 9.5 * t.cos(), 12.0 + 9.5 * t.sin())], ink);
- }
- }
- "moon" => {
- let outer: Vec =
- (0..=24).map(|i| -0.6 + i as f32 / 24.0 * 4.4).map(|t| pen.at(12.0 + 8.0 * t.cos(), 12.0 + 8.0 * t.sin())).collect();
- let inner: Vec =
- (0..=24).rev().map(|i| -0.9 + i as f32 / 24.0 * 3.9).map(|t| pen.at(15.5 + 6.5 * t.cos(), 9.5 + 6.5 * t.sin())).collect();
- let mut pts = outer;
- pts.extend(inner);
- p.add(Shape::closed_line(pts, Stroke::new(pen.w, ink)));
- }
- "section" => {
- pen.poly(&[(4.0, 8.0), (14.0, 4.0), (20.0, 8.0), (10.0, 12.0)], pen.fill, ink);
- pen.poly(&[(4.0, 8.0), (10.0, 12.0), (10.0, 20.0), (4.0, 16.0)], pen.fill, ink);
- pen.poly(&[(10.0, 12.0), (20.0, 8.0), (20.0, 16.0), (10.0, 20.0)], a, ink);
- }
- "presspull" => {
- pen.poly(&[(3.0, 15.0), (12.0, 11.0), (21.0, 15.0), (12.0, 19.0)], pen.fill, ink);
- pen.arrow((12.0, 15.0), (12.0, 3.0), a);
- }
- "move" => {
- for (dx, dy) in [(0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)] {
- pen.arrow((12.0, 12.0), (12.0 + 9.0 * dx, 12.0 + 9.0 * dy), a);
- }
- }
- "configuration_table" => {
- pen.poly(&[(3.0, 4.0), (21.0, 4.0), (21.0, 20.0), (3.0, 20.0)], pen.fill, ink);
- pen.line(&[(3.0, 9.0), (21.0, 9.0)], a);
- pen.line(&[(3.0, 14.0), (21.0, 14.0)], ink);
- pen.line(&[(9.0, 4.0), (9.0, 20.0)], ink);
- }
- "params" => {
- pen.line(&[(8.0, 20.0), (9.0, 8.0), (10.0, 5.0), (13.0, 4.0)], ink);
- pen.line(&[(6.0, 11.0), (12.0, 11.0)], ink);
- pen.line(&[(14.0, 13.0), (20.0, 20.0)], a);
- pen.line(&[(20.0, 13.0), (14.0, 20.0)], a);
- }
- "compute" => {
- pen.arc(12.0, 12.0, 7.5, 0.4, 3.0, ink);
- pen.arc(12.0, 12.0, 7.5, 3.5, 6.0, a);
- pen.arrow((5.0, 9.0), (4.6, 13.0), ink);
- pen.arrow((19.0, 15.0), (19.4, 11.0), a);
- }
- "delete" => {
- pen.line(&[(6.0, 6.0), (18.0, 18.0)], pen.accent);
- pen.line(&[(18.0, 6.0), (6.0, 18.0)], pen.accent);
- }
- "measure" => {
- pen.poly(&[(2.0, 15.0), (15.0, 2.0), (22.0, 9.0), (9.0, 22.0)], pen.fill, ink);
- for i in 0..5 {
- let t = 4.0 + i as f32 * 2.6;
- pen.line(&[(t, 15.0 - (t - 2.0)), (t + 2.0, 15.0 - (t - 2.0) + 2.0)], ink);
- }
- }
- "line" => {
- pen.line(&[(4.0, 19.0), (20.0, 5.0)], a);
- pen.dot(4.0, 19.0, ink);
- pen.dot(20.0, 5.0, ink);
- }
- "rect" | "rect3" | "rect_center" => {
- pen.closed(&[(4.0, 6.0), (20.0, 6.0), (20.0, 18.0), (4.0, 18.0)], a);
- match name {
- "rect" => {
- pen.dot(4.0, 18.0, ink);
- pen.dot(20.0, 6.0, ink);
- }
- "rect3" => {
- pen.dot(4.0, 18.0, ink);
- pen.dot(20.0, 18.0, ink);
- pen.dot(20.0, 6.0, ink);
- }
- _ => {
- pen.dot(12.0, 12.0, ink);
- pen.dot(20.0, 6.0, ink);
- }
- }
- }
- "rect_rounded" => {
- // Straight sides between quarter arcs of radius 3 at the corners.
- pen.line(&[(7.0, 6.0), (17.0, 6.0)], a);
- pen.line(&[(20.0, 9.0), (20.0, 15.0)], a);
- pen.line(&[(17.0, 18.0), (7.0, 18.0)], a);
- pen.line(&[(4.0, 15.0), (4.0, 9.0)], a);
- let q = std::f32::consts::FRAC_PI_2;
- pen.arc(17.0, 9.0, 3.0, 0.0, q, a);
- pen.arc(7.0, 9.0, 3.0, q, 2.0 * q, a);
- pen.arc(7.0, 15.0, 3.0, 2.0 * q, 3.0 * q, a);
- pen.arc(17.0, 15.0, 3.0, 3.0 * q, 4.0 * q, a);
- pen.dot(4.0, 18.0, ink);
- pen.dot(20.0, 6.0, ink);
- }
- "circle" | "circle2" | "circle3" => {
- pen.circle(12.0, 12.0, 8.0, Color32::TRANSPARENT, a);
- match name {
- "circle" => {
- pen.dot(12.0, 12.0, ink);
- pen.line(&[(12.0, 12.0), (17.6, 6.4)], ink);
- }
- "circle2" => {
- pen.dot(4.0, 12.0, ink);
- pen.dot(20.0, 12.0, ink);
- }
- _ => {
- pen.dot(4.0, 12.0, ink);
- pen.dot(12.0, 4.0, ink);
- pen.dot(20.0, 12.0, ink);
- }
- }
- }
- "arc3" | "arc_center" => {
- pen.arc(12.0, 16.0, 9.0, 0.0, std::f32::consts::PI, a);
- pen.dot(3.0, 16.0, ink);
- pen.dot(21.0, 16.0, ink);
- if name == "arc3" {
- pen.dot(12.0, 7.0, ink);
- } else {
- pen.dot(12.0, 16.0, ink);
- }
- }
- "polygon" => {
- let pts: Vec<(f32, f32)> =
- (0..6).map(|i| i as f32 / 6.0 * std::f32::consts::TAU + 0.52).map(|t| (12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())).collect();
- pen.closed(&pts, a);
- pen.dot(12.0, 12.0, ink);
- }
- "slot" => {
- pen.arc(8.0, 12.0, 5.0, std::f32::consts::FRAC_PI_2, std::f32::consts::PI * 1.5, a);
- pen.arc(16.0, 12.0, 5.0, -std::f32::consts::FRAC_PI_2, std::f32::consts::FRAC_PI_2, a);
- pen.line(&[(8.0, 7.0), (16.0, 7.0)], a);
- pen.line(&[(8.0, 17.0), (16.0, 17.0)], a);
- pen.dot(8.0, 12.0, ink);
- pen.dot(16.0, 12.0, ink);
- }
- "point" => {
- pen.dot(12.0, 12.0, a);
- pen.circle(12.0, 12.0, 4.0, Color32::TRANSPARENT, ink);
- }
- "dimension" => {
- pen.line(&[(4.0, 18.0), (4.0, 8.0)], ink);
- pen.line(&[(20.0, 18.0), (20.0, 8.0)], ink);
- pen.arrow((12.0, 11.0), (4.5, 11.0), a);
- pen.arrow((12.0, 11.0), (19.5, 11.0), a);
- pen.line(&[(9.0, 15.0), (15.0, 15.0)], ink);
- }
- // The Sketch Palette's line type selector (normal, construction, centerline) paints all
- // three on the same diagonal, from (3, 19) to (19, 3). Only the dash pattern changes
- // between them. Normal draws in `ink` and ignores `accent`. It is a plain line. It is
- // never construction geometry. Its colour must stay fixed even if a caller passes the
- // construction orange for it. Construction and centerline draw only in `accent` and
- // ignore `ink`. The caller decides the colour. The palette and the toolbar both pass
- // the theme's construction orange.
- //
- // These three are drawn small. The Sketch Palette draws them down to about 18 px
- // square. At that size a repeating dash pattern loses its gaps. It reads as a solid
- // line. Two patterns that only differ in dash phase read as the same texture too.
- // Normal, Construction and Centerline instead get three different silhouettes. One is
- // an unbroken line. One is a line broken by a single gap. One is a line broken by a
- // single gap with a bold dot sitting in it. A viewer can name the shape on sight. No
- // rhythm needs measuring. The stroke width and the dot radius both come from the rect
- // actually passed in. That keeps the shapes legible in the palette, the toolbar and the
- // command palette, at every size any of them uses.
- "normal" => {
- let w = line_type_stroke(r);
- p.add(Shape::line(vec![pen.at(3.0, 19.0), pen.at(19.0, 3.0)], Stroke::new(w, ink)));
- }
- "construction" => {
- // One gap, no dot. It is an unmistakably broken line. Nothing sits in the break.
- let w = line_type_stroke(r);
- let at = |t: f32| pen.at(3.0 + 16.0 * t, 19.0 - 16.0 * t);
- for (t0, t1) in [(0.0, 0.35), (0.65, 1.0)] {
- p.add(Shape::line(vec![at(t0), at(t1)], Stroke::new(w, a)));
- }
- }
- "centerline" => {
- // The same gap as Construction, with a bold dot centred in it. The silhouette is
- // line, dot, line. The dot's radius comes from the rect. It reads as a distinct
- // round mark on its own, never as a thicker stretch of the stroke. The dashes stop
- // well short of it on both sides. The dot never touches either stroke. It always
- // reads as a separate shape.
- let w = line_type_stroke(r);
- let dot_r = line_type_dot_radius(r);
- let at = |t: f32| pen.at(3.0 + 16.0 * t, 19.0 - 16.0 * t);
- for (t0, t1) in [(0.0, 0.26), (0.74, 1.0)] {
- p.add(Shape::line(vec![at(t0), at(t1)], Stroke::new(w, a)));
- }
- p.circle_filled(at(0.5), dot_r, a);
- }
- "c_hv" => {
- pen.line(&[(4.0, 18.0), (20.0, 18.0)], a);
- pen.line(&[(6.0, 4.0), (6.0, 14.0)], a);
- }
- "c_coincident" => {
- pen.line(&[(3.0, 18.0), (12.0, 12.0)], ink);
- pen.line(&[(12.0, 12.0), (21.0, 16.0)], ink);
- pen.circle(12.0, 12.0, 2.6, Color32::WHITE, a);
- }
- "c_tangent" => {
- pen.circle(12.0, 14.0, 6.0, Color32::TRANSPARENT, ink);
- pen.line(&[(3.0, 8.0), (21.0, 8.0)], a);
- }
- "c_equal" => {
- pen.line(&[(5.0, 9.0), (19.0, 9.0)], a);
- pen.line(&[(5.0, 15.0), (19.0, 15.0)], a);
- }
- "c_parallel" => {
- pen.line(&[(5.0, 19.0), (13.0, 5.0)], a);
- pen.line(&[(11.0, 19.0), (19.0, 5.0)], a);
- }
- "c_perpendicular" => {
- pen.line(&[(4.0, 19.0), (20.0, 19.0)], a);
- pen.line(&[(12.0, 19.0), (12.0, 5.0)], a);
- pen.line(&[(12.0, 15.0), (16.0, 15.0), (16.0, 19.0)], ink);
- }
- "c_fix" => {
- pen.circle(12.0, 9.0, 4.0, Color32::TRANSPARENT, a);
- pen.line(&[(12.0, 13.0), (12.0, 21.0)], a);
- pen.line(&[(9.0, 21.0), (15.0, 21.0)], ink);
- }
- "c_midpoint" => {
- pen.line(&[(3.0, 15.0), (21.0, 9.0)], ink);
- pen.poly(&[(12.0, 9.0), (15.0, 12.0), (12.0, 15.0), (9.0, 12.0)], a, a);
- }
- "c_concentric" => {
- pen.circle(12.0, 12.0, 8.5, Color32::TRANSPARENT, a);
- pen.circle(12.0, 12.0, 4.0, Color32::TRANSPARENT, a);
- }
- "c_collinear" => {
- pen.line(&[(3.0, 19.0), (10.0, 12.0)], a);
- pen.line(&[(14.0, 8.0), (21.0, 1.0)], a);
- }
- "c_symmetry" => {
- pen.line(&[(12.0, 3.0), (12.0, 21.0)], ink);
- pen.poly(&[(4.0, 8.0), (9.0, 12.0), (4.0, 16.0)], a, a);
- pen.poly(&[(20.0, 8.0), (15.0, 12.0), (20.0, 16.0)], a, a);
- }
- "undo" | "redo" => {
- let (x0, x1) = if name == "undo" { (19.0, 5.0) } else { (5.0, 19.0) };
- pen.line(&[(x0, 18.0), (x0, 12.0), (12.0, 9.0), (x1, 9.0)], ink);
- pen.arrow((x1 + (x0 - x1) * 0.15, 9.0), (x1, 9.0), ink);
- }
- "new" => {
- pen.poly(&[(6.0, 3.0), (15.0, 3.0), (19.0, 7.0), (19.0, 21.0), (6.0, 21.0)], Color32::WHITE, ink);
- pen.line(&[(15.0, 3.0), (15.0, 7.0), (19.0, 7.0)], ink);
- }
- "open" => {
- pen.poly(&[(3.0, 7.0), (9.0, 7.0), (11.0, 9.0), (20.0, 9.0), (20.0, 19.0), (3.0, 19.0)], pen.fill, ink);
- }
- "save" => {
- pen.poly(&[(4.0, 4.0), (17.0, 4.0), (20.0, 7.0), (20.0, 20.0), (4.0, 20.0)], pen.fill, ink);
- pen.poly(&[(8.0, 4.0), (16.0, 4.0), (16.0, 9.0), (8.0, 9.0)], Color32::WHITE, ink);
- }
- "export" => {
- pen.poly(&[(4.0, 10.0), (4.0, 20.0), (16.0, 20.0), (16.0, 10.0)], pen.fill, ink);
- pen.arrow((10.0, 14.0), (20.0, 4.0), a);
- }
- "import" => {
- pen.iso_box(4.0, 12.0, 12.0, 7.0, 7.0);
- pen.arrow((20.0, 3.0), (12.0, 11.0), a);
- }
- "home" => {
- pen.poly(&[(4.0, 12.0), (12.0, 4.0), (20.0, 12.0)], pen.fill, ink);
- pen.poly(&[(6.0, 12.0), (18.0, 12.0), (18.0, 20.0), (6.0, 20.0)], pen.fill, ink);
- }
- "fit" => {
- for (cx, cy, dx, dy) in [(4.0, 4.0, 1.0, 1.0), (20.0, 4.0, -1.0, 1.0), (4.0, 20.0, 1.0, -1.0), (20.0, 20.0, -1.0, -1.0)] {
- pen.line(&[(cx + dx * 5.0, cy), (cx, cy), (cx, cy + dy * 5.0)], ink);
- }
- pen.poly(&[(9.0, 9.0), (15.0, 9.0), (15.0, 15.0), (9.0, 15.0)], pen.fill, ink);
- }
- "orbit" => {
- pen.arc(12.0, 12.0, 8.0, 0.3, 5.5, ink);
- pen.arrow((17.0, 17.5), (19.6, 14.5), a);
- pen.dot(12.0, 12.0, ink);
- }
- "spin" => {
- // A turntable: an ellipse with an arrow running round it, a post in the middle.
- let pts: Vec = (0..=28).map(|i| 0.2 + i as f32 / 28.0 * 5.6).map(|t| pen.at(12.0 + 9.0 * t.cos(), 16.0 + 3.5 * t.sin())).collect();
- p.add(Shape::line(pts, Stroke::new(pen.w, ink)));
- pen.arrow((17.0, 19.0), (20.5, 17.0), a);
- pen.line(&[(12.0, 15.5), (12.0, 4.0)], ink);
- pen.poly(&[(9.0, 9.0), (15.0, 9.0), (15.0, 4.0), (9.0, 4.0)], Color32::TRANSPARENT, ink);
- }
- "pan" => {
- for (dx, dy) in [(0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)] {
- pen.arrow((12.0 + 3.0 * dx, 12.0 + 3.0 * dy), (12.0 + 9.0 * dx, 12.0 + 9.0 * dy), ink);
- }
- }
- "zoom" => {
- pen.circle(10.0, 10.0, 6.0, Color32::WHITE, ink);
- pen.line(&[(14.5, 14.5), (20.0, 20.0)], ink);
- }
- "perspective" => {
- pen.closed(&[(4.0, 6.0), (20.0, 4.0), (20.0, 20.0), (4.0, 18.0)], ink);
- pen.closed(&[(9.0, 9.0), (15.0, 8.5), (15.0, 15.5), (9.0, 15.0)], ink);
- }
- "eye" => {
- pen.arc(12.0, 18.0, 10.0, 0.6, 2.54, ink);
- pen.arc(12.0, 6.0, 10.0, 3.74, 5.68, ink);
- pen.circle(12.0, 12.0, 2.6, ink, ink);
- }
- "body" => pen.iso_box(4.0, 10.0, 8.0, 6.0, 6.0),
- // Component: a box with a smaller one stacked on it.
- "component" => {
- pen.iso_box(2.0, 13.0, 9.0, 6.0, 6.0);
- pen.iso_box(9.0, 6.0, 5.0, 4.0, 4.0);
- }
- "folder" => pen.poly(&[(3.0, 7.0), (9.0, 7.0), (11.0, 9.0), (21.0, 9.0), (21.0, 19.0), (3.0, 19.0)], Color32::from_rgb(236, 200, 110), ink),
- "origin" => {
- pen.arrow((6.0, 18.0), (20.0, 18.0), Color32::from_rgb(210, 60, 60));
- pen.arrow((6.0, 18.0), (6.0, 4.0), Color32::from_rgb(60, 100, 220));
- pen.arrow((6.0, 18.0), (15.0, 9.0), Color32::from_rgb(60, 160, 60));
- }
- "plane" => pen.poly(&[(3.0, 15.0), (12.0, 8.0), (21.0, 11.0), (12.0, 18.0)], Color32::from_rgb(244, 214, 150), ink),
- "axis" => {
- pen.line(&[(4.0, 20.0), (20.0, 4.0)], ink);
- pen.circle(4.0, 20.0, 2.0, ink, ink);
- }
- "settings" => {
- // A gear: a solid ring with six chunky, square-ish teeth and a see-through centre
- // hole (#290; thin rays read as a sun).
- let (teeth, root, tip, hole) = (6usize, 7.0f32, 10.5f32, 3.2f32);
- let pitch = std::f32::consts::TAU / teeth as f32;
- // Half-widths (radians) of a tooth at its root and at its tip: nearly parallel sides.
- let (half_root, half_tip) = (pitch * 0.3, pitch * 0.22);
- let polar = |a: f32, r: f32| (12.0 + r * a.cos(), 12.0 + r * a.sin());
- let start = -std::f32::consts::FRAC_PI_2;
- let mut outline = Vec::new();
- for i in 0..teeth {
- let c = start + i as f32 * pitch;
- // The tooth, filled; it reaches into the ring so no seam shows.
- let quad = [polar(c - half_root, root - 1.0), polar(c - half_tip, tip), polar(c + half_tip, tip), polar(c + half_root, root - 1.0)];
- let v: Vec = quad.iter().map(|(x, y)| pen.at(*x, *y)).collect();
- p.add(Shape::convex_polygon(v, pen.fill, Stroke::NONE));
- outline.extend([polar(c - half_root, root), polar(c - half_tip, tip), polar(c + half_tip, tip), polar(c + half_root, root)]);
- // The root circle up to the next tooth.
- for k in 1..4 {
- let a = c + half_root + (pitch - 2.0 * half_root) * k as f32 / 4.0;
- outline.push(polar(a, root));
- }
- }
- // The ring: a thick stroke, so the hole stays transparent on any background.
- p.circle_stroke(pen.at(12.0, 12.0), (root + hole) * 0.5 * pen.s(), Stroke::new((root - hole) * pen.s(), pen.fill));
- pen.closed(&outline, ink);
- pen.circle(12.0, 12.0, hole, Color32::TRANSPARENT, ink);
- }
- // Sketch tools (sketch-style: thin outlines, accent for what the tool makes).
- "canvas" | "decal" => {
- if name == "canvas" {
- pen.poly(&[(3.0, 5.0), (21.0, 5.0), (21.0, 19.0), (3.0, 19.0)], pen.fill, ink);
- } else {
- pen.poly(&[(3.0, 5.0), (21.0, 5.0), (21.0, 13.0), (15.0, 19.0), (3.0, 19.0)], pen.fill, ink);
- pen.line(&[(21.0, 13.0), (15.0, 13.0), (15.0, 19.0)], ink);
- }
- pen.line(&[(5.0, 17.0), (9.5, 10.5), (12.5, 14.5), (14.5, 12.0), (18.0, 16.5)], a);
- pen.circle(16.5, 8.5, 1.6, a, a);
- }
- "dxf" | "svg" => {
- pen.poly(&[(5.0, 2.5), (15.0, 2.5), (19.5, 7.0), (19.5, 21.5), (5.0, 21.5)], pen.fill, ink);
- pen.line(&[(15.0, 2.5), (15.0, 7.0), (19.5, 7.0)], ink);
- let label = if name == "dxf" { "DXF" } else { "SVG" };
- p.text(pen.at(12.2, 15.0), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(6.4 * pen.s()), a);
- }
- "c_smooth" => {
- let pts: Vec<(f32, f32)> =
- (0..=24).map(|i| i as f32 / 24.0).map(|t| (3.0 + 18.0 * t, 12.0 - 7.0 * (t * std::f32::consts::TAU).sin())).collect();
- let (l, r) = pts.split_at(13);
- pen.line(l, ink);
- let mut r2 = vec![pts[12]];
- r2.extend_from_slice(r);
- pen.line(&r2, a);
- pen.dot(12.0, 12.0, ink);
- }
- "c_polygon" => {
- let pts: Vec<(f32, f32)> =
- (0..6).map(|i| i as f32 / 6.0 * std::f32::consts::TAU).map(|t| (12.0 + 8.5 * t.cos(), 12.0 + 8.5 * t.sin())).collect();
- pen.closed(&pts, a);
- pen.line(&[(12.0, 12.0), (20.5, 12.0)], ink);
- pen.dot(12.0, 12.0, ink);
- }
- "auto_constrain" | "constrainer" => {
- pen.closed(&[(3.0, 8.0), (15.0, 8.0), (15.0, 20.0), (3.0, 20.0)], ink);
- if name == "auto_constrain" {
- for (dx, dy) in [(0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0), (0.7, 0.7), (-0.7, -0.7), (0.7, -0.7), (-0.7, 0.7)] {
- let k = if dx * dy == 0.0 { 4.0 } else { 2.5 };
- pen.line(&[(18.0, 6.0), (18.0 + k * dx, 6.0 + k * dy)], a);
- }
- } else {
- pen.line(&[(13.0, 6.0), (16.0, 9.0), (21.5, 2.5)], a);
- }
- pen.line(&[(6.0, 17.0), (12.0, 17.0)], a);
- pen.line(&[(6.0, 11.0), (6.0, 14.0)], a);
- }
- "project" | "project_surface" => {
- if name == "project" {
- pen.poly(&[(2.0, 18.0), (12.0, 14.0), (22.0, 18.0), (12.0, 22.0)], pen.fill, ink);
- pen.closed(&[(6.0, 18.0), (12.0, 15.6), (18.0, 18.0), (12.0, 20.4)], a);
- } else {
- pen.line(&[(2.0, 19.0), (7.0, 16.0), (12.0, 17.5), (17.0, 15.5), (22.0, 18.0)], ink);
- pen.line(&[(6.0, 19.5), (12.0, 20.5), (18.0, 18.5)], a);
- }
- pen.iso_box(7.0, 5.0, 5.0, 5.0, 4.0);
- pen.arrow((12.0, 10.5), (12.0, 15.0), a);
- }
- "intersect" | "intersection_curve" => {
- pen.iso_box(6.0, 6.0, 7.0, 5.0, 11.0);
- if name == "intersect" {
- pen.line(&[(2.0, 13.0), (14.0, 8.0), (22.0, 12.0)], ink.gamma_multiply(0.6));
- pen.line(&[(6.0, 13.0), (11.0, 15.5), (18.0, 12.0)], a);
- } else {
- pen.arc(12.0, 26.0, 13.0, 0.6, 2.55, ink.gamma_multiply(0.6));
- pen.line(&[(6.0, 14.5), (11.0, 16.5), (18.0, 13.5)], a);
- }
- }
- "include" => {
- pen.iso_box(4.0, 9.0, 10.0, 7.0, 9.0);
- pen.line(&[(4.0, 9.0), (11.0, 12.5), (21.0, 7.5)], a);
- pen.dot(11.0, 12.5, a);
- }
- "spun" => {
- pen.line(&[(12.0, 2.0), (12.0, 22.0)], ink);
- pen.line(&[(14.0, 5.0), (18.0, 9.0), (18.0, 15.0), (14.0, 19.0)], ink);
- pen.line(&[(10.0, 5.0), (6.0, 9.0), (6.0, 15.0), (10.0, 19.0)], a);
- pen.arc(12.0, 12.0, 6.0, 0.3, 2.8, ink.gamma_multiply(0.6));
- }
- "iso_curve" => {
- pen.poly(&[(3.0, 16.0), (10.0, 6.0), (21.0, 8.0), (14.0, 19.0)], pen.fill, ink);
- pen.line(&[(6.5, 11.0), (17.5, 13.5)], ink.gamma_multiply(0.5));
- pen.line(&[(7.7, 17.3), (14.8, 6.8)], a);
- }
- "fit_section" => {
- pen.closed(&[(3.0, 18.0), (8.0, 6.0), (14.0, 15.0), (19.0, 5.0), (21.0, 18.0)], ink.gamma_multiply(0.6));
- pen.line(&[(8.0, 6.0), (3.0, 18.0)], ink.gamma_multiply(0.6));
- pen.line(&[(14.0, 15.0), (3.0, 18.0)], ink.gamma_multiply(0.6));
- pen.line(&[(14.0, 15.0), (21.0, 18.0)], ink.gamma_multiply(0.6));
- pen.line(&[(2.0, 12.0), (7.0, 10.5), (12.0, 12.5), (17.0, 10.0), (22.0, 11.5)], a);
- }
- "sketch_mirror" => {
- p.add(Shape::dashed_line(&[pen.at(12.0, 2.0), pen.at(12.0, 22.0)], Stroke::new(pen.w, ink), 2.5 * pen.s(), 1.8 * pen.s()));
- pen.closed(&[(3.0, 6.0), (9.0, 10.0), (9.0, 18.0), (3.0, 18.0)], ink);
- pen.closed(&[(21.0, 6.0), (15.0, 10.0), (15.0, 18.0), (21.0, 18.0)], a);
- }
- "sketch_circ_pattern" => paint(p, r, "pattern_circ", ink, Color32::TRANSPARENT, a),
- "sketch_rect_pattern" => paint(p, r, "pattern_rect", ink, Color32::TRANSPARENT, a),
- "line_mid" => {
- pen.line(&[(3.0, 19.0), (21.0, 5.0)], a);
- pen.dot(12.0, 12.0, ink);
- pen.dot(21.0, 5.0, ink);
- pen.circle(12.0, 12.0, 3.0, Color32::TRANSPARENT, ink);
- }
- "arc_tangent" => {
- pen.line(&[(2.0, 18.0), (11.0, 18.0)], ink);
- pen.arc(11.0, 10.0, 8.0, -std::f32::consts::FRAC_PI_2, 0.6, a);
- pen.dot(11.0, 18.0, ink);
- }
- "circle_tt" | "circle_ttt" => {
- if name == "circle_ttt" {
- pen.closed(&[(3.0, 20.0), (21.0, 20.0), (12.0, 4.0)], ink);
- } else {
- pen.line(&[(21.0, 20.0), (3.0, 20.0), (12.0, 4.0)], ink);
- }
- pen.circle(12.0, 14.74, 5.26, Color32::TRANSPARENT, a);
- }
- "arc_slot" => {
- let (t0, t1) = (0.75_f32, 2.4_f32);
- pen.arc(12.0, 21.0, 13.0, t0, t1, a);
- pen.arc(12.0, 21.0, 7.0, t0, t1, a);
- pen.arc(12.0 + 10.0 * t0.cos(), 21.0 - 10.0 * t0.sin(), 3.0, t0, t0 - std::f32::consts::PI, a);
- pen.arc(12.0 + 10.0 * t1.cos(), 21.0 - 10.0 * t1.sin(), 3.0, t1, t1 + std::f32::consts::PI, a);
- pen.dot(12.0, 21.0, ink);
- }
- "ellipse" => {
- let pts: Vec<(f32, f32)> =
- (0..40).map(|i| i as f32 / 40.0 * std::f32::consts::TAU).map(|t| (12.0 + 9.5 * t.cos(), 12.0 + 5.5 * t.sin())).collect();
- pen.closed(&pts, a);
- pen.dot(12.0, 12.0, ink);
- pen.line(&[(12.0, 12.0), (21.5, 12.0)], ink);
- }
- "spline" | "spline_cv" => {
- let c = [(3.0_f32, 18.0_f32), (8.0, 3.0), (16.0, 21.0), (21.0, 6.0)];
- let pts: Vec<(f32, f32)> = (0..=24)
- .map(|i| i as f32 / 24.0)
- .map(|t| {
- let u = 1.0 - t;
- let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t];
- (w.iter().zip(&c).map(|(w, q)| w * q.0).sum(), w.iter().zip(&c).map(|(w, q)| w * q.1).sum())
- })
- .collect();
- if name == "spline_cv" {
- pen.line(&c, ink.gamma_multiply(0.6));
- for q in c {
- pen.dot(q.0, q.1, ink);
- }
- } else {
- for i in [0, 8, 16, 24] {
- if let Some(q) = pts.get(i) {
- pen.dot(q.0, q.1, ink);
- }
- }
- }
- pen.line(&pts, a);
- }
- "conic" => {
- pen.line(&[(3.0, 20.0), (12.0, 3.0), (21.0, 20.0)], ink.gamma_multiply(0.6));
- let pts: Vec<(f32, f32)> =
- (0..=20).map(|i| i as f32 / 20.0).map(|t| (3.0 + 18.0 * t, 20.0 - 4.0 * 0.78 * 17.0 * t * (1.0 - t))).collect();
- pen.line(&pts, a);
- pen.dot(3.0, 20.0, ink);
- pen.dot(21.0, 20.0, ink);
- }
- "text" => {
- p.text(pen.at(12.0, 11.5), egui::Align2::CENTER_CENTER, "A", egui::FontId::proportional(17.0 * pen.s()), a);
- pen.line(&[(4.0, 20.5), (20.0, 20.5)], ink);
- }
- "curvature_comb" => {
- let f = |x: f32| 16.0 - 6.0 * ((x - 3.0) / 18.0 * std::f32::consts::PI).sin();
- let mut env = Vec::new();
- for i in 0..=8 {
- let x = 3.0 + 18.0 * i as f32 / 8.0;
- let k = 1.0 + 5.0 * ((x - 3.0) / 18.0 * std::f32::consts::PI).sin();
- pen.line(&[(x, f(x)), (x, f(x) - k)], a.gamma_multiply(0.7));
- env.push((x, f(x) - k));
- }
- pen.line(&env, a);
- let pts: Vec<(f32, f32)> = (0..=24).map(|i| 3.0 + 18.0 * i as f32 / 24.0).map(|x| (x, f(x))).collect();
- pen.line(&pts, ink);
- }
- "min_radius" => {
- pen.line(&[(3.0, 20.0), (7.0, 9.0), (12.0, 5.5), (17.0, 9.0), (21.0, 20.0)], ink);
- pen.circle(12.0, 10.5, 5.0, Color32::TRANSPARENT, a);
- pen.arrow((12.0, 10.5), (15.5, 7.0), a);
- pen.dot(12.0, 10.5, ink);
- }
- "iso_analysis" => {
- pen.poly(&[(3.0, 17.0), (9.0, 5.0), (21.0, 7.0), (15.0, 19.0)], pen.fill, ink);
- for k in 1..4 {
- let t = k as f32 / 4.0;
- pen.line(&[(3.0 + 6.0 * t, 17.0 - 12.0 * t), (15.0 + 6.0 * t, 19.0 - 12.0 * t)], a);
- pen.line(&[(3.0 + 12.0 * t, 17.0 + 2.0 * t), (9.0 + 12.0 * t, 5.0 + 2.0 * t)], a);
- }
- }
- "accessibility" => {
- pen.poly(&[(2.0, 20.0), (22.0, 20.0), (22.0, 15.0), (2.0, 15.0)], Color32::from_rgb(70, 180, 90), ink);
- pen.poly(&[(8.0, 15.0), (16.0, 15.0), (16.0, 17.5), (8.0, 17.5)], Color32::from_rgb(220, 70, 60), Color32::TRANSPARENT);
- pen.poly(&[(6.0, 10.0), (18.0, 10.0), (18.0, 12.5), (6.0, 12.5)], pen.fill, ink);
- pen.arrow((4.0, 2.0), (4.0, 13.0), a);
- pen.arrow((20.0, 2.0), (20.0, 13.0), a);
- }
- "environment_map" => {
- let pts: Vec =
- (0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())).collect();
- p.add(Shape::convex_polygon(pts, Color32::from_rgb(190, 205, 230), Stroke::new(pen.w, ink)));
- pen.poly(&[(3.6, 13.0), (20.4, 13.0), (18.5, 17.5), (5.5, 17.5)], Color32::from_rgb(60, 62, 70), Color32::TRANSPARENT);
- pen.line(&[(3.2, 12.6), (20.8, 12.6)], Color32::WHITE);
- p.circle_filled(pen.at(9.0, 8.0), 2.2 * pen.s(), Color32::WHITE);
- pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink);
- }
- "zebra" | "draft_analysis" | "curvature_map" => {
- let pts: Vec =
- (0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())).collect();
- match name {
- "zebra" => {
- p.add(Shape::convex_polygon(pts, Color32::WHITE, Stroke::new(pen.w, ink)));
- for k in 0..3 {
- let y = 6.5 + k as f32 * 4.5;
- let h = (81.0 - (y + 1.0 - 12.0) * (y + 1.0 - 12.0)).max(0.0).sqrt();
- pen.poly(
- &[(12.0 - h, y), (12.0 + h, y), (12.0 + h, y + 2.2), (12.0 - h, y + 2.2)],
- Color32::from_gray(30),
- Color32::TRANSPARENT,
- );
- }
- pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink);
- }
- "draft_analysis" => {
- pen.poly(&[(4.0, 20.0), (20.0, 20.0), (17.0, 5.0), (7.0, 5.0)], Color32::from_rgb(70, 180, 90), ink);
- pen.poly(&[(7.0, 5.0), (17.0, 5.0), (16.0, 9.0), (8.0, 9.0)], Color32::from_rgb(235, 200, 50), Color32::TRANSPARENT);
- pen.arrow((12.0, 16.0), (12.0, 1.5), a);
- }
- _ => {
- p.add(Shape::convex_polygon(pts, Color32::from_rgb(60, 110, 230), Stroke::new(pen.w, ink)));
- pen.circle(14.0, 10.0, 5.0, Color32::from_rgb(70, 190, 90), Color32::TRANSPARENT);
- pen.circle(15.5, 8.5, 2.2, Color32::from_rgb(225, 60, 45), Color32::TRANSPARENT);
- }
- }
- }
- "center_of_mass" => {
- pen.circle(12.0, 12.0, 8.0, Color32::TRANSPARENT, ink);
- let q = |a0: f32| {
- let pts: Vec