From 9f76cca8b46f088bb4cc5c9c85abf062719254f8 Mon Sep 17 00:00:00 2001 From: Nathan Dane Date: Wed, 14 Oct 2026 06:49:32 +0200 Subject: [PATCH] Collect application resources for #433 (#833) --- .github/workflows/packaging-lint.yml | 14 +- AGENTS.md | 4 +- ATTRIBUTION.md | 88 +- THIRD-PARTY-LICENSES.txt | 4 +- apps/solidcraft-cli/src/main.rs | 2 +- apps/solidcraft-web/index.html | 11 +- apps/solidcraft/build.rs | 54 +- apps/solidcraft/src/main.rs | 2 +- assets/README.md | 127 +- assets/appearances/library.rs | 23 + .../exports}/icon.iconset/icon_128x128.png | Bin .../exports}/icon.iconset/icon_128x128@2x.png | Bin .../exports}/icon.iconset/icon_16x16.png | Bin .../exports}/icon.iconset/icon_16x16@2x.png | Bin .../exports}/icon.iconset/icon_256x256.png | Bin .../exports}/icon.iconset/icon_256x256@2x.png | Bin .../exports}/icon.iconset/icon_32x32.png | Bin .../exports}/icon.iconset/icon_32x32@2x.png | Bin .../exports}/icon.iconset/icon_512x512.png | Bin .../exports}/icon.iconset/icon_512x512@2x.png | Bin .../icons/macos-v1/exports}/icon.png | Bin .../icons/macos-v1/exports}/icon.svg | 0 .../icons/macos-v1/source}/icon.vectorcraft | 0 .../src => assets/branding/web/source}/og.png | Bin .../biz-udp-gothic}/BIZUDPGothic-Regular.ttf | Bin .../fonts/biz-udp-gothic}/OFL.txt | 0 .../LiberationSans-Regular.ttf | Bin .../fonts/liberation-sans}/OFL.txt | 0 .../apps/ai.storyteller.solidcraft.png | Bin 0 -> 6330 bytes .../16x16/apps/ai.storyteller.solidcraft.png | Bin 0 -> 629 bytes .../24x24/apps/ai.storyteller.solidcraft.png | Bin 0 -> 944 bytes .../apps/ai.storyteller.solidcraft.png | Bin 0 -> 13471 bytes .../32x32/apps/ai.storyteller.solidcraft.png | Bin 0 -> 1287 bytes .../48x48/apps/ai.storyteller.solidcraft.png | Bin 0 -> 2087 bytes .../apps/ai.storyteller.solidcraft.png | Bin 0 -> 29091 bytes .../64x64/apps/ai.storyteller.solidcraft.png | Bin 0 -> 2940 bytes .../{ => icons/app/macos/exports}/Assets.car | Bin .../{ => icons/app/macos/exports}/icon.icns | Bin .../SolidCraft.icon/Assets/02-grid-dark.svg | 0 .../SolidCraft.icon/Assets/02-grid.svg | 0 .../Assets/03-solid-blue-cutout.svg | 0 .../macos/source}/SolidCraft.icon/icon.json | 0 .../icons/app/shared/source}/app_icon.rs | 0 assets/icons/app/shared/source/windows_ico.rs | 48 + .../windows/exports/solidcraft-executable.ico | Bin 0 -> 372526 bytes .../icons/app/windows/exports/solidcraft.ico | Bin 0 -> 30333 bytes assets/icons/ui/README.md | 22 + assets/icons/ui/source/glyphs.rs | 158 ++ assets/icons/ui/source/icons.rs | 1510 ++++++++++++++++ assets/icons/ui/source/markers.rs | 86 + assets/icons/ui/source/navigation.rs | 167 ++ assets/icons/ui/source/pointer.rs | 14 + .../ui-egui/src => assets}/locales/de.json | 0 .../ui-egui/src => assets}/locales/en.json | 0 .../ui-egui/src => assets}/locales/ja.json | 0 .../ui-egui/src => assets}/locales/ko.json | 0 .../ui-egui/src => assets}/locales/zh.json | 0 assets/materials/library.rs | 40 + .../linux/ai.storyteller.solidcraft.desktop | 0 .../ai.storyteller.solidcraft.metainfo.xml.in | 0 .../linux/ai.storyteller.solidcraft.mime.xml | 0 .../platforms}/macos/Info.plist.in | 0 .../platforms}/macos/README.txt | 0 .../platforms}/windows/solidcraft.en-us.wxl | 0 .../platforms}/windows/solidcraft.wxs | 0 assets/shaders/viewport.wgsl | 316 ++++ assets/styles/ui/palettes.rs | 137 ++ assets/styles/web/loading.css | 4 + crates/doc/src/appearance.rs | 25 +- crates/doc/src/document.rs | 42 +- crates/engine/src/cmd/sketch_modify_tests.rs | 2 +- crates/sketch/src/font_tests.rs | 10 +- crates/sketch/src/fonts.rs | 2 +- crates/sketch/src/text.rs | 4 +- crates/ui-egui/src/agent_cursor.rs | 16 +- crates/ui-egui/src/browser.rs | 41 +- crates/ui-egui/src/context_menu.rs | 12 +- crates/ui-egui/src/dialogs_assembly.rs | 52 +- crates/ui-egui/src/dim_view.rs | 4 +- crates/ui-egui/src/gpu.rs | 318 +--- crates/ui-egui/src/i18n.rs | 10 +- crates/ui-egui/src/i18n_menu_tests.rs | 2 +- crates/ui-egui/src/icons.rs | 1522 +---------------- crates/ui-egui/src/navigation_cube.rs | 153 +- crates/ui-egui/src/sketch_text.rs | 2 +- crates/ui-egui/src/sketch_tools.rs | 28 +- crates/ui-egui/src/theme.rs | 139 +- crates/ui-egui/src/timeline.rs | 24 +- crates/ui-egui/src/titlebar.rs | 25 +- crates/ui-egui/src/toolbar.rs | 70 +- crates/ui-egui/src/viewport.rs | 21 +- docs/book/.gitignore | 1 + docs/book/src/dev/commands.md | 2 +- docs/compatibility.md | 10 +- docs/localization.md | 2 +- flake.nix | 6 +- packaging/env.sh | 11 +- packaging/freebsd/package.sh | 2 +- packaging/linux/package.sh | 8 +- packaging/macos/package.sh | 15 +- packaging/windows/package.ps1 | 13 +- xtask/src/app_icons.rs | 40 + xtask/src/assets.rs | 117 +- xtask/src/book.rs | 1 + xtask/src/i18n.rs | 4 +- xtask/src/licences.rs | 8 +- xtask/src/main.rs | 5 +- 107 files changed, 2940 insertions(+), 2660 deletions(-) create mode 100644 assets/appearances/library.rs rename assets/{ => archive/icons/macos-v1/exports}/icon.iconset/icon_128x128.png (100%) rename assets/{ => archive/icons/macos-v1/exports}/icon.iconset/icon_128x128@2x.png (100%) rename assets/{ => archive/icons/macos-v1/exports}/icon.iconset/icon_16x16.png (100%) rename assets/{ => archive/icons/macos-v1/exports}/icon.iconset/icon_16x16@2x.png (100%) rename assets/{ => archive/icons/macos-v1/exports}/icon.iconset/icon_256x256.png (100%) rename assets/{ => archive/icons/macos-v1/exports}/icon.iconset/icon_256x256@2x.png (100%) rename assets/{ => archive/icons/macos-v1/exports}/icon.iconset/icon_32x32.png (100%) rename assets/{ => archive/icons/macos-v1/exports}/icon.iconset/icon_32x32@2x.png (100%) rename assets/{ => archive/icons/macos-v1/exports}/icon.iconset/icon_512x512.png (100%) rename assets/{ => archive/icons/macos-v1/exports}/icon.iconset/icon_512x512@2x.png (100%) rename assets/{ => archive/icons/macos-v1/exports}/icon.png (100%) rename assets/{ => archive/icons/macos-v1/exports}/icon.svg (100%) rename assets/{ => archive/icons/macos-v1/source}/icon.vectorcraft (100%) rename {docs/book/src => assets/branding/web/source}/og.png (100%) rename {apps/solidcraft-web/fonts => assets/fonts/biz-udp-gothic}/BIZUDPGothic-Regular.ttf (100%) rename {apps/solidcraft-web/fonts => assets/fonts/biz-udp-gothic}/OFL.txt (100%) rename {crates/sketch/fonts => assets/fonts/liberation-sans}/LiberationSans-Regular.ttf (100%) rename {crates/sketch/fonts => assets/fonts/liberation-sans}/OFL.txt (100%) create mode 100644 assets/icons/app/linux/exports/hicolor/128x128/apps/ai.storyteller.solidcraft.png create mode 100644 assets/icons/app/linux/exports/hicolor/16x16/apps/ai.storyteller.solidcraft.png create mode 100644 assets/icons/app/linux/exports/hicolor/24x24/apps/ai.storyteller.solidcraft.png create mode 100644 assets/icons/app/linux/exports/hicolor/256x256/apps/ai.storyteller.solidcraft.png create mode 100644 assets/icons/app/linux/exports/hicolor/32x32/apps/ai.storyteller.solidcraft.png create mode 100644 assets/icons/app/linux/exports/hicolor/48x48/apps/ai.storyteller.solidcraft.png create mode 100644 assets/icons/app/linux/exports/hicolor/512x512/apps/ai.storyteller.solidcraft.png create mode 100644 assets/icons/app/linux/exports/hicolor/64x64/apps/ai.storyteller.solidcraft.png rename assets/{ => icons/app/macos/exports}/Assets.car (100%) rename assets/{ => icons/app/macos/exports}/icon.icns (100%) rename assets/{ => icons/app/macos/source}/SolidCraft.icon/Assets/02-grid-dark.svg (100%) rename assets/{ => icons/app/macos/source}/SolidCraft.icon/Assets/02-grid.svg (100%) rename assets/{ => icons/app/macos/source}/SolidCraft.icon/Assets/03-solid-blue-cutout.svg (100%) rename assets/{ => icons/app/macos/source}/SolidCraft.icon/icon.json (100%) rename {apps => assets/icons/app/shared/source}/app_icon.rs (100%) create mode 100644 assets/icons/app/shared/source/windows_ico.rs create mode 100644 assets/icons/app/windows/exports/solidcraft-executable.ico create mode 100644 assets/icons/app/windows/exports/solidcraft.ico create mode 100644 assets/icons/ui/README.md create mode 100644 assets/icons/ui/source/glyphs.rs create mode 100644 assets/icons/ui/source/icons.rs create mode 100644 assets/icons/ui/source/markers.rs create mode 100644 assets/icons/ui/source/navigation.rs create mode 100644 assets/icons/ui/source/pointer.rs rename {crates/ui-egui/src => assets}/locales/de.json (100%) rename {crates/ui-egui/src => assets}/locales/en.json (100%) rename {crates/ui-egui/src => assets}/locales/ja.json (100%) rename {crates/ui-egui/src => assets}/locales/ko.json (100%) rename {crates/ui-egui/src => assets}/locales/zh.json (100%) create mode 100644 assets/materials/library.rs rename {packaging => assets/platforms}/linux/ai.storyteller.solidcraft.desktop (100%) rename {packaging => assets/platforms}/linux/ai.storyteller.solidcraft.metainfo.xml.in (100%) rename {packaging => assets/platforms}/linux/ai.storyteller.solidcraft.mime.xml (100%) rename {packaging => assets/platforms}/macos/Info.plist.in (100%) rename {packaging => assets/platforms}/macos/README.txt (100%) rename {packaging => assets/platforms}/windows/solidcraft.en-us.wxl (100%) rename {packaging => assets/platforms}/windows/solidcraft.wxs (100%) create mode 100644 assets/shaders/viewport.wgsl create mode 100644 assets/styles/ui/palettes.rs create mode 100644 assets/styles/web/loading.css create mode 100644 xtask/src/app_icons.rs 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 0000000000000000000000000000000000000000..4ae8425954de17e8af87afacdb805e4bffceb483 GIT binary patch literal 6330 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 P00000NkvXXu0mjfn!a5t literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b54a031b62d49d4d91bf35f6c3b8f9ab0dc7e4ef GIT binary patch literal 944 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 t@nPYfs0Ms#Xy2`catU literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9b5ca774daa0d87163b2d9cdd41c75f361d3ddcf GIT binary patch literal 1287 zcmV+i1^D`jP)AybWiT6yrC|>54j<p5#S9<(X{17Vz0W{Z~h?{i}~# zu^i05WYLSae(43L`++a`o`2xUcOLPtMDT2%{e-*6*Z%#dtXU4`Me*qCDlc_o<=cPp zcAk9a5&s4R5AgNBwc>~k{7e4owvo^Ig?--jO&`uTe&maB{mB{sN(2w^ zSGP}m=no$7_jj33c%vO&?cp>2CCkBl$L}BV(~mP>^uaI5N5AcZ*;*7{M2Kgy{adg0 zB^O_tXWE*HcYOI7ulBIYPyh81PrPU1Me#DXbiBn&%sIUk>}+=8g(G+Y4%X&7|6s+Z z{qzy9`mhD>_v)K0dhsv0?LyA2skJ$%N;z3zg)`-eOE(69Xm{_-h%04xXdLw|b2$N$*H*M7p?<6qnU zD%;0+HpDa8{;gO0l8dj+i(qRe-uKn!ywpvFjb8lfpWMw4|Mp!REF1Ux#*ch4zV7!Y z9_VFm>3HW?+}yopyL_~y?*Ry&?Yn>UgLZab;}ISIl7qGR+TUFA2Tz^&)jzqLZ~vv+ zIaoISB^!OPe=r;e-kU23o=MX8+<3e^_>13m>#M)+UADMxC-{%@sOu`9^u{}|e~}OR zs=ws{s62qAOTfq2{_dankYiu{BY(Df?eF}96$fkcZ*giXcEeh}a+Bfk2 zZ+i1@1E0+mRQ?OR*(;slBj4u1w;tQ@e|>zb`)EnO!wW~{MIh;SfS0lTUT^&3{a1h8 zZ!VAj-jhcj=*?c~4Db8KH}Bqfdj8RpzK0hL;$O-3G2pZR^0x7#4_3yjeYO{smwm*s xuLeF_(j{JWh<_v74+6fME8bhuGx*oz9|7phxt!>&aJm2h002ovPDHLkV1g$W9gbxr42!X2lv;#XTjWgP#LgoTiAl9uU>v1hCMq@I%polSK6FX~xqJxo z01C!xHB>OM!wokEnNkS|$w%ZaLM9di1d|YiaL)~rFSqa87tYRkFMqtZ=lwm;^LyX& zua6`I1%4ce!{LIG5)U53`klTSfXCX@_nf6T9AP=>;QkYwbxR$74?a76x5t9Amn-{T z)?G|K*O;Se{(t$dLVWiRQt|J1ZC!Te%d!gpQ5w%0A;T$ua_?^j2kgL@U?UnAfRv2U z&b#Z2(m=GoUX6I8q>QkZ5qststp|+m0=H;P{=C)Tb!4K`yYK>Y3Na46 z>0#B32WnSCCU+Vot*#4bu4_bHv@oy&m ziWh6vLM~#mz$Fz$6KufK+3q%l0WU?$;^>|w!@uT3;*4 zb8tKw5G}G1`k~%5`ARW`w^$Q3(X8U~EWsS>{RB_^(E?gF6k;AR+BGGR*FoeM_64I6 z!e@Hd@|wZe4mX6-J-$c2=;td(@H|3+bhbD;&JB}dG1$;z>Iw0h#@o4=V&2dA&LWRjg_q`mr#+(zDbb&h!+lTQg) z*vEVuYXvSKTb#F;+Nq=JKWfq4x%xrI-22ScV42la#KCEx)o=*nEtxzeAu5$yFq=|ri4n8DAi4fdX|Ytn*GSy$kU1)|Kp^I*xxvtXL9 zG&Or)btELa!o{I?P#l3}yAS@y5Q^sNF_wo!Q0K2P;opEqdK*Tu>tUfw1-ohhv|Hnx z)G4l)WK1!wgp|`Zku0cv@|=N;nc=>al z;|H0C zrOrO24K#dt!=jMJX0o;+4aqcs5lIrFP zt%C*W>dAfW7irpyC=q`{`6 zPST<;Eo8R4J6n-oJSX+Fk>2-J=8{$#Dr{z*3Qiw;jq8#bBAwN{|XeNQ-xg>4%$Z!ZUh zB0ju< z*v^m(vO?i3eSPFv1CwAerMvfI4+Y@G62!X%lsBjxpD^*6IX4_bYkAS3!C*R{0(!4* z<$RIE2XhxJ`^jqBGPkKiGb(6sa96uip}FK0vTqB62odO=1x=}YJKa$K;5T3vqF(b5 z!rzEq$%aDBBVd7s+1`s#y$YJq(eszHXcYX F{vRtR#WVl_ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..128dd43146e186cf408d8dda723eefb1ee6aa402 GIT binary patch literal 29091 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..26abe266c497d62b8cca533e9dd86d251281286a GIT binary patch literal 2940 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 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 0000000000000000000000000000000000000000..92f61f5834222486b0dee7c610c1b61ce60ef34c GIT binary patch literal 372526 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 TARRt%6_5_&H?7aFt?&PT#O%ri literal 0 HcmV?d00001 diff --git a/assets/icons/app/windows/exports/solidcraft.ico b/assets/icons/app/windows/exports/solidcraft.ico new file mode 100644 index 0000000000000000000000000000000000000000..2e2ccc76a4c29dc9a1a7e26d0c2ebdfabf67352f GIT binary patch literal 30333 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 literal 0 HcmV?d00001 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 = 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"); - } - } - } -} +//! Stable UI rendering boundary for the collected artwork. Compiled only at L5. +#[path = "../../../assets/icons/ui/source/icons.rs"] +mod source; +pub use source::paint; +#[path = "../../../assets/icons/ui/source/glyphs.rs"] +pub(crate) mod glyphs; +#[path = "../../../assets/icons/ui/source/markers.rs"] +pub(crate) mod markers; +#[path = "../../../assets/icons/ui/source/navigation.rs"] +pub(crate) mod navigation; +#[path = "../../../assets/icons/ui/source/pointer.rs"] +pub(crate) mod pointer; diff --git a/crates/ui-egui/src/navigation_cube.rs b/crates/ui-egui/src/navigation_cube.rs index fe8fa3ca..876e6db7 100644 --- a/crates/ui-egui/src/navigation_cube.rs +++ b/crates/ui-egui/src/navigation_cube.rs @@ -1,5 +1,6 @@ //! Original, code-drawn navigation cube. Flat faces share invisible picking regions. use super::*; +use crate::icons::navigation::{Facet, arrow, curved_arrow, paint_cube}; pub(super) const SIZE: f32 = 112.0; // Paint and picking use the same original coordinate space, scaled as a group. @@ -9,16 +10,6 @@ const HALF: f64 = 40.0; // The remaining regions belong to the face, including the middle of each edge. const INSET: f64 = 0.68; -struct Facet { - direction: Vec3, - points: Vec, - outer_edges: Vec, - centre: Pos2, - label: String, - text_axes: Option<(egui::Vec2, egui::Vec2)>, - depth: f64, -} - fn project(cam: &Camera, v: Vec3, scale: f64) -> egui::Vec2 { let (r, u, _) = cam.basis(); let v = cam.from_frame(v); @@ -128,120 +119,6 @@ pub(super) fn drag_origin(cam: &Camera, centre: Pos2, pointer: Pos2, scale: f32) hit(&facets(cam, centre, HALF * f64::from(scale)), pointer).is_some() } -// Private marker for label meshes with atlas-pixel UVs. The output hook replaces -// it with the font texture before any renderer sees the frame. -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. -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)); - } - } -} - -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); - } - } -} - fn navigate(app: &mut SolveApp, action: Option<&str>, dir: Option) { let mut params = json!({"camera":app.cam}); if let Some(action) = action { @@ -263,32 +140,6 @@ fn control(app: &mut SolveApp, ui: &mut egui::Ui, bounds: Rect, action: &str, to hover } -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 }), - )); -} - -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(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { let outer = axis_rect(app, rect); if !outer.is_positive() { @@ -387,6 +238,8 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { #[cfg(test)] mod tests { use super::*; + #[cfg(test)] + use crate::icons::navigation::{LABEL_PIXELS, face_text}; use solidcraft_engine::render::{StandardView, UpAxis}; fn paint_frame(ctx: &egui::Context, paint: impl FnMut(&mut egui::Ui)) -> egui::FullOutput { diff --git a/crates/ui-egui/src/sketch_text.rs b/crates/ui-egui/src/sketch_text.rs index fe54ff28..346b3b37 100644 --- a/crates/ui-egui/src/sketch_text.rs +++ b/crates/ui-egui/src/sketch_text.rs @@ -863,7 +863,7 @@ mod tests { fn the_font_row_sets_keeps_and_clears_the_font_of_a_text() { // The repository's second font, BIZ UDPGothic: its "I" is 17.6385 mm² at a capital height of 10 mm, the built-in // font's 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 (mut h, link, _) = setup(); // The dialog closes with the selection; the text's first outline is selected again to open it. let reopen = |h: &mut Harness| { diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index bc843639..40d2b148 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -3,6 +3,8 @@ //! tools for free-form curves and slots, text placement, and constraint glyphs. Like `tools`, //! these are UI state only: every action runs a command. +use crate::icons::markers::glyph_icon; +use crate::icons::markers::glyph_letter; use std::cell::{Cell, RefCell}; use egui::{Pos2, Stroke}; @@ -1023,24 +1025,6 @@ fn glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj, hover: Option 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, - }) -} - /// The curves and points a constraint ties together. fn constraint_entities(k: &solidcraft_engine::sketch::ConstraintKind) -> (Vec, Vec) { use solidcraft_engine::sketch::ConstraintKind::*; @@ -1062,14 +1046,6 @@ fn constraint_entities(k: &solidcraft_engine::sketch::ConstraintKind) -> (Vec &'static str { - match name { - "Horizontal" => "H", - "Vertical" => "V", - _ => "?", - } -} - /// Where a constraint's glyphs go: one per entity (the middle of a curve, with its direction /// there; a point, without). fn glyph_anchors(sk: &solidcraft_engine::sketch::Sketch, k: &solidcraft_engine::sketch::ConstraintKind) -> Vec<(Vec2, Option)> { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 7e327c27..285d2c8a 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -3,6 +3,9 @@ use std::sync::atomic::{AtomicBool, Ordering}; +#[path = "../../../assets/styles/ui/palettes.rs"] +mod palettes; + use egui::{Color32, Stroke}; static DARK: AtomicBool = AtomicBool::new(true); @@ -148,142 +151,6 @@ impl Tokens { t.axis_z = t.axis_z.lerp_to_gamma(t.viewport_top, 0.55); t } - - 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), - } - } - - /// Dark blue-grey: viewport #2e3440 as measured in the reference tool, chrome a little - /// darker, light text, a blue accent. - 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 - } } /// How a physical material looks (the default appearance follows the material). diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index 65e8e601..736a5988 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -1,6 +1,7 @@ //! The timeline (bottom): features in order, the rollback marker, errors and warnings. -use egui::{Color32, Rect, Sense, Stroke, UiBuilder, pos2, vec2}; +pub(crate) use crate::icons::glyphs::transport_glyph; +use egui::{Rect, Sense, Stroke, UiBuilder, pos2, vec2}; use serde_json::json; use solidcraft_engine::doc::FeatureKind; @@ -346,24 +347,3 @@ fn find_in_browser(app: &mut SolveApp, id: u64) { let items = if bodies.is_empty() { vec![json!({"type": "feature", "id": id})] } else { bodies }; let _ = app.run("select.set", json!({ "items": items })); } - -/// Transport buttons drawn as shapes (the UI font has no media glyphs). -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); - } - } -} diff --git a/crates/ui-egui/src/titlebar.rs b/crates/ui-egui/src/titlebar.rs index 6144c3d2..7d7af003 100644 --- a/crates/ui-egui/src/titlebar.rs +++ b/crates/ui-egui/src/titlebar.rs @@ -8,6 +8,7 @@ //! //! Adapted from VectorCraft's `crates/ui-egui/src/titlebar.rs` (MIT OR Apache-2.0). +use crate::icons::glyphs::paint_glyph; use egui::{Color32, CursorIcon, Id, LayerId, Order, PointerButton, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2}; use crate::SolveApp; @@ -127,30 +128,6 @@ pub fn caption_buttons(app: &mut SolveApp, ui: &mut Ui, bar: Rect) { let _ = window_action(app, &ui.ctx().clone(), action); } -/// The 10 pt line glyphs of the caption buttons. -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); - } - } -} - /// The edge and corner zones of a window with content rect `w`: (zone, direction, cursor). /// Corners come last so they win where they overlap the edges. fn zones(w: Rect) -> [(Rect, ResizeDirection, CursorIcon); 8] { diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 2a5f1df0..36561fea 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -1,5 +1,7 @@ //! Application bar, the workspace toolbar (tabs and panels) and keyboard shortcuts. +use crate::icons::glyphs::caret; +use crate::icons::glyphs::pin_icon; use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, pos2, vec2}; use serde_json::json; @@ -168,47 +170,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { // (viewBox 357 x 412). Same 20 px height and centre as the old block. { let c = pos2(left + 20.0, r.center().y); - 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() }; - let p = ui.painter(); - // 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, - )); + crate::icons::glyphs::brand_mark(ui.painter(), c); } if !compact_actions { ui.painter().text(pos2(left + 36.0, r.center().y), Align2::LEFT_CENTER, "SolidCraft", FontId::proportional(14.0), t.app_bar_text); @@ -1130,30 +1092,11 @@ fn drag_ghost(ctx: &egui::Context, id: &str, at: egui::Pos2, removing: bool) { let s = Stroke::new(2.0, t.error); let x = Rect::from_center_size(r.right_top(), vec2(9.0, 9.0)); p.circle_filled(x.center(), 7.0, t.toolbar); - p.line_segment([x.left_top(), x.right_bottom()], s); - p.line_segment([x.right_top(), x.left_bottom()], s); + crate::icons::glyphs::cross(&p, x, s); } crate::scenario::publish_handle(if removing { "toolbar_drag:remove" } else { "toolbar_drag" }, at); } -/// A push pin (drawn in code): upright and filled when the command is on the toolbar, tilted and -/// outlined when not. -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); -} - /// The SELECT panel: what a click in the view picks. fn selection_filter(app: &mut SolveApp, ui: &mut egui::Ui) { let t = Tokens::get(); @@ -1179,11 +1122,6 @@ fn selection_filter(app: &mut SolveApp, ui: &mut egui::Ui) { } } -/// A small drop-down caret. -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)); -} - /// Global keyboard shortcuts (when no text field has focus). pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { if app.file_load.busy() { diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 498e2b17..e083d31d 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -2832,7 +2832,7 @@ fn axis_rect(app: &SolveApp, rect: Rect) -> Rect { } const AXIS_LENGTH: f64 = 38.0; -const AXIS_RADIUS: f32 = 11.0; +use crate::icons::navigation::AXIS_RADIUS; /// Six signed world axes, sorted back to front so overlapping tips have one owner. fn axis_tips(cam: &Camera, c: Pos2) -> Vec<(&'static str, Vec3, Pos2, f64)> { @@ -2999,24 +2999,7 @@ fn signed_axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { painter.circle_filled(c, 55.0, t.overlay); } for (i, (name, d, at, depth)) in tips.iter().enumerate() { - let col = if d.x != 0.0 { - colors::AXIS_X - } else if d.y != 0.0 { - colors::AXIS_Y - } else { - 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 == Some(i) { t.accent_soft } else { t.overlay }); - painter.circle_stroke( - *at, - AXIS_RADIUS, - Stroke::new(if hovered == Some(i) { 2.0 } else { 1.0 }, if hovered == Some(i) { t.accent } else { ink }), - ); - painter.text(*at, Align2::CENTER_CENTER, name.to_uppercase(), FontId::proportional(11.0), ink); + crate::icons::navigation::axis_tip(&painter, c, name, d, *at, *depth, hovered == Some(i), t); crate::scenario::publish_handle(&format!("axis_view:{name}"), *at); } crate::scenario::publish_count("axis_view_highlights", f64::from(u8::from(hovered.is_some()))); diff --git a/docs/book/.gitignore b/docs/book/.gitignore index 14e59e8e..5034fda2 100644 --- a/docs/book/.gitignore +++ b/docs/book/.gitignore @@ -1,2 +1,3 @@ /src/generated/ /src/reference/commands.md +/src/og.png diff --git a/docs/book/src/dev/commands.md b/docs/book/src/dev/commands.md index d7b0d3de..d6e20066 100644 --- a/docs/book/src/dev/commands.md +++ b/docs/book/src/dev/commands.md @@ -9,7 +9,7 @@ parameters and never open dialogs; a dialog is a front-end form that builds the ```rust CommandSpec::new("solid.fillet", "Fillet", fillet) .at("SOLID", "MODIFY") // toolbar tab and panel ("" = not on the toolbar) - .icon("fillet") // drawn in code, crates/ui-egui/src/icons.rs + .icon("fillet") // drawn in code, assets/icons/ui/source/icons.rs .key("F") // default shortcut .params("edges: [[x,y,z] | {body, index}]; radius: expr; …"), ``` diff --git a/docs/compatibility.md b/docs/compatibility.md index daa8ffec..1c4f97cb 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -20,9 +20,9 @@ New designs are saved as `.solidcraft`. Files with the old extension still open | Open dialog filters (desktop) | `apps/solidcraft/src/main.rs` (`services`) | | Drag-drop, recent files, `doc.open`, MCP `open`, CLI `eval`/`run` | go through the same format detection in `solidcraft_io` | | Browser file picker | `apps/solidcraft-web/src/files.rs` (`accept`) | -| Windows | `packaging/windows/solidcraft.wxs`: the `.solvecraft` extension on the design ProgId and in `SupportedTypes` | -| macOS | `packaging/macos/Info.plist.in`: `solvecraft` in the design document type's extensions | -| Linux / FreeBSD / Flatpak | `packaging/linux/ai.storyteller.solidcraft.mime.xml`: `*.solvecraft` glob and the `application/x-solvecraft` alias | +| Windows | `assets/platforms/windows/solidcraft.wxs`: the `.solvecraft` extension on the design ProgId and in `SupportedTypes` | +| macOS | `assets/platforms/macos/Info.plist.in`: `solvecraft` in the design document type's extensions | +| Linux / FreeBSD / Flatpak | `assets/platforms/linux/ai.storyteller.solidcraft.mime.xml`: `*.solvecraft` glob and the `application/x-solvecraft` alias | Recent-files entries that point at a `.solvecraft` file keep working: they are plain paths and open through the same detection. The browser app also lists designs it stored under the old @@ -126,8 +126,8 @@ everywhere (Flatpak, deb, rpm, AppImage, Snap, FreeBSD, Nix). The shims: | Shim | Where | What it does | |---|---|---| | End-of-life rebase in the Flatpak repository | `packaging/flatpak-site.sh` | Each release's Pages deploy keeps the old `app/ai.storyteller.solvecraft//stable` refs (mirrored from the previous deploy) and marks them once with `flatpak build-commit-from --end-of-life-rebase=ai.storyteller.solvecraft=ai.storyteller.solidcraft`. `flatpak update` then says the old app is end-of-life and offers to replace it with the new one; accepting installs `ai.storyteller.solidcraft`, uninstalls the old app, and Flatpak moves `~/.var/app/ai.storyteller.solvecraft` to the new id's folder (leaving a symlink at the old path) on the first launch, so settings and autosaves come along unchanged. | -| AppStream `` / `` | `packaging/linux/ai.storyteller.solidcraft.metainfo.xml.in` | Software centres treat the new id as the successor of the old one and of `ai.storyteller.solvecraft.desktop`. | -| `X-Flatpak-RenamedFrom` | `packaging/linux/ai.storyteller.solidcraft.desktop` | Desktops that honour it (GNOME Shell) keep favourites and pinned launchers pointing at the renamed entry. | +| AppStream `` / `` | `assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in` | Software centres treat the new id as the successor of the old one and of `ai.storyteller.solvecraft.desktop`. | +| `X-Flatpak-RenamedFrom` | `assets/platforms/linux/ai.storyteller.solidcraft.desktop` | Desktops that honour it (GNOME Shell) keep favourites and pinned launchers pointing at the renamed entry. | | Read-only access to the old Flatpak data folder | `--filesystem=~/.var/app/ai.storyteller.solvecraft:ro` in `packaging/linux/flatpak/ai.storyteller.solidcraft.bundle.yml`; `solidcraft_io::legacy_flatpak_path`, used by `adopt_legacy_settings` and `recovery::default_dir` | When the new app was installed next to the old one (for example from a `.flatpak` bundle, which has no update feed) instead of through the rebase, its first launch copies settings and autosaves from the old app's folder, as in section 4. The old folder is not changed. | | The old window `app_id` as eframe's legacy storage key | `adopt_legacy_settings` (`LEGACY_APP_ID`) | Window state that releases before the settings rename kept under `~/.local/share/ai.storyteller.solvecraft` is still copied (section 4). | | Old AppImage menu entries | `packaging/linux/AppRun` | An AppImage's first run removes the `ai.storyteller.solvecraft.desktop` entry and icons that an AppImage up to 0.7 installed in `~/.local/share`, so the menu doesn't show the app twice. | diff --git a/docs/localization.md b/docs/localization.md index 407f49e7..b55e93f7 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -7,7 +7,7 @@ registry. ## Messages -`crates/ui-egui/src/locales/en.json` defines stable message IDs and English fallback text. +`assets/locales/en.json` defines stable message IDs and English fallback text. Keep IDs when changing wording. Other catalogs use the same IDs; missing messages fall back to English. Use `app.tr("ui.preferences")` for static messages and `Locale::format` for full sentences with named placeholders. Translate the entire sentence, diff --git a/flake.nix b/flake.nix index 4afc02c6..4b9d4650 100644 --- a/flake.nix +++ b/flake.nix @@ -135,14 +135,14 @@ postInstall = '' ${pkgs.lib.optionalString pkgs.stdenv.isLinux '' - install -Dm644 packaging/linux/ai.storyteller.solidcraft.desktop \ + install -Dm644 assets/platforms/linux/ai.storyteller.solidcraft.desktop \ $out/share/applications/ai.storyteller.solidcraft.desktop - install -Dm644 packaging/linux/ai.storyteller.solidcraft.mime.xml \ + install -Dm644 assets/platforms/linux/ai.storyteller.solidcraft.mime.xml \ $out/share/mime/packages/ai.storyteller.solidcraft.xml for size in 16 24 32 48 64 128 256 512; do icon=$out/share/icons/hicolor/''${size}x''${size}/apps/ai.storyteller.solidcraft.png mkdir -p "$(dirname "$icon")" - $out/bin/solidcraft-cli icon --size "$size" --out "$icon" >/dev/null + cp assets/icons/app/linux/exports/hicolor/''${size}x''${size}/apps/ai.storyteller.solidcraft.png "$icon" >/dev/null done ''} ''; diff --git a/packaging/env.sh b/packaging/env.sh index 0703847d..39408ae9 100644 --- a/packaging/env.sh +++ b/packaging/env.sh @@ -57,15 +57,12 @@ copy_docs() { done } -# The app icon in every size a package needs, drawn by the CLI (no image files are committed): -# $1 = the solidcraft-cli to run, $2 = output dir. Writes hicolor/x/apps/$APP_ID.png, -# solidcraft.ico and solidcraft-1024.png. +# Copy canonical package exports; $1 is retained for callers' CLI argument compatibility. make_icons() { - local cli="$1" out="$2" s + local out="$2" s for s in 16 24 32 48 64 128 256 512; do mkdir -p "$out/hicolor/${s}x${s}/apps" - "$cli" icon --size "$s" --out "$out/hicolor/${s}x${s}/apps/$APP_ID.png" >/dev/null + cp "$ROOT/assets/icons/app/linux/exports/hicolor/${s}x${s}/apps/$APP_ID.png" "$out/hicolor/${s}x${s}/apps/$APP_ID.png" || return done - "$cli" icon --out "$out/solidcraft.ico" >/dev/null - "$cli" icon --size 1024 --out "$out/solidcraft-1024.png" >/dev/null + cp "$ROOT/assets/icons/app/windows/exports/solidcraft.ico" "$out/solidcraft.ico" } diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index e13404db..c2337253 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -14,7 +14,7 @@ set -euo pipefail # shellcheck source=../env.sh . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" -LINUX="$ROOT/packaging/linux" +LINUX="$ROOT/assets/platforms/linux" SKIP_BUILD=0 while [ $# -gt 0 ]; do diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 27cf0e0d..c07be030 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -68,11 +68,11 @@ install -Dm755 "$BIN/solidcraft-cli" "$STAGE/usr/bin/solidcraft-cli" # Compatibility shim (docs/compatibility.md): the old CLI name forwards to solidcraft-cli. install -Dm755 "$BIN/solvecraft-cli" "$STAGE/usr/bin/solvecraft-cli" strip "$STAGE/usr/bin/solidcraft" "$STAGE/usr/bin/solidcraft-cli" "$STAGE/usr/bin/solvecraft-cli" 2>/dev/null || true -install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop" -install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml" +install -Dm644 "$ROOT/assets/platforms/linux/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop" +install -Dm644 "$ROOT/assets/platforms/linux/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml" mkdir -p "$STAGE/usr/share/metainfo" sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$SOLIDCRAFT_BUILD_DATE/g" \ - "$HERE/$APP_ID.metainfo.xml.in" >"$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml" + "$ROOT/assets/platforms/linux/$APP_ID.metainfo.xml.in" >"$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml" make_icons "$STAGE/usr/bin/solidcraft-cli" "$WORK/icons" mkdir -p "$STAGE/usr/share/icons" cp -R "$WORK/icons/hicolor" "$STAGE/usr/share/icons/" @@ -117,7 +117,7 @@ if has appimage; then # $XDG_DATA_HOME before launching, so Wayland compositors can resolve the window's app_id to # our icon. install -Dm755 "$HERE/AppRun" "$APPDIR/AppRun" - cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop" + cp "$ROOT/assets/platforms/linux/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop" cp "$WORK/icons/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png" ln -s "$APP_ID.png" "$APPDIR/.DirIcon" diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index c612b4e0..b76d183a 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -12,7 +12,6 @@ set -euo pipefail # shellcheck source=../env.sh . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" -HERE="$ROOT/packaging/macos" # The same reverse-DNS id as the Linux APP_ID. MACOS_BUNDLE_ID=ai.storyteller.solidcraft @@ -63,11 +62,11 @@ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" cp "$WORK/bin/solidcraft" "$APP/Contents/MacOS/SolidCraft" # Apple-compiled layered icon and older-macOS fallback (regeneration: assets/README.md). # Keep compiled resources so packaging also works on runners with older Xcode versions. -cp "$ROOT/assets/Assets.car" "$APP/Contents/Resources/Assets.car" -cp "$ROOT/assets/icon.icns" "$APP/Contents/Resources/SolidCraft.icns" +cp "$ROOT/assets/icons/app/macos/exports/Assets.car" "$APP/Contents/Resources/Assets.car" +cp "$ROOT/assets/icons/app/macos/exports/icon.icns" "$APP/Contents/Resources/SolidCraft.icns" mkdir -p "$APP/Contents/Resources/Licenses" copy_docs "$APP/Contents/Resources/Licenses" -sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" "$HERE/Info.plist.in" >"$APP/Contents/Info.plist" +sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" "$ROOT/assets/platforms/macos/Info.plist.in" >"$APP/Contents/Info.plist" plutil -lint "$APP/Contents/Info.plist" printf 'APPL????' >"$APP/Contents/PkgInfo" # Ad-hoc signature over the whole bundle (Apple silicon refuses unsigned code). @@ -80,7 +79,7 @@ STAGE="$WORK/dmg" mkdir -p "$STAGE" ditto "$APP" "$STAGE/SolidCraft.app" ln -s /Applications "$STAGE/Applications" -cp "$HERE/README.txt" "$STAGE/README.txt" +cp "$ROOT/assets/platforms/macos/README.txt" "$STAGE/README.txt" rm -f "$DMG" "$WORK/raw.dmg" # makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`, # which is flaky on CI runners ("Resource busy"). @@ -100,9 +99,9 @@ for key in CFBundleName CFBundleDisplayName CFBundleExecutable CFBundleIconFile done test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$MNT/SolidCraft.app/Contents/Info.plist")" = "$MACOS_BUNDLE_ID" test -s "$MNT/SolidCraft.app/Contents/Resources/SolidCraft.icns" -cmp "$ROOT/assets/icon.icns" "$MNT/SolidCraft.app/Contents/Resources/SolidCraft.icns" -cmp "$ROOT/assets/Assets.car" "$MNT/SolidCraft.app/Contents/Resources/Assets.car" -cmp "$HERE/README.txt" "$MNT/README.txt" +cmp "$ROOT/assets/icons/app/macos/exports/icon.icns" "$MNT/SolidCraft.app/Contents/Resources/SolidCraft.icns" +cmp "$ROOT/assets/icons/app/macos/exports/Assets.car" "$MNT/SolidCraft.app/Contents/Resources/Assets.car" +cmp "$ROOT/assets/platforms/macos/README.txt" "$MNT/README.txt" case "$ARCH" in universal) WANT=(arm64 x86_64) ;; aarch64) WANT=(arm64) ;; *) WANT=(x86_64) ;; esac for arch in "${WANT[@]}"; do lipo "$MNT/SolidCraft.app/Contents/MacOS/SolidCraft" -verify_arch "$arch" diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 1ef9972c..f2b99cb5 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -88,20 +88,15 @@ New-Item -ItemType Directory -Force -Path $Stage | Out-Null Copy-Item (Join-Path $Bin 'solidcraft.exe'), (Join-Path $Bin 'solidcraft-cli.exe'), (Join-Path $Bin 'solvecraft-cli.exe') $Stage foreach ($f in 'LICENSE-MIT', 'LICENSE-APACHE', 'NOTICE', 'THIRD-PARTY-LICENSES.txt') { Copy-Item (Join-Path $Root $f) $Stage } -$HostArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() -$CanRun = ($Arch -ne 'arm64' -or $HostArch -eq 'arm64') -$Icon = if ($env:SOLIDCRAFT_ICONS) { Join-Path $env:SOLIDCRAFT_ICONS 'solidcraft.ico' } else { Join-Path $Stage 'solidcraft.ico' } -if (-not (Test-Path $Icon)) { - if (-not $CanRun) { throw "no icon: set SOLIDCRAFT_ICONS to a folder with solidcraft.ico (an $Arch CLI can't run on this $HostArch machine)" } - Invoke-Native 'draw the icon' { & (Join-Path $Stage 'solidcraft-cli.exe') icon --out $Icon } -} +$Icon = if ($env:SOLIDCRAFT_ICONS) { Join-Path $env:SOLIDCRAFT_ICONS 'solidcraft.ico' } else { Join-Path $Root 'assets/icons/app/windows/exports/solidcraft.ico' } +if (-not (Test-Path $Icon)) { throw "Missing package icon: $Icon" } # ---- MSI --------------------------------------------------------------------------------------- $Msi = Join-Path $Dist "solidcraft-$Version-windows-$Arch.msi" Invoke-Native 'wix build' { - wix build (Join-Path $PSScriptRoot 'solidcraft.wxs') -arch $Arch ` + wix build (Join-Path $Root 'assets/platforms/windows/solidcraft.wxs') -arch $Arch ` -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext ` - -culture en-US -loc (Join-Path $PSScriptRoot 'solidcraft.en-us.wxl') ` + -culture en-US -loc (Join-Path $Root 'assets/platforms/windows/solidcraft.en-us.wxl') ` -d "Version=$MsiVersion" -d "DisplayVersion=$Version" -d "BinDir=$Stage" -d "IconPath=$Icon" ` -o $Msi } diff --git a/xtask/src/app_icons.rs b/xtask/src/app_icons.rs new file mode 100644 index 00000000..3183419a --- /dev/null +++ b/xtask/src/app_icons.rs @@ -0,0 +1,40 @@ +//! Regenerate all exports of the shared app mark together. Packaging copies these files. +use std::path::Path; + +#[path = "../../assets/icons/app/shared/source/app_icon.rs"] +mod app_icon; +#[path = "../../assets/icons/app/shared/source/windows_ico.rs"] +mod windows_ico; + +pub fn run(root: &Path) -> Result<(), String> { + let stage = root.join("target/resources/app-icons"); + std::fs::create_dir_all(&stage).map_err(|e| e.to_string())?; + let mut exports = Vec::new(); + for size in [16, 24, 32, 48, 64, 128, 256, 512] { + let png = stage.join(format!("{size}.png")); + draw(&png, size)?; + exports.push((png, format!("linux/exports/hicolor/{size}x{size}/apps/ai.storyteller.solidcraft.png"))); + } + let installer = stage.join("solidcraft.ico"); + draw(&installer, 256)?; + exports.push((installer, "windows/exports/solidcraft.ico".into())); + let executable = stage.join("solidcraft-executable.ico"); + std::fs::write(&executable, windows_ico::encode().map_err(|e| e.to_string())?).map_err(|e| e.to_string())?; + exports.push((executable, "windows/exports/solidcraft-executable.ico".into())); + + // Finish generation before replacing any canonical exports. + for (source, relative) in exports { + let target = root.join("assets/icons/app").join(relative); + if let Some(dir) = target.parent() { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + } + std::fs::copy(source, target).map_err(|e| e.to_string())?; + } + Ok(()) +} + +fn draw(path: &Path, size: usize) -> Result<(), String> { + let mut c = crate::cargo(); + c.args(["run", "-q", "--profile", "ci", "-p", "solidcraft-cli", "--", "icon", "--size", &size.to_string(), "--out"]).arg(path); + crate::run(c, "solidcraft-cli icon") +} diff --git a/xtask/src/assets.rs b/xtask/src/assets.rs index 700af3bd..e2467f9d 100644 --- a/xtask/src/assets.rs +++ b/xtask/src/assets.rs @@ -1,40 +1,107 @@ -//! `cargo xtask assets`: every non-code asset in the repository must have a row in -//! ATTRIBUTION.md (author, source, licence). Icons are drawn in code and need no file. - +//! Asset provenance gate. ATTRIBUTION.md is the single inventory of collected files. +use std::collections::BTreeSet; use std::path::Path; -const ASSET_EXT: &[&str] = - &["png", "jpg", "jpeg", "svg", "ico", "icns", "ttf", "otf", "woff", "woff2", "gif", "bmp", "webp", "wav", "mp3", "stl", "step", "stp", "obj"]; -const SKIP_DIRS: &[&str] = &["target", ".git", "plan", "dist"]; +const ASSET_EXT: &[&str] = &[ + "png", + "jpg", + "jpeg", + "svg", + "ico", + "icns", + "car", + "vectorcraft", + "ai", + "psd", + "afdesign", + "fig", + "sketch", + "ttf", + "otf", + "woff", + "woff2", + "gif", + "bmp", + "webp", + "wav", + "mp3", + "mp4", + "stl", + "step", + "stp", + "obj", +]; -fn walk(dir: &Path, root: &Path, out: &mut Vec) { - let Ok(rd) = std::fs::read_dir(dir) else { return }; - for e in rd.flatten() { +fn walk(dir: &Path, root: &Path, out: &mut Vec) -> Result<(), String> { + for e in std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))? { + let e = e.map_err(|e| e.to_string())?; + let name = e.file_name().to_string_lossy().into_owned(); + if name.starts_with('.') || name.starts_with("target") || matches!(name.as_str(), "plan" | "dist" | "node_modules") { + continue; + } let p = e.path(); - let name = e.file_name().to_string_lossy().to_string(); - if p.is_dir() { - if !SKIP_DIRS.contains(&name.as_str()) && !name.starts_with('.') { - walk(&p, root, out); + let kind = e.file_type().map_err(|e| e.to_string())?; + if kind.is_symlink() { + if p.starts_with(root.join("assets")) { + return Err(format!("resource symlink: {}", p.display())); } - } else if let Some(ext) = p.extension().map(|x| x.to_string_lossy().to_ascii_lowercase()) - && ASSET_EXT.contains(&ext.as_str()) - && let Ok(rel) = p.strip_prefix(root) - { - out.push(rel.to_string_lossy().replace('\\', "/")); + } else if kind.is_dir() { + walk(&p, root, out)?; + } else if kind.is_file() { + out.push(p.strip_prefix(root).map_err(|e| e.to_string())?.to_string_lossy().replace('\\', "/")); } } + Ok(()) +} + +fn needs_attribution(path: &str) -> bool { + // Include code masters, native work, catalogs, metadata and licence texts in the collection. + if path.starts_with("assets/") { + return !path.ends_with(".md"); + } + Path::new(path).extension().is_some_and(|ext| ASSET_EXT.contains(&ext.to_string_lossy().to_ascii_lowercase().as_str())) +} + +fn missing<'a>(files: &'a [String], attribution: &str) -> Vec<&'a str> { + let rows: BTreeSet<_> = + attribution.lines().filter(|line| line.starts_with('|')).filter_map(|line| line.split('|').nth(1)).map(str::trim).collect(); + files.iter().map(String::as_str).filter(|path| needs_attribution(path) && !rows.contains(path)).collect() } pub fn run(root: &Path) -> Result<(), String> { let attribution = std::fs::read_to_string(root.join("ATTRIBUTION.md")).map_err(|e| format!("ATTRIBUTION.md: {e}"))?; let mut files = Vec::new(); - walk(root, root, &mut files); + walk(root, root, &mut files)?; files.sort(); - let missing: Vec<&String> = files.iter().filter(|f| !attribution.contains(f.as_str())).collect(); - if missing.is_empty() { - println!("assets: {} asset file(s), all attributed", files.len()); - Ok(()) - } else { - Err(format!("assets missing from ATTRIBUTION.md:\n{}", missing.iter().map(|m| format!(" {m}")).collect::>().join("\n"))) + let missing = missing(&files, &attribution); + if !missing.is_empty() { + return Err(format!("assets missing attribution rows:\n{}", missing.iter().map(|path| format!(" {path}")).collect::>().join("\n"))); + } + println!("assets: {} files, all attributed", files.iter().filter(|path| needs_attribution(path)).count()); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn collected_code_native_work_and_metadata_require_rows() { + for path in + ["assets/icons/source/icon.rs", "assets/work/project.ai", "assets/fonts/OFL.txt", "assets/locales/en.json", "assets/macos/Assets.car"] + { + assert!(needs_attribution(path), "{path}"); + } + assert!(needs_attribution("docs/screenshot.png")); + assert!(needs_attribution("design/project.vectorcraft")); + assert!(!needs_attribution("assets/README.md")); + assert!(!needs_attribution("crates/ui/src/widget.rs")); + } + + #[test] + fn incidental_mentions_do_not_replace_attribution_rows() { + let files = ["assets/source.svg".to_string(), "assets/README.md".to_string()]; + assert_eq!(missing(&files, "Mention assets/source.svg"), ["assets/source.svg"]); + assert!(missing(&files, "| assets/source.svg | author | original | MIT |\n").is_empty()); } } diff --git a/xtask/src/book.rs b/xtask/src/book.rs index 3a243e87..b20f0115 100644 --- a/xtask/src/book.rs +++ b/xtask/src/book.rs @@ -19,6 +19,7 @@ const SNAPSHOT_SIZE: (&str, &str) = ("960", "600"); pub fn run(root: &Path, build: bool) -> Result<(), String> { let book = root.join("docs/book"); + std::fs::copy(root.join("assets/branding/web/source/og.png"), book.join("src/og.png")).map_err(|e| format!("book preview card: {e}"))?; let mut c = cargo(); c.args(["build", "-q", "--release", "-p", "solidcraft-cli"]); crate::run(c, "cargo build -p solidcraft-cli")?; diff --git a/xtask/src/i18n.rs b/xtask/src/i18n.rs index 888b68b5..ea89f4c3 100644 --- a/xtask/src/i18n.rs +++ b/xtask/src/i18n.rs @@ -1,6 +1,6 @@ //! `cargo xtask i18n`: the message catalogs' coverage report, and a way to repair it. //! -//! The catalogs under `crates/ui-egui/src/locales` are plain JSON maps from a stable message id +//! The catalogs under `assets/locales` are plain JSON maps from a stable message id //! to one translation. The English catalog is also the lookup table for `Locale::label()`, which //! translates source strings coming from the engine (command labels, toolbar tabs and panels) by //! matching their English text. A label the English catalog does not carry therefore stays English @@ -20,7 +20,7 @@ use std::path::Path; use serde_json::{Map, Value}; /// Where the catalogs live, and the file the English one must lead. -const DIR: &str = "crates/ui-egui/src/locales"; +const DIR: &str = "assets/locales"; /// Catalogs delivered complete: a missing message here fails `--check`. English is the source /// catalog and is checked implicitly; keep this in step with `COMPLETE` in `ui-egui::i18n`. diff --git a/xtask/src/licences.rs b/xtask/src/licences.rs index 691ef50e..16ba4a9b 100644 --- a/xtask/src/licences.rs +++ b/xtask/src/licences.rs @@ -162,15 +162,15 @@ fn render(root: &Path, crates: &[Crate]) -> String { let font_rule = "=".repeat(80); out += &format!( "\n{font_rule}\nBundled fonts (SIL Open Font License, Version 1.1)\n{font_rule}\n\n\ - - LiberationSans-Regular.ttf (crates/sketch/fonts/LiberationSans-Regular.ttf)\n \ + - LiberationSans-Regular.ttf (assets/fonts/liberation-sans/LiberationSans-Regular.ttf)\n \ Copyright 2012 Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google)\n \ Source: https://github.com/liberationfonts\n\n\ - - BIZUDPGothic-Regular.ttf (apps/solidcraft-web/fonts/BIZUDPGothic-Regular.ttf)\n \ + - BIZUDPGothic-Regular.ttf (assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf)\n \ Copyright 2022 The BIZ UDGothic Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho)\n \ Source: https://github.com/googlefonts/morisawa-biz-ud-gothic\n\n" ); - let ofl_path = root.join("apps/solidcraft-web/fonts/OFL.txt"); - let fallback_path = root.join("crates/sketch/fonts/OFL.txt"); + let ofl_path = root.join("assets/fonts/biz-udp-gothic/OFL.txt"); + let fallback_path = root.join("assets/fonts/liberation-sans/OFL.txt"); let ofl_text = std::fs::read_to_string(&ofl_path).or_else(|_| std::fs::read_to_string(&fallback_path)).unwrap_or_default(); let ofl_clean = ofl_text.replace("\r\n", "\n").trim().to_string(); if !ofl_clean.is_empty() { diff --git a/xtask/src/main.rs b/xtask/src/main.rs index ad4a2527..33f6122a 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -3,6 +3,7 @@ //! Pure Rust (std, serde_json and sha2). External tools (`cargo`) are invoked through //! `std::process::Command`. +mod app_icons; mod assets; mod book; mod features; @@ -22,9 +23,10 @@ usage: cargo xtask commands: layers enforce the crate dependency layering (plan/architecture.md §3) assets check that every non-code asset file is attributed in ATTRIBUTION.md + app-icons regenerate shared app-icon exports for Linux, FreeBSD and Windows features list SolidCraft's commands by area in docs/features.md (from the registry) i18n [--check | --add-missing] - report how complete the message catalogs in crates/ui-egui/src/locales are: + report how complete the message catalogs in assets/locales are: which messages a locale has not translated and which command labels the English catalog cannot translate (so they stay English everywhere). --check fails when there is a gap, for CI; --add-missing writes the uncovered command labels into @@ -64,6 +66,7 @@ fn main() -> ExitCode { let result = match args.first().map(String::as_str) { Some("layers") => cmd_layers(), Some("assets") => assets::run(&root()), + Some("app-icons") => app_icons::run(&root()), Some("features") => features::run(&root()), Some("i18n") => i18n::run(&root(), &rest), Some("oracle") => cmd_oracle(rest.iter().position(|a| *a == "--cli").and_then(|i| rest.get(i + 1)).copied()),