App icon: the chameleon, vectorised like the other Crafting Apps (#156)

The owner's engraving-style chameleon drawing, traced to three colours (ink, paper,
FigCraft green #02da96) on the shared 512-unit tile. assets/app-icon/figcraft.svg is the
master; cargo xtask icon now renders the PNGs (macOS grid), the hicolor theme and the .ico
(tight crop, 16-256 px) from it with resvg instead of drawing the old geometric mark.
Off macOS the window icon uses the tight 256 px render.
This commit is contained in:
BFlat 2026-10-12 14:25:29 +07:00 committed by GitHub
parent ab94cff913
commit a0d1e72c8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 111 additions and 106 deletions

View File

@ -46,7 +46,8 @@ code.
`assets/`, `docs/brand/` or `docs/images/`. `assets/`, `docs/brand/` or `docs/images/`.
- FigCraft's UI icons are drawn in code (`crates/ui-egui/src/icons.rs`) from simple primitives and - FigCraft's UI icons are drawn in code (`crates/ui-egui/src/icons.rs`) from simple primitives and
are original. Keep it that way: new icons are new code, designed from scratch. are original. Keep it that way: new icons are new code, designed from scratch.
- The app icon is generated in code (`cargo xtask icon`, `xtask/src/icon.rs`). - The app icon is the owner's chameleon drawing, vectorised (`assets/app-icon/README.md`); `cargo xtask icon`
renders the PNGs and `.ico` from `assets/app-icon/figcraft.svg`.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this - **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this
repo.** The app uses it only through the optional `CRAFT_FONTS_DIR` build input repo.** The app uses it only through the optional `CRAFT_FONTS_DIR` build input
(`.cargo/config.toml` points it at `../craft-fonts` for local builds; `crates/text/build.rs` (`.cargo/config.toml` points it at `../craft-fonts` for local builds; `crates/text/build.rs`

View File

@ -11,7 +11,7 @@ MIT OR Apache-2.0. No third-party icon sets are used.
| Path | Author | Source | Licence | Notes | | Path | Author | Source | Licence | Notes |
|---|---|---|---|---| |---|---|---|---|---|
| `assets/app-icon/` (all files: `figcraft-*.png`, `figcraft.ico`, `hicolor/*/apps/ai.storyteller.figcraft.png`) | FigCraft contributors | Generated by `cargo xtask icon` (`xtask/src/icon.rs`) with FigCraft's own renderer | MIT OR Apache-2.0 | Original geometric mark (stacked layer cards and a cursor) | | `assets/app-icon/` (all files: `figcraft.svg`, `figcraft-small.svg`, `figcraft-*.png`, `figcraft.ico`, `hicolor/`) | The project owner (engraving-style drawing, vectorised) | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md); derived files rendered by `cargo xtask icon` | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) | FigCraft app icon (chameleon) |
| `docs/brand/artcraft-logo.svg`, `docs/brand/artcraft-logo.png`, `docs/brand/artcraft-logo-white.svg`, `docs/brand/artcraft-logo-white.png`, `docs/brand/artcraft-mark.svg`, `docs/brand/artcraft-mark.png`, `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`), not open source | Trademarks; unmodified use within FigCraft only | | `docs/brand/artcraft-logo.svg`, `docs/brand/artcraft-logo.png`, `docs/brand/artcraft-logo-white.svg`, `docs/brand/artcraft-logo-white.png`, `docs/brand/artcraft-mark.svg`, `docs/brand/artcraft-mark.png`, `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`), not open source | Trademarks; unmodified use within FigCraft only |
| `docs/images/` (all screenshots and exports: `shot-*.png`, `art-*.png`) | FigCraft contributors | Screenshots of FigCraft showing synthetic demo documents made in FigCraft, and PNG exports of those documents by FigCraft's renderer | MIT OR Apache-2.0 | No third-party artwork | | `docs/images/` (all screenshots and exports: `shot-*.png`, `art-*.png`) | FigCraft contributors | Screenshots of FigCraft showing synthetic demo documents made in FigCraft, and PNG exports of those documents by FigCraft's renderer | MIT OR Apache-2.0 | No third-party artwork |
| `examples/*.figcraft` | FigCraft contributors | Synthetic demo documents built through FigCraft's command API; invented copy, shapes and vectors drawn from scratch, no images or embedded fonts | MIT OR Apache-2.0 | Text uses Inter (OFL, from craft-fonts or the system); no third-party artwork | | `examples/*.figcraft` | FigCraft contributors | Synthetic demo documents built through FigCraft's command API; invented copy, shapes and vectors drawn from scratch, no images or embedded fonts | MIT OR Apache-2.0 | Text uses Inter (OFL, from craft-fonts or the system); no third-party artwork |

View File

@ -46,7 +46,8 @@ code.
`assets/`, `docs/brand/` or `docs/images/`. `assets/`, `docs/brand/` or `docs/images/`.
- FigCraft's UI icons are drawn in code (`crates/ui-egui/src/icons.rs`) from simple primitives and - FigCraft's UI icons are drawn in code (`crates/ui-egui/src/icons.rs`) from simple primitives and
are original. Keep it that way: new icons are new code, designed from scratch. are original. Keep it that way: new icons are new code, designed from scratch.
- The app icon is generated in code (`cargo xtask icon`, `xtask/src/icon.rs`). - The app icon is the owner's chameleon drawing, vectorised (`assets/app-icon/README.md`); `cargo xtask icon`
renders the PNGs and `.ico` from `assets/app-icon/figcraft.svg`.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this - **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this
repo.** The app uses it only through the optional `CRAFT_FONTS_DIR` build input repo.** The app uses it only through the optional `CRAFT_FONTS_DIR` build input
(`.cargo/config.toml` points it at `../craft-fonts` for local builds; `crates/text/build.rs` (`.cargo/config.toml` points it at `../craft-fonts` for local builds; `crates/text/build.rs`

5
Cargo.lock generated
View File

@ -5805,12 +5805,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
name = "xtask" name = "xtask"
version = "0.4.0" version = "0.4.0"
dependencies = [ dependencies = [
"figcraft-color",
"figcraft-doc",
"figcraft-engine", "figcraft-engine",
"figcraft-render",
"figcraft-svg",
"image",
"serde_json", "serde_json",
] ]

View File

@ -210,7 +210,11 @@ impl Services for Desktop {
/// The window icon from the bundled app icon (if present). /// The window icon from the bundled app icon (if present).
pub fn window_icon() -> Option<egui::IconData> { pub fn window_icon() -> Option<egui::IconData> {
// macOS wants Apple's padded grid; the other platforms the tile edge to edge.
#[cfg(target_os = "macos")]
let bytes = include_bytes!("../../../assets/app-icon/figcraft-256.png"); let bytes = include_bytes!("../../../assets/app-icon/figcraft-256.png");
#[cfg(not(target_os = "macos"))]
let bytes = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.figcraft.png");
let img = image::load_from_memory(bytes).ok()?.to_rgba8(); let img = image::load_from_memory(bytes).ok()?.to_rgba8();
let (w, h) = img.dimensions(); let (w, h) = img.dimensions();
Some(egui::IconData { rgba: img.into_raw(), width: w, height: h }) Some(egui::IconData { rgba: img.into_raw(), width: w, height: h })

View File

@ -0,0 +1,12 @@
FigCraft app icon (figcraft.svg, figcraft-small.svg and every file derived from them in this
directory: figcraft-<size>.png, figcraft.ico, hicolor/).
Original artwork made for this project by the project owner: an engraving-style drawing, then
vectorised. No third-party material. Licensed like the rest of FigCraft, under either of
* Apache License, Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
* MIT license (https://opensource.org/licenses/MIT)
at your option.
Regenerate the derived files with `cargo xtask icon`.

41
assets/app-icon/README.md Normal file
View File

@ -0,0 +1,41 @@
# FigCraft app icon
A **chameleon**: an engraving portrait in profile with a curled tail, in the Crafting Apps'
owl-template framing (full-bleed field, head and body filling the tile, body running off the edges).
## Palette
Exactly three colours:
| Colour | Hex | Used for |
|---|---|---|
| Ink | `#0b0b0c` | line work and contours |
| Paper | `#efe9dc` | the figure |
| FigCraft green (app colour) | `#02da96` | the full-bleed field |
## Geometry
A 512-unit tile (`viewBox="0 0 512 512"`), rounded square with `rx=112`, no border. The macOS
renders pad it onto Apple's 824/1024 icon grid; the Windows and Linux renders crop 22 units off
each side so the figure reads at 16–48 px.
## Provenance
The owner's original engraving-style drawing (1500 px, keyed to the palette), supplied for
FigCraft and vectorised with craftrules `assets/logo-options/_tools/vectorize_tile.py`. The source
image is not kept in this repo. License: see `LICENSE.txt`.
## Files
- `figcraft.svg`: canonical master (traced at 1500 px, the source's size).
- `figcraft-small.svg`: lighter trace (traced at 1024 px); also the hicolor scalable icon.
- `figcraft-<size>.png` (16–1024): renders on the macOS grid; `packaging/macos/package.sh` builds
the `.icns` from them.
- `figcraft.ico`: Windows icon, 16–256 px, embedded in the `.exe` by `apps/figcraft/build.rs`.
- `hicolor/<size>/apps/ai.storyteller.figcraft.png` (16–512) and `hicolor/scalable/...svg`:
Linux icon theme; the 256 px one is also the runtime window icon off macOS.
## Regenerate
Replace `figcraft.svg` (and `figcraft-small.svg`), then run `cargo xtask icon`. It needs `resvg`
(`cargo install resvg`).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 559 B

After

Width:  |  Height:  |  Size: 678 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 106 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 559 B

After

Width:  |  Height:  |  Size: 779 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 453 KiB

View File

@ -7,9 +7,4 @@ publish = false
[dependencies] [dependencies]
serde_json = { workspace = true } serde_json = { workspace = true }
figcraft-doc = { workspace = true }
figcraft-color = { workspace = true }
figcraft-render = { workspace = true }
figcraft-engine = { workspace = true } figcraft-engine = { workspace = true }
figcraft-svg = { workspace = true }
image = { workspace = true }

View File

@ -1,102 +1,64 @@
//! The app icon, generated from code with FigCraft's own renderer (original artwork, //! The app icon: rasterises the master `assets/app-icon/figcraft.svg` (the chameleon, see
//! MIT OR Apache-2.0): a rounded tile in the app colour with a paper "frame" mark — three stacked //! `assets/app-icon/README.md`) with the `resvg` CLI (`cargo install resvg`). Writes
//! layer cards and a cursor. Writes assets/app-icon/*.png, the hicolor theme and figcraft.ico. //! figcraft-<size>.png (macOS grid), the hicolor theme and figcraft.ico. The outputs are
//! committed, so building and packaging never need `resvg`.
use figcraft_color::{Color, Paint}; use std::path::Path;
use figcraft_doc::{Document, NodeKind}; use std::process::Command;
pub const APP_COLOR: &str = "0EA5C6"; const VIEW_BOX: &str = r#"viewBox="0 0 512 512""#;
const INK: &str = "0B0B0C"; /// macOS: the 512-unit tile padded onto Apple's 824/1024 icon grid (transparent margin).
const PAPER: &str = "EFE9DC"; const MAC_VIEW_BOX: &str = r#"viewBox="-62 -62 636 636""#;
/// Windows and Linux: 22 units cropped off each side so the portrait reads at 16-48 px.
const TIGHT_VIEW_BOX: &str = r#"viewBox="22 22 468 468""#;
fn build() -> Option<Document> { fn render(svg: &Path, size: u32, out: &Path) -> bool {
let mut d = Document::new("icon"); let s = size.to_string();
let p = d.first_page()?; let ok = Command::new("resvg").args(["-w", &s, "-h", &s]).arg(svg).arg(out).status().is_ok_and(|st| st.success());
let bg = d.create(p, NodeKind::Rectangle, "tile", 0.0, 0.0, 512.0, 512.0).ok()?; if !ok {
let n = d.get_mut(bg).ok()?; eprintln!("icon: resvg failed for {} (is it installed? `cargo install resvg`)", out.display());
n.corners.radii = [112.0; 4];
n.corners.smoothing = 0.6;
n.fills = vec![Paint::solid(Color::from_hex(APP_COLOR)?)];
// Three stacked cards (layers), back to front.
for (i, (x, y, w, h)) in [(150.0, 96.0, 250.0, 200.0), (122.0, 150.0, 250.0, 200.0), (94.0, 204.0, 250.0, 200.0)].iter().enumerate() {
let c = d.create(p, NodeKind::Rectangle, "card", *x, *y, *w, *h).ok()?;
let n = d.get_mut(c).ok()?;
n.corners.radii = [28.0; 4];
n.corners.smoothing = 0.6;
n.fills = vec![Paint::solid(Color::from_hex(PAPER)?)];
n.strokes = vec![Paint::solid(Color::from_hex(INK)?)];
n.stroke.weight = 14.0;
n.stroke.align = figcraft_doc::StrokeAlign::Inside;
if i == 2 {
// Front card: a frame grid "#".
for (gx, gy, gw, gh) in
[(150.0, 228.0, 14.0, 152.0), (250.0, 228.0, 14.0, 152.0), (118.0, 260.0, 178.0, 14.0), (118.0, 330.0, 178.0, 14.0)]
{
let g = d.create(p, NodeKind::Rectangle, "grid", gx, gy, gw, gh).ok()?;
let gn = d.get_mut(g).ok()?;
gn.fills = vec![Paint::solid(Color::from_hex(INK)?)];
gn.corners.radii = [7.0; 4];
}
}
} }
// Cursor. ok
let cur = figcraft_doc::VectorNetwork::from_points(
&[
(0.0, 0.0).into(),
(0.0, 120.0).into(),
(32.0, 92.0).into(),
(52.0, 136.0).into(),
(72.0, 128.0).into(),
(52.0, 84.0).into(),
(92.0, 82.0).into(),
],
true,
);
let c = d.create(p, NodeKind::Vector { network: cur }, "cursor", 318.0, 300.0, 92.0, 136.0).ok()?;
let n = d.get_mut(c).ok()?;
n.fills = vec![Paint::solid(Color::from_hex(INK)?)];
n.strokes = vec![Paint::solid(Color::from_hex(PAPER)?)];
n.stroke.weight = 10.0;
n.stroke.align = figcraft_doc::StrokeAlign::Outside;
n.stroke.join = figcraft_doc::StrokeJoin::Round;
Some(d)
} }
pub fn generate(root: &std::path::Path) -> bool { pub fn generate(root: &Path) -> bool {
let Some(doc) = build() else { return false };
let Some(page) = doc.first_page() else { return false };
let dir = root.join("assets/app-icon"); let dir = root.join("assets/app-icon");
let _ = std::fs::create_dir_all(dir.join("hicolor")); let Ok(master) = std::fs::read_to_string(dir.join("figcraft.svg")) else {
let render = |size: u32| -> Option<image::RgbaImage> { eprintln!("icon: cannot read {}", dir.join("figcraft.svg").display());
let view = figcraft_render::View { origin: (0.0, 0.0), scale: f64::from(size) / 512.0, width: size, height: size }; return false;
let opts = figcraft_render::Options { background: Some(Color::TRANSPARENT), ..Default::default() };
let pix = figcraft_render::render_view(&doc, page, &view, &opts)?;
image::RgbaImage::from_raw(size, size, figcraft_render::to_rgba8(&pix))
}; };
let mut ok = true; if !master.contains(VIEW_BOX) {
eprintln!("icon: expected {VIEW_BOX} in figcraft.svg");
return false;
}
let tmp = root.join("target/icon");
let (mac, tight) = (tmp.join("mac.svg"), tmp.join("tight.svg"));
let mut ok = std::fs::create_dir_all(&tmp).is_ok()
&& std::fs::write(&mac, master.replacen(VIEW_BOX, MAC_VIEW_BOX, 1)).is_ok()
&& std::fs::write(&tight, master.replacen(VIEW_BOX, TIGHT_VIEW_BOX, 1)).is_ok();
if !ok {
eprintln!("icon: cannot write {}", tmp.display());
return false;
}
for size in [16u32, 32, 48, 64, 128, 256, 512, 1024] { for size in [16u32, 32, 48, 64, 128, 256, 512, 1024] {
let Some(img) = render(size) else { return false }; ok &= render(&mac, size, &dir.join(format!("figcraft-{size}.png")));
let name = if size == 1024 { "figcraft-1024.png".to_string() } else { format!("figcraft-{size}.png") };
ok &= img.save(dir.join(&name)).is_ok();
if size <= 512 { if size <= 512 {
let hd = dir.join(format!("hicolor/{size}x{size}/apps")); let hd = dir.join(format!("hicolor/{size}x{size}/apps"));
let _ = std::fs::create_dir_all(&hd); let _ = std::fs::create_dir_all(&hd);
ok &= img.save(hd.join("ai.storyteller.figcraft.png")).is_ok(); ok &= render(&tight, size, &hd.join("ai.storyteller.figcraft.png"));
} }
} }
// Windows .ico (PNG-compressed entries). // Windows .ico (PNG-compressed entries).
let mut ico = Vec::new(); let sizes = [16u32, 20, 24, 32, 40, 48, 64, 128, 256];
let sizes = [16u32, 32, 48, 256];
let pngs: Vec<Vec<u8>> = sizes let pngs: Vec<Vec<u8>> = sizes
.iter() .iter()
.filter_map(|s| { .filter_map(|s| {
let img = render(*s)?; let png = tmp.join(format!("ico-{s}.png"));
let mut out = Vec::new(); render(&tight, *s, &png).then(|| std::fs::read(png).ok()).flatten()
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?;
Some(out)
}) })
.collect(); .collect();
ico.extend_from_slice(&[0, 0, 1, 0, pngs.len() as u8, 0]); ok &= pngs.len() == sizes.len();
let mut ico = vec![0, 0, 1, 0, pngs.len() as u8, 0];
let mut offset = 6 + 16 * pngs.len() as u32; let mut offset = 6 + 16 * pngs.len() as u32;
for (s, png) in sizes.iter().zip(&pngs) { for (s, png) in sizes.iter().zip(&pngs) {
let b = if *s >= 256 { 0 } else { *s as u8 }; let b = if *s >= 256 { 0 } else { *s as u8 };
@ -109,17 +71,10 @@ pub fn generate(root: &std::path::Path) -> bool {
ico.extend_from_slice(png); ico.extend_from_slice(png);
} }
ok &= std::fs::write(dir.join("figcraft.ico"), ico).is_ok(); ok &= std::fs::write(dir.join("figcraft.ico"), ico).is_ok();
// Scalable SVG (same artwork, written by FigCraft's SVG exporter). // The lighter trace keeps the scalable theme icon cheap to render.
let tops: Vec<_> = doc.node(page).map(|p| p.children.clone()).unwrap_or_default(); let sd = dir.join("hicolor/scalable/apps");
match figcraft_svg::export_svg(&doc, &tops, &Default::default()) { let _ = std::fs::create_dir_all(&sd);
Some(svg) => { ok &= std::fs::copy(dir.join("figcraft-small.svg"), sd.join("ai.storyteller.figcraft.svg")).is_ok();
let sd = dir.join("hicolor/scalable/apps");
let _ = std::fs::create_dir_all(&sd);
ok &= std::fs::write(dir.join("figcraft.svg"), &svg).is_ok();
ok &= std::fs::write(sd.join("ai.storyteller.figcraft.svg"), &svg).is_ok();
}
None => ok = false,
}
eprintln!("icon: wrote {}", dir.display()); eprintln!("icon: wrote {}", dir.display());
ok ok
} }

View File

@ -5,7 +5,7 @@
//! - `assets` every file under assets/ and docs/ has a row in ATTRIBUTION.md //! - `assets` every file under assets/ and docs/ has a row in ATTRIBUTION.md
//! - `parity` menu-catalog coverage → docs/parity-checklist.md //! - `parity` menu-catalog coverage → docs/parity-checklist.md
//! - `version` print the version, or `version set X.Y.Z` //! - `version` print the version, or `version set X.Y.Z`
//! - `icon` regenerate the app icon PNGs/ICO from code (original artwork) //! - `icon` regenerate the app icon PNGs/ICO from assets/app-icon/figcraft.svg (needs resvg)
//! - `wasm` check the core crates and web app for wasm32, and build the plugin SDK and examples //! - `wasm` check the core crates and web app for wasm32, and build the plugin SDK and examples
//! - `plugins` format-check, lint, test and build the plugin SDK and example plugins (`plugins/`, a //! - `plugins` format-check, lint, test and build the plugin SDK and example plugins (`plugins/`, a
//! workspace of its own) for wasm32: `<target>/plugins/dist/<example>/` holds each manifest and //! workspace of its own) for wasm32: `<target>/plugins/dist/<example>/` holds each manifest and