diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index ff0d3c1..83eb313 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -115,6 +115,26 @@ is committed and listed here with its exports. | assets/icons/solve.frequency.svg, assets/icons/solve.frequency.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/solve.frequency.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/solve.fsi.svg, assets/icons/solve.fsi.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/solve.fsi.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/solve.static.svg, assets/icons/solve.static.vectorcraft | ModelCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/solve.static.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | +| assets/app-icon/icon.vectorcraft, assets/app-icon/icon.svg, assets/app-icon/icon.png | ModelCraft contributors | Original application icon (an L bracket with a bolt hole under a structured hexahedral mesh, on a rounded-square backdrop), drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs); editable source, SVG and 1024 × 1024 PNG export | MIT OR Apache-2.0 | +| assets/app-icon/icon-small.vectorcraft, assets/app-icon/icon-small.svg, assets/app-icon/icon-small.png | ModelCraft contributors | Original simplified application icon for 16–32 px (no mesh or floor grid), drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 | +| assets/app-icon/layers/01-floor.vectorcraft, assets/app-icon/ModelCraft.icon/Assets/01-floor.svg | ModelCraft contributors | Original isometric floor grid with a radial fade (Default appearance), drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 | +| assets/app-icon/layers/01-floor-dark.vectorcraft, assets/app-icon/ModelCraft.icon/Assets/01-floor-dark.svg | ModelCraft contributors | Original isometric floor grid with a radial fade (Dark appearance), drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 | +| assets/app-icon/layers/02-part.vectorcraft, assets/app-icon/ModelCraft.icon/Assets/02-part.svg | ModelCraft contributors | Original bracket faces with transparent gaps at the shared edges, drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 | +| assets/app-icon/layers/03-mesh.vectorcraft, assets/app-icon/ModelCraft.icon/Assets/03-mesh.svg | ModelCraft contributors | Original hexahedral mesh lines (O-grid around the hole) clipped to the bracket's faces, drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 | +| assets/app-icon/ModelCraft.icon/icon.json | ModelCraft contributors | Icon Composer composition of the layers above (Default and Dark floor grid), written by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 | +| assets/app-icon/Assets.car | ModelCraft contributors | Apple actool compilation of assets/app-icon/ModelCraft.icon; contains original artwork only | MIT OR Apache-2.0 | +| assets/app-icon/icon.icns | ModelCraft contributors | iconutil compilation of assets/app-icon/icon.iconset | MIT OR Apache-2.0 | +| assets/app-icon/icon.ico | ModelCraft contributors | ImageMagick compilation (16–256 px) of sips exports of assets/app-icon/icon-small.png (≤ 32 px) and assets/app-icon/icon.png | MIT OR Apache-2.0 | +| assets/app-icon/icon.iconset/icon_16x16.png | ModelCraft contributors | sips export (16 px) of assets/app-icon/icon-small.png | MIT OR Apache-2.0 | +| assets/app-icon/icon.iconset/icon_16x16@2x.png | ModelCraft contributors | sips export (32 px) of assets/app-icon/icon-small.png | MIT OR Apache-2.0 | +| assets/app-icon/icon.iconset/icon_32x32.png | ModelCraft contributors | sips export (32 px) of assets/app-icon/icon-small.png | MIT OR Apache-2.0 | +| assets/app-icon/icon.iconset/icon_32x32@2x.png | ModelCraft contributors | sips export (64 px) of assets/app-icon/icon.png | MIT OR Apache-2.0 | +| assets/app-icon/icon.iconset/icon_128x128.png | ModelCraft contributors | sips export (128 px) of assets/app-icon/icon.png | MIT OR Apache-2.0 | +| assets/app-icon/icon.iconset/icon_128x128@2x.png | ModelCraft contributors | sips export (256 px) of assets/app-icon/icon.png | MIT OR Apache-2.0 | +| assets/app-icon/icon.iconset/icon_256x256.png | ModelCraft contributors | sips export (256 px) of assets/app-icon/icon.png | MIT OR Apache-2.0 | +| assets/app-icon/icon.iconset/icon_256x256@2x.png | ModelCraft contributors | sips export (512 px) of assets/app-icon/icon.png | MIT OR Apache-2.0 | +| assets/app-icon/icon.iconset/icon_512x512.png | ModelCraft contributors | sips export (512 px) of assets/app-icon/icon.png | MIT OR Apache-2.0 | +| assets/app-icon/icon.iconset/icon_512x512@2x.png | ModelCraft contributors | sips export (1024 px) of assets/app-icon/icon.png | MIT OR Apache-2.0 | ## Adapted code diff --git a/apps/modelcraft/src/main.rs b/apps/modelcraft/src/main.rs index f16f272..f0de7aa 100644 --- a/apps/modelcraft/src/main.rs +++ b/apps/modelcraft/src/main.rs @@ -20,6 +20,17 @@ const USAGE: &str = "usage: modelcraft [--sample] [--control PORT]"; const DEPTH_BITS: u8 = 24; const MSAA: u16 = 4; +/// The window and Dock icon: the 256 px export of the application icon (assets/app-icon). +const ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/icon.iconset/icon_128x128@2x.png"); + +/// The application icon for the window; eframe's default icon if the PNG cannot be decoded. +fn app_icon() -> egui::IconData { + eframe::icon_data::from_png_bytes(ICON_PNG).unwrap_or_else(|e| { + log::warn!("app icon: {e}"); + egui::IconData::default() + }) +} + /// How often unsaved changes are autosaved for recovery (seconds). const AUTOSAVE_SECONDS: f64 = 30.0; @@ -108,7 +119,11 @@ fn main() -> eframe::Result { } }; let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default().with_title("ModelCraft").with_inner_size([1400.0, 900.0]).with_min_inner_size([800.0, 500.0]), + viewport: egui::ViewportBuilder::default() + .with_title("ModelCraft") + .with_icon(app_icon()) + .with_inner_size([1400.0, 900.0]) + .with_min_inner_size([800.0, 500.0]), depth_buffer: DEPTH_BITS, multisampling: MSAA, ..Default::default() @@ -145,6 +160,13 @@ fn main() -> eframe::Result { #[cfg(test)] mod tests { + #[test] + fn the_app_icon_decodes() { + let icon = super::app_icon(); + assert_eq!((icon.width, icon.height), (256, 256)); + assert_eq!(icon.rgba.len(), 256 * 256 * 4); + } + use super::*; fn parse(a: &[&str], env: Option<&str>) -> Result, String> { diff --git a/assets/app-icon/Assets.car b/assets/app-icon/Assets.car new file mode 100644 index 0000000..9977d42 Binary files /dev/null and b/assets/app-icon/Assets.car differ diff --git a/assets/app-icon/ModelCraft.icon/Assets/01-floor-dark.svg b/assets/app-icon/ModelCraft.icon/Assets/01-floor-dark.svg new file mode 100644 index 0000000..492536c --- /dev/null +++ b/assets/app-icon/ModelCraft.icon/Assets/01-floor-dark.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + ModelCraft icon floor grid (Dark) + + + + diff --git a/assets/app-icon/ModelCraft.icon/Assets/01-floor.svg b/assets/app-icon/ModelCraft.icon/Assets/01-floor.svg new file mode 100644 index 0000000..4228703 --- /dev/null +++ b/assets/app-icon/ModelCraft.icon/Assets/01-floor.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + ModelCraft icon floor grid + + + + diff --git a/assets/app-icon/ModelCraft.icon/Assets/02-part.svg b/assets/app-icon/ModelCraft.icon/Assets/02-part.svg new file mode 100644 index 0000000..b9f0514 --- /dev/null +++ b/assets/app-icon/ModelCraft.icon/Assets/02-part.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ModelCraft icon part + + + + + + + + + diff --git a/assets/app-icon/ModelCraft.icon/Assets/03-mesh.svg b/assets/app-icon/ModelCraft.icon/Assets/03-mesh.svg new file mode 100644 index 0000000..ba3dd49 --- /dev/null +++ b/assets/app-icon/ModelCraft.icon/Assets/03-mesh.svg @@ -0,0 +1,12 @@ + + + + + + + + ModelCraft icon mesh + + + + diff --git a/assets/app-icon/ModelCraft.icon/icon.json b/assets/app-icon/ModelCraft.icon/icon.json new file mode 100644 index 0000000..7aa6709 --- /dev/null +++ b/assets/app-icon/ModelCraft.icon/icon.json @@ -0,0 +1,116 @@ +{ + "fill": "system-light", + "groups": [ + { + "layers": [ + { + "glass-specializations": [ + { + "appearance": "tinted", + "value": true + } + ], + "image-name": "03-mesh.svg", + "name": "03-mesh", + "position": { + "scale": 0.9, + "translation-in-points": [ + 0, + 0 + ] + } + } + ], + "name": "03 Mesh", + "shadow": { + "kind": "none", + "opacity": 0 + }, + "specular": false, + "translucency": { + "enabled": false, + "value": 0.3 + } + }, + { + "layers": [ + { + "glass-specializations": [ + { + "appearance": "tinted", + "value": true + } + ], + "image-name": "02-part.svg", + "name": "02-part", + "position": { + "scale": 0.9, + "translation-in-points": [ + 0, + 0 + ] + } + } + ], + "name": "02 Part", + "shadow": { + "kind": "none", + "opacity": 0 + }, + "specular": true, + "translucency": { + "enabled": false, + "value": 0.3 + } + }, + { + "layers": [ + { + "fill": "none", + "glass-specializations": [ + { + "value": false + }, + { + "appearance": "tinted", + "value": true + } + ], + "image-name-specializations": [ + { + "value": "01-floor.svg" + }, + { + "appearance": "dark", + "value": "01-floor-dark.svg" + } + ], + "name": "01-floor", + "position": { + "scale": 0.9, + "translation-in-points": [ + 0, + 0 + ] + } + } + ], + "name": "01 Floor", + "shadow": { + "kind": "none", + "opacity": 0 + }, + "specular": false, + "translucency": { + "enabled": false, + "value": 0.3 + } + } + ], + "supported-platforms": { + "circles": [ + "watchOS" + ], + "squares": "shared" + } +} diff --git a/assets/app-icon/README.md b/assets/app-icon/README.md new file mode 100644 index 0000000..0475c1a --- /dev/null +++ b/assets/app-icon/README.md @@ -0,0 +1,27 @@ +# ModelCraft application icon + +ModelCraft's mark is an L-shaped bracket with a bolt hole, drawn in isometric view and covered +by a structured hexahedral mesh with an O-grid around the hole. Its faces are separated by +transparent gaps rather than painted edge lines, so the backdrop shows through them in every +appearance. It is original artwork, attributed in `ATTRIBUTION.md`, and shares the Craft +family's layout (rounded-square backdrop, isometric floor grid, lit top faces) in its own teal. + +Everything here is generated by `cargo xtask app-icon` (`xtask/src/app_icon.rs`), which computes +the geometry and draws it with VectorCraft's command-line tool (`VECTORCRAFT_CLI`, else +`vectorcraft-cli` on the PATH). Change the recipe there and rerun it; do not edit the exports by +hand. It runs on macOS (sips, iconutil, `xcrun actool`) with ImageMagick (`magick`) installed. + +| File | What it is | +|---|---| +| `icon.vectorcraft`, `icon.svg`, `icon.png` | The full icon on its backdrop, 1024 × 1024 | +| `icon-small.vectorcraft`, `icon-small.svg`, `icon-small.png` | Small-size artwork used at 16–32 px: no mesh or floor grid, faces edge to edge | +| `icon.iconset/`, `icon.icns` | macOS iconset (sips) and its iconutil compilation | +| `icon.ico` | Windows icon, 16–256 px | +| `ModelCraft.icon/` | Icon Composer document: floor grid (Default and Dark), part and mesh layers, each scaled to 90% | +| `layers/*.vectorcraft` | Editable sources of the Icon Composer layers | +| `Assets.car` | `actool` compilation of `ModelCraft.icon` for macOS 26 and later (needs Xcode 26 or later; the task skips it with a note otherwise) | + +The desktop app embeds `icon.iconset/icon_128x128@2x.png` as its window and Dock icon +(`apps/modelcraft/src/main.rs`). There is no macOS bundle yet; packaging will copy +`Assets.car` and `icon.icns` into `Contents/Resources` and name `ModelCraft` in +`CFBundleIconName` / `CFBundleIconFile`. diff --git a/assets/app-icon/icon-small.png b/assets/app-icon/icon-small.png new file mode 100644 index 0000000..1465277 Binary files /dev/null and b/assets/app-icon/icon-small.png differ diff --git a/assets/app-icon/icon-small.svg b/assets/app-icon/icon-small.svg new file mode 100644 index 0000000..a54e434 --- /dev/null +++ b/assets/app-icon/icon-small.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ModelCraft application icon, small sizes + + + + + + + + + + + + + + diff --git a/assets/app-icon/icon-small.vectorcraft b/assets/app-icon/icon-small.vectorcraft new file mode 100644 index 0000000..05771a8 Binary files /dev/null and b/assets/app-icon/icon-small.vectorcraft differ diff --git a/assets/app-icon/icon.icns b/assets/app-icon/icon.icns new file mode 100644 index 0000000..512a914 Binary files /dev/null and b/assets/app-icon/icon.icns differ diff --git a/assets/app-icon/icon.ico b/assets/app-icon/icon.ico new file mode 100644 index 0000000..d143d51 Binary files /dev/null and b/assets/app-icon/icon.ico differ diff --git a/assets/app-icon/icon.iconset/icon_128x128.png b/assets/app-icon/icon.iconset/icon_128x128.png new file mode 100644 index 0000000..8f82f11 Binary files /dev/null and b/assets/app-icon/icon.iconset/icon_128x128.png differ diff --git a/assets/app-icon/icon.iconset/icon_128x128@2x.png b/assets/app-icon/icon.iconset/icon_128x128@2x.png new file mode 100644 index 0000000..a4cfed6 Binary files /dev/null and b/assets/app-icon/icon.iconset/icon_128x128@2x.png differ diff --git a/assets/app-icon/icon.iconset/icon_16x16.png b/assets/app-icon/icon.iconset/icon_16x16.png new file mode 100644 index 0000000..bde5131 Binary files /dev/null and b/assets/app-icon/icon.iconset/icon_16x16.png differ diff --git a/assets/app-icon/icon.iconset/icon_16x16@2x.png b/assets/app-icon/icon.iconset/icon_16x16@2x.png new file mode 100644 index 0000000..d85b5ec Binary files /dev/null and b/assets/app-icon/icon.iconset/icon_16x16@2x.png differ diff --git a/assets/app-icon/icon.iconset/icon_256x256.png b/assets/app-icon/icon.iconset/icon_256x256.png new file mode 100644 index 0000000..a4cfed6 Binary files /dev/null and b/assets/app-icon/icon.iconset/icon_256x256.png differ diff --git a/assets/app-icon/icon.iconset/icon_256x256@2x.png b/assets/app-icon/icon.iconset/icon_256x256@2x.png new file mode 100644 index 0000000..cf32c37 Binary files /dev/null and b/assets/app-icon/icon.iconset/icon_256x256@2x.png differ diff --git a/assets/app-icon/icon.iconset/icon_32x32.png b/assets/app-icon/icon.iconset/icon_32x32.png new file mode 100644 index 0000000..d85b5ec Binary files /dev/null and b/assets/app-icon/icon.iconset/icon_32x32.png differ diff --git a/assets/app-icon/icon.iconset/icon_32x32@2x.png b/assets/app-icon/icon.iconset/icon_32x32@2x.png new file mode 100644 index 0000000..8517996 Binary files /dev/null and b/assets/app-icon/icon.iconset/icon_32x32@2x.png differ diff --git a/assets/app-icon/icon.iconset/icon_512x512.png b/assets/app-icon/icon.iconset/icon_512x512.png new file mode 100644 index 0000000..cf32c37 Binary files /dev/null and b/assets/app-icon/icon.iconset/icon_512x512.png differ diff --git a/assets/app-icon/icon.iconset/icon_512x512@2x.png b/assets/app-icon/icon.iconset/icon_512x512@2x.png new file mode 100644 index 0000000..74f0f45 Binary files /dev/null and b/assets/app-icon/icon.iconset/icon_512x512@2x.png differ diff --git a/assets/app-icon/icon.png b/assets/app-icon/icon.png new file mode 100644 index 0000000..74f0f45 Binary files /dev/null and b/assets/app-icon/icon.png differ diff --git a/assets/app-icon/icon.svg b/assets/app-icon/icon.svg new file mode 100644 index 0000000..d568459 --- /dev/null +++ b/assets/app-icon/icon.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ModelCraft application icon + + + + + + + + + + + + + + + + + + + + diff --git a/assets/app-icon/icon.vectorcraft b/assets/app-icon/icon.vectorcraft new file mode 100644 index 0000000..8b5da44 Binary files /dev/null and b/assets/app-icon/icon.vectorcraft differ diff --git a/assets/app-icon/layers/01-floor-dark.vectorcraft b/assets/app-icon/layers/01-floor-dark.vectorcraft new file mode 100644 index 0000000..e089aa1 Binary files /dev/null and b/assets/app-icon/layers/01-floor-dark.vectorcraft differ diff --git a/assets/app-icon/layers/01-floor.vectorcraft b/assets/app-icon/layers/01-floor.vectorcraft new file mode 100644 index 0000000..5e78798 Binary files /dev/null and b/assets/app-icon/layers/01-floor.vectorcraft differ diff --git a/assets/app-icon/layers/02-part.vectorcraft b/assets/app-icon/layers/02-part.vectorcraft new file mode 100644 index 0000000..7929913 Binary files /dev/null and b/assets/app-icon/layers/02-part.vectorcraft differ diff --git a/assets/app-icon/layers/03-mesh.vectorcraft b/assets/app-icon/layers/03-mesh.vectorcraft new file mode 100644 index 0000000..698cf9b Binary files /dev/null and b/assets/app-icon/layers/03-mesh.vectorcraft differ diff --git a/xtask/src/app_icon.rs b/xtask/src/app_icon.rs new file mode 100644 index 0000000..be3b07f --- /dev/null +++ b/xtask/src/app_icon.rs @@ -0,0 +1,753 @@ +//! `cargo xtask app-icon`: draw ModelCraft's application icon family with VectorCraft. +//! +//! The mark is an L-shaped bracket with a bolt hole, drawn in isometric view and covered by a +//! structured hexahedral mesh (an O-grid around the hole). The faces are separated by +//! transparent gaps instead of painted edge lines, so the backdrop shows through them in every +//! appearance. All geometry is computed here, turned into VectorCraft commands and run headlessly +//! with `vectorcraft-cli run` (`VECTORCRAFT_CLI`, else `vectorcraft-cli` on the PATH), which +//! saves each document's editable `.vectorcraft` source and exports its SVG (and PNG). +//! +//! Outputs, all under `assets/app-icon/` (see its README.md): +//! - `icon.vectorcraft`, `icon.svg`, `icon.png`: the full icon on its backdrop, 1024 × 1024. +//! - `icon-small.vectorcraft`, `icon-small.svg`, `icon-small.png`: simplified artwork (no mesh, +//! no floor grid, faces meeting edge to edge so the tones alone separate them) for 16–32 px. +//! - `icon.iconset/` (sips), `icon.icns` (iconutil) and `icon.ico` (ImageMagick). +//! - `ModelCraft.icon/`: an Icon Composer document with separate floor-grid (Default and Dark), +//! part and mesh layers; their editable sources are `layers/*.vectorcraft`. +//! - `Assets.car`: Apple's actool compilation of `ModelCraft.icon`, when Xcode 26 or later is +//! installed (otherwise the step is skipped with a note and the checked-in catalog stays). +//! +//! macOS only (sips, iconutil); ImageMagick (`magick`) writes the Windows icon. + +use std::f64::consts::{FRAC_PI_2, TAU}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde_json::{Value, json}; + +type P2 = [f64; 2]; +type P3 = [f64; 3]; + +/// Canvas side in pixels; every document is one square artboard. +const CANVAS: f64 = 1024.0; +/// Pixels per model unit, and the screen point the model origin's bounding box centres on. +const SCALE: f64 = 371.0; +const CENTRE: P2 = [512.0, 512.0]; +const COS30: f64 = 0.866_025_403_784_438_6; + +/// The bracket, in model units (x to the right-front, y to the left-front, z up): a base plate +/// `L × D × T` with an upright wall `L × T × H` along its back edge. +const L: f64 = 1.0; +const D: f64 = 1.0; +const H: f64 = 1.0; +const T: f64 = 0.32; +/// The bolt hole through the base plate, and the half-side of the O-grid block around it (the +/// block spans the plate's depth exactly). +const HOLE: P2 = [0.5, 0.66]; +const HOLE_R: f64 = 0.19; +const BLOCK: f64 = 0.34; + +/// The macOS icon grid: the backdrop's square, inset 100 px on a 1024 canvas. +const BACKDROP: [f64; 2] = [100.0, 924.0]; + +/// Part palette: lit top faces, the left (+y) faces and the right (+x) faces, each a vertical +/// gradient, and the bolt hole's inner wall. +const TOP: [&str; 2] = ["#86e3d3", "#4cc4b1"]; +const LEFT: [&str; 2] = ["#20b39f", "#118c7c"]; +const RIGHT: [&str; 2] = ["#0f786d", "#0a544e"]; +const BORE: [&str; 2] = ["#0b665e", "#04302c"]; + +#[derive(Clone, Copy, PartialEq)] +enum Appearance { + Light, + Dark, +} + +/// How much of the mark is drawn: the full mesh and floor grid, or the small-size artwork. +#[derive(Clone, Copy, PartialEq)] +enum Detail { + Full, + Small, +} + +impl Detail { + /// Width of the transparent gap between neighbouring faces, in pixels on the 1024 canvas. + fn gap(self) -> f64 { + match self { + Detail::Full => 16.0, + Detail::Small => 0.0, + } + } +} + +struct Stroke { + paint: Value, + width: f64, + cap: &'static str, +} + +struct Shape { + name: String, + d: String, + fill: Value, + stroke: Option, + opacity: f64, + shadow: Option, +} + +impl Shape { + fn filled(name: &str, d: String, fill: Value) -> Shape { + Shape { name: name.into(), d, fill, stroke: None, opacity: 1.0, shadow: None } + } + + fn stroked(name: &str, d: String, stroke: Stroke, opacity: f64) -> Shape { + Shape { name: name.into(), d, fill: json!({"none": true}), stroke: Some(stroke), opacity, shadow: None } + } +} + +struct Layer { + name: &'static str, + shapes: Vec, + /// A path that clips the layer's contents (VectorCraft's layer clipping mask). + clip: Option, +} + +struct Doc { + title: &'static str, + layers: Vec, +} + +// ---------------------------------------------------------------- geometry + +fn project(p: P3) -> P2 { + [CENTRE[0] + SCALE * (p[0] - p[1]) * COS30, CENTRE[1] + SCALE * ((p[0] + p[1]) * 0.5 - p[2])] +} + +fn sub(a: P2, b: P2) -> P2 { + [a[0] - b[0], a[1] - b[1]] +} + +fn cross(a: P2, b: P2) -> f64 { + a[0] * b[1] - a[1] * b[0] +} + +fn signed_area(poly: &[P2]) -> f64 { + let n = poly.len(); + (0..n).map(|i| cross(poly[i], poly[(i + 1) % n])).sum::() * 0.5 +} + +/// Number for SVG path data: two decimals, trailing zeros dropped. +fn num(x: f64) -> String { + let s = format!("{x:.2}"); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".into() } else { s.into() } +} + +fn polyline(pts: &[P2], closed: bool) -> String { + let mut d = String::new(); + for (i, p) in pts.iter().enumerate() { + d.push_str(if i == 0 { "M" } else { " L" }); + d.push_str(&format!("{} {}", num(p[0]), num(p[1]))); + } + if closed { + d.push_str(" Z"); + } + d +} + +/// Move the polygon's flagged edges inward by `by` pixels (the others stay), recomputing each +/// corner as the meeting point of its two edge lines. +fn inset(poly: &[P2], flagged: &[bool], by: f64) -> Result, String> { + let n = poly.len(); + if n < 3 || flagged.len() != n { + return Err("a face needs at least three corners and one flag per edge".into()); + } + let inward = signed_area(poly).signum(); + // Each edge as (point on the moved line, unit direction). + let mut lines = Vec::with_capacity(n); + for i in 0..n { + let (a, b) = (poly[i], poly[(i + 1) % n]); + let v = sub(b, a); + let len = v[0].hypot(v[1]); + if len < 1e-9 { + return Err("a face has a zero-length edge".into()); + } + let dir = [v[0] / len, v[1] / len]; + let off = if flagged[i] { by } else { 0.0 }; + let normal = [-dir[1] * inward * off, dir[0] * inward * off]; + lines.push(([a[0] + normal[0], a[1] + normal[1]], dir)); + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let (p, r) = lines[(i + n - 1) % n]; + let (q, s) = lines[i]; + let denom = cross(r, s); + if denom.abs() < 1e-9 { + return Err("a face has two collinear edges in a row".into()); + } + let t = cross(sub(q, p), s) / denom; + out.push([p[0] + r[0] * t, p[1] + r[1] * t]); + } + Ok(out) +} + +/// A circle of radius `r` around `c` on the horizontal plane at height `z`, projected: four +/// cubic arcs (projection is affine, so the projected Béziers are exact). `ccw` picks the +/// screen winding (positive shoelace area when true). +fn circle_d(c: P2, r: f64, z: f64, ccw: bool) -> String { + let arcs = |sign: f64| -> Vec<[P2; 3]> { + let k = 0.552_284_749_830_793_4 * r; + let at = |t: f64| [c[0] + r * t.cos(), c[1] + sign * r * t.sin()]; + let tangent = |t: f64| [-t.sin(), sign * t.cos()]; + (0..4) + .map(|i| { + let (t0, t1) = (i as f64 * FRAC_PI_2, (i + 1) as f64 * FRAC_PI_2); + let (p0, p1, d0, d1) = (at(t0), at(t1), tangent(t0), tangent(t1)); + let c1 = [p0[0] + k * d0[0], p0[1] + k * d0[1]]; + let c2 = [p1[0] - k * d1[0], p1[1] - k * d1[1]]; + [project([c1[0], c1[1], z]), project([c2[0], c2[1], z]), project([p1[0], p1[1], z])] + }) + .collect() + }; + let mut segs = arcs(1.0); + let anchors: Vec = segs.iter().map(|s| s[2]).collect(); + if (signed_area(&anchors) > 0.0) != ccw { + segs = arcs(-1.0); + } + let start = segs[3][2]; + let mut d = format!("M{} {}", num(start[0]), num(start[1])); + for [c1, c2, p] in &segs { + d.push_str(&format!(" C{} {} {} {} {} {}", num(c1[0]), num(c1[1]), num(c2[0]), num(c2[1]), num(p[0]), num(p[1]))); + } + d.push_str(" Z"); + d +} + +#[derive(Clone, Copy)] +enum Tone { + Top, + Left, + Right, +} + +struct Face { + name: &'static str, + /// Corners in model space and, per edge (corner i to i + 1), whether another face of the + /// part meets it there (those edges get the gap). + corners: Vec, + shared: Vec, + tone: Tone, +} + +fn faces() -> Vec { + vec![ + Face { + name: "wall top", + corners: vec![[0.0, 0.0, H], [L, 0.0, H], [L, T, H], [0.0, T, H]], + shared: vec![false, true, true, false], + tone: Tone::Top, + }, + Face { + name: "base top", + corners: vec![[0.0, T, T], [L, T, T], [L, D, T], [0.0, D, T]], + shared: vec![true, true, true, false], + tone: Tone::Top, + }, + Face { + name: "wall front", + corners: vec![[0.0, T, T], [L, T, T], [L, T, H], [0.0, T, H]], + shared: vec![true, true, true, false], + tone: Tone::Left, + }, + Face { + name: "base front", + corners: vec![[0.0, D, 0.0], [L, D, 0.0], [L, D, T], [0.0, D, T]], + shared: vec![false, true, true, false], + tone: Tone::Left, + }, + Face { + name: "end", + corners: vec![[L, 0.0, 0.0], [L, D, 0.0], [L, D, T], [L, T, T], [L, T, H], [L, 0.0, H]], + shared: vec![false, true, true, true, true, false], + tone: Tone::Right, + }, + ] +} + +fn ccw(poly: Vec) -> Vec { + if signed_area(&poly) < 0.0 { poly.into_iter().rev().collect() } else { poly } +} + +/// A face's outline as path data, with its tone and its vertical extent on screen. +struct Outline { + name: &'static str, + tone: Tone, + d: String, + top: f64, + bottom: f64, +} + +/// Each face's outline on screen, inset at the shared edges by half the gap, wound +/// counter-clockwise (positive area); the base top carries the bolt hole wound the other way. +fn face_outlines(detail: Detail) -> Result, String> { + let mut out = Vec::new(); + for f in faces() { + let screen: Vec = f.corners.iter().map(|&p| project(p)).collect(); + let poly = ccw(inset(&screen, &f.shared, detail.gap() / 2.0)?); + let (top, bottom) = poly.iter().fold((f64::MAX, f64::MIN), |(a, b), p| (a.min(p[1]), b.max(p[1]))); + let mut d = polyline(&poly, true); + if f.name == "base top" { + d.push(' '); + d.push_str(&circle_d(HOLE, HOLE_R, T, false)); + } + out.push(Outline { name: f.name, tone: f.tone, d, top, bottom }); + } + Ok(out) +} + +/// Clip a segment to the axis-aligned square [lo, hi]² (Liang–Barsky). +fn clip_to_square(a: P2, b: P2, lo: f64, hi: f64) -> Option<(P2, P2)> { + let d = sub(b, a); + let (mut t0, mut t1) = (0.0_f64, 1.0_f64); + for (p, q) in [(-d[0], a[0] - lo), (d[0], hi - a[0]), (-d[1], a[1] - lo), (d[1], hi - a[1])] { + if p.abs() < 1e-12 { + if q < 0.0 { + return None; + } + } else { + let r = q / p; + if p < 0.0 { + t0 = t0.max(r); + } else { + t1 = t1.min(r); + } + } + } + (t0 < t1).then(|| ([a[0] + d[0] * t0, a[1] + d[1] * t0], [a[0] + d[0] * t1, a[1] + d[1] * t1])) +} + +/// The hexahedral mesh's edges on the visible faces, as path data. The interior nodes are +/// shared, so lines run on across the face boundaries as a real structured mesh would. +fn mesh_d() -> String { + let (x0, x1) = (HOLE[0] - BLOCK, HOLE[0] + BLOCK); + let xs: Vec = (0..=4).map(|i| x0 + (x1 - x0) * i as f64 / 4.0).collect(); + let ys: Vec = (1..4).map(|i| T + (D - T) * i as f64 / 4.0).collect(); + let zs: Vec = (1..4).map(|i| T + (H - T) * i as f64 / 4.0).collect(); + let mid = T / 2.0; + let mut segs: Vec<(P3, P3)> = Vec::new(); + // Base top: the O-grid block's sides and the strips either side of it. + for x in [x0, x1] { + segs.push(([x, T, T], [x, D, T])); + } + for &y in &ys { + segs.push(([0.0, y, T], [x0, y, T])); + segs.push(([x1, y, T], [L, y, T])); + } + // Wall front and wall top. + for &x in &xs { + segs.push(([x, T, T], [x, T, H])); + segs.push(([x, 0.0, H], [x, T, H])); + } + for &z in &zs { + segs.push(([0.0, T, z], [L, T, z])); + } + segs.push(([0.0, mid, H], [L, mid, H])); + // Base front. + for &x in &xs { + segs.push(([x, D, 0.0], [x, D, T])); + } + segs.push(([0.0, D, mid], [L, D, mid])); + // End face (x = L), an L shape. + segs.push(([L, mid, 0.0], [L, mid, H])); + for &y in std::iter::once(&T).chain(&ys) { + segs.push(([L, y, 0.0], [L, y, T])); + } + segs.push(([L, 0.0, mid], [L, D, mid])); + segs.push(([L, 0.0, T], [L, T, T])); + for &z in &zs { + segs.push(([L, 0.0, z], [L, T, z])); + } + let mut parts: Vec = segs.iter().map(|&(a, b)| polyline(&[project(a), project(b)], false)).collect(); + // O-grid around the hole: spokes from the block's boundary nodes to the hole, and one ring + // halfway between the hole and the block's square. + for i in -2_i32..=2 { + for j in -2_i32..=2 { + if i.abs() != 2 && j.abs() != 2 { + continue; + } + let node = [HOLE[0] + BLOCK * i as f64 / 2.0, HOLE[1] + BLOCK * j as f64 / 2.0]; + let t = (j as f64).atan2(i as f64); + let rim = [HOLE[0] + HOLE_R * t.cos(), HOLE[1] + HOLE_R * t.sin()]; + parts.push(polyline(&[project([rim[0], rim[1], T]), project([node[0], node[1], T])], false)); + } + } + let ring: Vec = (0..96) + .map(|k| { + let t = k as f64 * TAU / 96.0; + let square = BLOCK / t.cos().abs().max(t.sin().abs()); + let r = HOLE_R + 0.5 * (square - HOLE_R); + project([HOLE[0] + r * t.cos(), HOLE[1] + r * t.sin(), T]) + }) + .collect(); + parts.push(polyline(&ring, true)); + parts.join(" ") +} + +/// The floor grid on the ground plane under the part, clipped to the backdrop's square. +fn floor_d() -> String { + let step = 1.0 / 3.0; + let mut parts = Vec::new(); + for k in -12..=15 { + let c = k as f64 * step; + for (a, b) in [([c, -6.0, 0.0], [c, 6.0, 0.0]), ([-6.0, c, 0.0], [6.0, c, 0.0])] { + if let Some((p, q)) = clip_to_square(project(a), project(b), BACKDROP[0], BACKDROP[1]) { + parts.push(polyline(&[p, q], false)); + } + } + } + parts.join(" ") +} + +/// The backdrop's rounded square: straight sides with soft (continuous-looking) corners. +fn backdrop_d() -> String { + let [lo, hi] = BACKDROP; + let (r, h) = (220.0, 150.0); // corner span and handle length + let p = |x: f64, y: f64| format!("{} {}", num(x), num(y)); + format!( + "M{} L{} C{} {} {} L{} C{} {} {} L{} C{} {} {} L{} C{} {} {} Z", + p(lo + r, lo), + p(hi - r, lo), + p(hi - r + h, lo), + p(hi, lo + r - h), + p(hi, lo + r), + p(hi, hi - r), + p(hi, hi - r + h), + p(hi - r + h, hi), + p(hi - r, hi), + p(lo + r, hi), + p(lo + r - h, hi), + p(lo, hi - r + h), + p(lo, hi - r), + p(lo, lo + r), + p(lo, lo + r - h), + p(lo + r - h, lo), + p(lo + r, lo), + ) +} + +// ---------------------------------------------------------------- paints and layers + +fn linear(stops: [&str; 2], top: f64, bottom: f64) -> Value { + json!({"gradient": {"kind": "linear", "stops": [{"offset": 0, "color": stops[0]}, {"offset": 1, "color": stops[1]}], + "start": [CANVAS / 2.0, top], "end": [CANVAS / 2.0, bottom]}}) +} + +fn part_layer(detail: Detail) -> Result { + let mut shapes = Vec::new(); + for Outline { name, tone, d, top, bottom } in face_outlines(detail)? { + let colours = match tone { + Tone::Top => TOP, + Tone::Left => LEFT, + Tone::Right => RIGHT, + }; + shapes.push(Shape::filled(name, d, linear(colours, top, bottom))); + } + let centre = project([HOLE[0], HOLE[1], T]); + let half = HOLE_R * SCALE * 0.5_f64.sqrt(); + shapes.push(Shape::filled("bolt hole", circle_d(HOLE, HOLE_R, T, true), linear(BORE, centre[1] - half, centre[1] + half))); + Ok(Layer { name: "Part", shapes, clip: None }) +} + +fn mesh_layer() -> Result { + let clip = face_outlines(Detail::Full)?.into_iter().map(|o| o.d).collect::>().join(" "); + let stroke = Stroke { paint: json!({"color": "#ffffff"}), width: 4.5, cap: "butt" }; + Ok(Layer { name: "Mesh", shapes: vec![Shape::stroked("hexahedral mesh", mesh_d(), stroke, 0.5)], clip: Some(clip) }) +} + +fn floor_layer(appearance: Appearance) -> Layer { + let colour = match appearance { + Appearance::Light => "#7fa8a2", + Appearance::Dark => "#4f8a82", + }; + let fade = [(0.0, 1.0), (0.3, 0.9), (0.65, 0.35), (1.0, 0.0)]; + let stops: Vec = fade.iter().map(|(o, a)| json!({"offset": o, "color": colour, "opacity": a})).collect(); + let paint = json!({"gradient": {"kind": "radial", "stops": stops, "start": [512.0, 720.0], "end": [1090.0, 720.0], "aspect": 50}}); + let stroke = Stroke { paint, width: 3.0, cap: "butt" }; + // Clipped to the backdrop, so the flattened icon's grid stops at its rounded corners (Icon + // Composer masks its own layers the same way). + Layer { name: "Floor grid", shapes: vec![Shape::stroked("floor grid", floor_d(), stroke, 1.0)], clip: Some(backdrop_d()) } +} + +fn backdrop_layer(appearance: Appearance) -> Layer { + let (colours, shadow) = match appearance { + Appearance::Light => (["#fbfdfd", "#dbe5e4"], "#1d3b38"), + Appearance::Dark => (["#2b3433", "#141a1a"], "#000000"), + }; + let mut shape = Shape::filled("backdrop", backdrop_d(), linear(colours, BACKDROP[0], BACKDROP[1])); + shape.shadow = Some(json!({"mode": "multiply", "opacity": 22, "x": 0, "y": 10, "blur": 14, "color": shadow})); + Layer { name: "Backdrop", shapes: vec![shape], clip: None } +} + +fn composite(detail: Detail, appearance: Appearance) -> Result { + let mut layers = vec![backdrop_layer(appearance)]; + if detail == Detail::Full { + layers.push(floor_layer(appearance)); + } + layers.push(part_layer(detail)?); + if detail == Detail::Full { + layers.push(mesh_layer()?); + } + let title = match (detail, appearance) { + (Detail::Full, Appearance::Light) => "ModelCraft application icon", + (Detail::Full, Appearance::Dark) => "ModelCraft application icon (dark preview)", + (Detail::Small, Appearance::Light) => "ModelCraft application icon, small sizes", + (Detail::Small, Appearance::Dark) => "ModelCraft application icon, small sizes (dark preview)", + }; + Ok(Doc { title, layers }) +} + +// ---------------------------------------------------------------- VectorCraft + +/// The VectorCraft commands that draw `doc` and save its source to `source`. +fn commands(doc: &Doc, source: &Path) -> Vec<(&'static str, Value)> { + let mut out = vec![("file.new", json!({"width": CANVAS, "height": CANVAS, "units": "Pixels", "name": doc.title, "created": null}))]; + for (i, layer) in doc.layers.iter().enumerate() { + if i == 0 { + out.push(("layer.setProps", json!({"name": layer.name}))); + } else { + out.push(("layer.new", json!({"name": layer.name}))); + } + for s in &layer.shapes { + out.push(("path.create", json!({"d": s.d}))); + out.push(("paint.setFill", s.fill.clone())); + match &s.stroke { + Some(st) => { + out.push(("paint.setStroke", st.paint.clone())); + out.push(("stroke.set", json!({"weight": st.width, "cap": st.cap, "join": "round", "dash": null}))); + } + None => out.push(("paint.setStroke", json!({"none": true}))), + } + let mut props = json!({"name": s.name}); + if s.opacity < 1.0 { + props["opacity"] = json!(s.opacity * 100.0); + } + out.push(("object.setProps", props)); + if let Some(shadow) = &s.shadow { + out.push(("effect.apply", json!({"effect": "stylize.dropShadow", "params": shadow}))); + } + } + if let Some(clip) = &layer.clip { + out.push(("path.create", json!({"d": clip}))); + out.push(("object.setProps", json!({"name": "faces"}))); + out.push(("select.none", json!({}))); + out.push(("layer.clippingMask.toggle", json!({}))); + } + } + out.push(("select.none", json!({}))); + out.push(("document.save", json!({"path": source, "compress": true, "modified": null}))); + out +} + +fn vectorcraft(doc: &Doc, source: &Path, exports: &[PathBuf]) -> Result<(), String> { + let cli = std::env::var("VECTORCRAFT_CLI").unwrap_or_else(|_| "vectorcraft-cli".into()); + let mut c = Command::new(&cli); + c.arg("run"); + for (cmd, params) in commands(doc, source) { + c.args(["--cmd", cmd, "--params", ¶ms.to_string()]); + } + for e in exports { + c.arg("--export").arg(e); + } + let out = c.output().map_err(|e| format!("{cli}: {e} (set VECTORCRAFT_CLI to the vectorcraft-cli binary)"))?; + if !out.status.success() { + return Err(format!( + "{}: vectorcraft-cli failed:\n{}{}", + doc.title, + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + )); + } + Ok(()) +} + +fn tool(program: &str, args: &[&str]) -> Result<(), String> { + let out = Command::new(program).args(args).output().map_err(|e| format!("{program}: {e}"))?; + if out.status.success() { + Ok(()) + } else { + Err(format!("{program} {}: {}{}", args.join(" "), String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr))) + } +} + +fn path_str(p: &Path) -> Result<&str, String> { + p.to_str().ok_or_else(|| format!("{}: not valid UTF-8", p.display())) +} + +/// Pixel sizes at and below this use the small-size artwork. +const SMALL_MAX: u32 = 32; + +/// Downsample the 1024 px export that suits `px` into `dest`. +fn resample(dir: &Path, px: u32, dest: &Path) -> Result<(), String> { + let src = dir.join(if px <= SMALL_MAX { "icon-small.png" } else { "icon.png" }); + let size = px.to_string(); + tool("sips", &["-z", &size, &size, path_str(&src)?, "--out", path_str(dest)?]) +} + +fn icon_json() -> Value { + let group = |name: &str, layer: Value, specular: bool| { + json!({"layers": [layer], "name": name, "shadow": {"kind": "none", "opacity": 0}, "specular": specular, + "translucency": {"enabled": false, "value": 0.3}}) + }; + let position = json!({"scale": 0.9, "translation-in-points": [0, 0]}); + json!({ + "fill": "system-light", + "groups": [ + group("03 Mesh", json!({"image-name": "03-mesh.svg", "name": "03-mesh", "position": position, + "glass-specializations": [{"appearance": "tinted", "value": true}]}), false), + group("02 Part", json!({"image-name": "02-part.svg", "name": "02-part", "position": position, + "glass-specializations": [{"appearance": "tinted", "value": true}]}), true), + group("01 Floor", json!({"image-name-specializations": [{"value": "01-floor.svg"}, {"appearance": "dark", "value": "01-floor-dark.svg"}], + "name": "01-floor", "fill": "none", "position": position, + "glass-specializations": [{"value": false}, {"appearance": "tinted", "value": true}]}), false), + ], + "supported-platforms": {"circles": ["watchOS"], "squares": "shared"} + }) +} + +pub fn run(root: &Path) -> Result<(), String> { + let dir = root.join("assets/app-icon"); + let layers = dir.join("layers"); + let bundle = dir.join("ModelCraft.icon"); + let assets = bundle.join("Assets"); + let iconset = dir.join("icon.iconset"); + let target = std::env::var_os("CARGO_TARGET_DIR").map_or_else(|| root.join("target"), PathBuf::from); + let work = target.join("app-icon"); + for d in [&layers, &assets, &iconset, &work] { + std::fs::create_dir_all(d).map_err(|e| format!("{}: {e}", d.display()))?; + } + + // The flattened icons (backdrop included) and the dark previews. + vectorcraft(&composite(Detail::Full, Appearance::Light)?, &dir.join("icon.vectorcraft"), &[dir.join("icon.svg"), dir.join("icon.png")])?; + vectorcraft( + &composite(Detail::Small, Appearance::Light)?, + &dir.join("icon-small.vectorcraft"), + &[dir.join("icon-small.svg"), dir.join("icon-small.png")], + )?; + vectorcraft(&composite(Detail::Full, Appearance::Dark)?, &work.join("icon-dark.vectorcraft"), &[work.join("icon-dark.png")])?; + vectorcraft(&composite(Detail::Small, Appearance::Dark)?, &work.join("icon-small-dark.vectorcraft"), &[work.join("icon-small-dark.png")])?; + + // Icon Composer layers: the system supplies the backdrop. + let single = |title, layer| Doc { title, layers: vec![layer] }; + let layer_docs = [ + ("01-floor", single("ModelCraft icon floor grid", floor_layer(Appearance::Light))), + ("01-floor-dark", single("ModelCraft icon floor grid (Dark)", floor_layer(Appearance::Dark))), + ("02-part", single("ModelCraft icon part", part_layer(Detail::Full)?)), + ("03-mesh", single("ModelCraft icon mesh", mesh_layer()?)), + ]; + for (name, doc) in &layer_docs { + vectorcraft(doc, &layers.join(format!("{name}.vectorcraft")), &[assets.join(format!("{name}.svg"))])?; + } + let json = serde_json::to_string_pretty(&icon_json()).map_err(|e| e.to_string())?; + std::fs::write(bundle.join("icon.json"), json + "\n").map_err(|e| format!("icon.json: {e}"))?; + + // macOS iconset and icns. + for px in [16_u32, 32, 128, 256, 512] { + resample(&dir, px, &iconset.join(format!("icon_{px}x{px}.png")))?; + resample(&dir, px * 2, &iconset.join(format!("icon_{px}x{px}@2x.png")))?; + } + tool("iconutil", &["-c", "icns", path_str(&iconset)?, "-o", path_str(&dir.join("icon.icns"))?])?; + + // Windows icon. + let mut ico_args = Vec::new(); + for px in [16_u32, 24, 32, 48, 64, 128, 256] { + let p = work.join(format!("ico-{px}.png")); + resample(&dir, px, &p)?; + ico_args.push(path_str(&p)?.to_string()); + } + ico_args.push(path_str(&dir.join("icon.ico"))?.to_string()); + let args: Vec<&str> = ico_args.iter().map(String::as_str).collect(); + tool("magick", &args)?; + + // The layered macOS 26 icon, when an Xcode with Icon Composer support is installed. + let partial = work.join("icon-info.plist"); + let compiled = tool( + "xcrun", + &[ + "actool", + path_str(&bundle)?, + "--compile", + path_str(&work)?, + "--platform", + "macosx", + "--minimum-deployment-target", + "11.0", + "--app-icon", + "ModelCraft", + "--output-partial-info-plist", + path_str(&partial)?, + "--output-format", + "human-readable-text", + ], + ); + match compiled { + Ok(()) => { + std::fs::copy(work.join("Assets.car"), dir.join("Assets.car")).map_err(|e| format!("Assets.car: {e}"))?; + println!("app-icon: compiled ModelCraft.icon into assets/app-icon/Assets.car"); + } + Err(e) => println!("app-icon: note: actool could not compile ModelCraft.icon, Assets.car left as it was ({e})"), + } + println!("app-icon: wrote assets/app-icon (dark previews in {})", work.display()); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn insetting_moves_only_the_flagged_edges() { + let square = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]; + let out = inset(&square, &[true, false, false, false], 2.0).unwrap(); + assert_eq!(out, vec![[0.0, 2.0], [10.0, 2.0], [10.0, 10.0], [0.0, 10.0]]); + // The same result whichever way the polygon winds. + let rev: Vec = square.iter().rev().copied().collect(); + let out = inset(&rev, &[false, false, true, false], 2.0).unwrap(); + assert!(out.contains(&[0.0, 2.0]) && out.contains(&[10.0, 2.0])); + assert!(inset(&square[..2], &[true, true], 1.0).is_err()); + } + + #[test] + fn the_hole_winds_against_its_face() { + for o in face_outlines(Detail::Full).unwrap() { + assert!(o.d.starts_with('M') && o.d.contains('Z'), "{}: {}", o.name, o.d); + } + let anchors = |d: &str| -> Vec { + d.split('C') + .skip(1) + .map(|s| { + let n: Vec = s.split_whitespace().filter_map(|x| x.trim_end_matches('Z').parse().ok()).collect(); + [n[4], n[5]] + }) + .collect() + }; + assert!(signed_area(&anchors(&circle_d(HOLE, HOLE_R, T, true))) > 0.0); + assert!(signed_area(&anchors(&circle_d(HOLE, HOLE_R, T, false))) < 0.0); + } + + #[test] + fn segments_clip_to_the_square() { + assert_eq!(clip_to_square([-5.0, 5.0], [15.0, 5.0], 0.0, 10.0), Some(([0.0, 5.0], [10.0, 5.0]))); + assert_eq!(clip_to_square([-5.0, -5.0], [-1.0, 20.0], 0.0, 10.0), None); + } + + #[test] + fn every_document_starts_new_and_ends_saved() { + let doc = composite(Detail::Full, Appearance::Light).unwrap(); + let steps = commands(&doc, Path::new("/tmp/icon.vectorcraft")); + assert_eq!(steps.first().map(|s| s.0), Some("file.new")); + assert_eq!(steps.last().map(|s| s.0), Some("document.save")); + assert_eq!(steps.iter().filter(|s| s.0 == "layer.new").count(), 3); + // The floor grid is clipped to the backdrop and the mesh to the faces. + assert_eq!(steps.iter().filter(|s| s.0 == "layer.clippingMask.toggle").count(), 2); + } +} diff --git a/xtask/src/assets.rs b/xtask/src/assets.rs index e62655b..54d6d8f 100644 --- a/xtask/src/assets.rs +++ b/xtask/src/assets.rs @@ -10,6 +10,7 @@ const ASSET_EXT: &[&str] = &[ "svg", "ico", "icns", + "car", "ttf", "otf", "woff", diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 9804a6b..64c0613 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -5,6 +5,7 @@ //! ATTRIBUTION.md. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod app_icon; mod assets; mod icons; mod layers; @@ -23,6 +24,9 @@ commands: icons [ID…] draw the toolbar icons from their recipes (assets/icons/src/*.json) with VectorCraft (VECTORCRAFT_CLI, else vectorcraft-cli on the PATH): writes each icon's .vectorcraft source and .svg export + app-icon draw the application icon family (assets/app-icon) with VectorCraft, then + write the iconset, icns, ico and Icon Composer document (macOS: sips, + iconutil, magick; actool for Assets.car when available) licences [--check] write THIRD-PARTY-LICENSES.txt (the licences of every shipped crate); --check fails when it is out of date @@ -44,6 +48,7 @@ fn main() -> ExitCode { Some("layers") => cmd_layers(), Some("assets") => assets::run(&root()), Some("icons") => icons::run(&root(), &rest), + Some("app-icon") => app_icon::run(&root()), Some("licences") => licences::run(&root(), rest.contains(&"--check")), Some("test") => { let (cargo_args, test_args) = rest.iter().position(|a| *a == "--").map_or((&rest[..], &[][..]), |i| (&rest[..i], &rest[i + 1..]));