feat: application icon family (a meshed bracket) drawn with VectorCraft (#37)

cargo xtask app-icon computes an isometric L bracket with a bolt hole and a
structured hexahedral mesh (O-grid around the hole), draws it with
vectorcraft-cli and writes icon.svg/png, small-size artwork, the iconset,
icns, ico, an Icon Composer ModelCraft.icon (floor grid Default/Dark, part,
mesh) and its actool Assets.car. The desktop app uses the 256 px export as
its window icon, falling back to eframe's default if it cannot be decoded.


Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-13 20:11:41 -04:00
1 parent a17752a49f
commit 4e4d19b88c
34 files changed
+1145 -1

No files matched your search

+753
View File
@@ -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<Stroke>,
opacity: f64,
shadow: Option<Value>,
}
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<Shape>,
/// A path that clips the layer's contents (VectorCraft's layer clipping mask).
clip: Option<String>,
}
struct Doc {
title: &'static str,
layers: Vec<Layer>,
}
// ---------------------------------------------------------------- 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::<f64>() * 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<Vec<P2>, 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<P2> = 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<P3>,
shared: Vec<bool>,
tone: Tone,
}
fn faces() -> Vec<Face> {
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<P2>) -> Vec<P2> {
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<Vec<Outline>, String> {
let mut out = Vec::new();
for f in faces() {
let screen: Vec<P2> = 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<f64> = (0..=4).map(|i| x0 + (x1 - x0) * i as f64 / 4.0).collect();
let ys: Vec<f64> = (1..4).map(|i| T + (D - T) * i as f64 / 4.0).collect();
let zs: Vec<f64> = (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<String> = 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<P2> = (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<Layer, String> {
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<Layer, String> {
let clip = face_outlines(Detail::Full)?.into_iter().map(|o| o.d).collect::<Vec<_>>().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<Value> = 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<Doc, String> {
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", &params.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<P2> = 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<P2> {
d.split('C')
.skip(1)
.map(|s| {
let n: Vec<f64> = 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);
}
}
+1
View File
@@ -10,6 +10,7 @@ const ASSET_EXT: &[&str] = &[
"svg",
"ico",
"icns",
"car",
"ttf",
"otf",
"woff",
+5
View File
@@ -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..]));