forked from 1b-agent/fieldcraft-upstream
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:
34 files changed
+1145
-1
No files matched your search
@@ -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", ¶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<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);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ const ASSET_EXT: &[&str] = &[
|
||||
"svg",
|
||||
"ico",
|
||||
"icns",
|
||||
"car",
|
||||
"ttf",
|
||||
"otf",
|
||||
"woff",
|
||||
|
||||
@@ -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..]));
|
||||
|
||||
Reference in new issue
Block a user