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

+20
View File
@@ -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
+23 -1
View File
@@ -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<Option<Args>, String> {
Binary file not shown.
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<clipPath id="clip-path-1">
<path d="M320 100 L704 100 C854 100 924 170 924 320 L924 704 C924 854 854 924 704 924 L320 924 C170 924 100 854 100 704 L100 320 C100 170 170 100 320 100 Z"/>
</clipPath>
<radialGradient id="radial-gradient-1" cx="0" cy="0" r="578" gradientTransform="matrix(1 0 0 0.5 512 720)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#4f8a82"/>
<stop offset="0.3" stop-color="#4f8a82" stop-opacity="0.9"/>
<stop offset="0.65" stop-color="#4f8a82" stop-opacity="0.35"/>
<stop offset="1" stop-color="#4f8a82" stop-opacity="0"/>
</radialGradient>
</defs>
<title>ModelCraft icon floor grid (Dark)</title>
<g id="Floor_grid" data-name="Floor grid" clip-path="url(#clip-path-1)">
<path id="floor_grid" data-name="floor grid" d="M154.62 100 L100 131.53 M869.38 100 L924 131.53 M368.82 100 L100 255.2 M655.18 100 L924 255.2 M583.01 100 L100 378.87 M440.99 100 L924 378.87 M797.21 100 L100 502.53 M226.79 100 L924 502.53 M924 150.47 L100 626.2 M100 150.47 L924 626.2 M924 274.13 L100 749.87 M100 274.13 L924 749.87 M924 397.8 L100 873.53 M100 397.8 L924 873.53 M924 521.47 L226.79 924 M100 521.47 L797.21 924 M924 645.13 L440.99 924 M100 645.13 L583.01 924 M924 768.8 L655.18 924 M100 768.8 L368.82 924 M924 892.47 L869.38 924 M100 892.47 L154.62 924" fill="none" stroke="url(#radial-gradient-1)" stroke-width="3" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<clipPath id="clip-path-1">
<path d="M320 100 L704 100 C854 100 924 170 924 320 L924 704 C924 854 854 924 704 924 L320 924 C170 924 100 854 100 704 L100 320 C100 170 170 100 320 100 Z"/>
</clipPath>
<radialGradient id="radial-gradient-1" cx="0" cy="0" r="578" gradientTransform="matrix(1 0 0 0.5 512 720)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#7fa8a2"/>
<stop offset="0.3" stop-color="#7fa8a2" stop-opacity="0.9"/>
<stop offset="0.65" stop-color="#7fa8a2" stop-opacity="0.35"/>
<stop offset="1" stop-color="#7fa8a2" stop-opacity="0"/>
</radialGradient>
</defs>
<title>ModelCraft icon floor grid</title>
<g id="Floor_grid" data-name="Floor grid" clip-path="url(#clip-path-1)">
<path id="floor_grid" data-name="floor grid" d="M154.62 100 L100 131.53 M869.38 100 L924 131.53 M368.82 100 L100 255.2 M655.18 100 L924 255.2 M583.01 100 L100 378.87 M440.99 100 L924 378.87 M797.21 100 L100 502.53 M226.79 100 L924 502.53 M924 150.47 L100 626.2 M100 150.47 L924 626.2 M924 274.13 L100 749.87 M100 274.13 L924 749.87 M924 397.8 L100 873.53 M100 397.8 L924 873.53 M924 521.47 L226.79 924 M100 521.47 L797.21 924 M924 645.13 L440.99 924 M100 645.13 L583.01 924 M924 768.8 L655.18 924 M100 768.8 L368.82 924 M924 892.47 L869.38 924 M100 892.47 L154.62 924" fill="none" stroke="url(#radial-gradient-1)" stroke-width="3" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="linear-gradient-1" x1="512" y1="141" x2="512" y2="376.622" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#86e3d3"/>
<stop offset="1" stop-color="#4cc4b1"/>
</linearGradient>
<linearGradient id="linear-gradient-2" x1="512" y1="457.259" x2="512" y2="755.042" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#86e3d3"/>
<stop offset="1" stop-color="#4cc4b1"/>
</linearGradient>
<linearGradient id="linear-gradient-3" x1="512" y1="209.598" x2="512" y2="624.284" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#20b39f"/>
<stop offset="1" stop-color="#118c7c"/>
</linearGradient>
<linearGradient id="linear-gradient-4" x1="512" y1="588.018" x2="512" y2="878.381" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#20b39f"/>
<stop offset="1" stop-color="#118c7c"/>
</linearGradient>
<linearGradient id="linear-gradient-5" x1="512" y1="335.738" x2="512" y2="878.381" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0f786d"/>
<stop offset="1" stop-color="#0a544e"/>
</linearGradient>
<linearGradient id="linear-gradient-6" x1="512" y1="558.616" x2="512" y2="658.304" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0b665e"/>
<stop offset="1" stop-color="#04302c"/>
</linearGradient>
</defs>
<title>ModelCraft icon part</title>
<g id="Part">
<path id="wall_top" data-name="wall top" d="M512 141 L825.3 321.88 L730.48 376.62 L417.19 195.74 Z" fill="url(#linear-gradient-1)"/>
<path id="base_top" data-name="base top" d="M401.19 457.26 L714.48 638.14 L512 755.04 L198.7 574.16 Z M521.64 643.71 C555.35 624.24 555.35 592.68 521.64 573.22 C487.92 553.75 433.26 553.75 399.55 573.22 C365.83 592.68 365.83 624.24 399.55 643.71 C433.26 663.17 487.92 663.17 521.64 643.71 Z" fill="url(#linear-gradient-2)"/>
<path id="wall_front" data-name="wall front" d="M409.19 209.6 L722.48 390.48 L722.48 624.28 L409.19 443.4 Z" fill="url(#linear-gradient-3)"/>
<path id="base_front" data-name="base front" d="M190.7 588.02 L504 768.9 L504 878.38 L190.7 697.5 Z" fill="url(#linear-gradient-4)"/>
<path id="end" d="M833.3 697.5 L520 878.38 L520 768.9 L738.48 642.76 L738.48 390.48 L833.3 335.74 Z" fill="url(#linear-gradient-5)"/>
<path id="bolt_hole" data-name="bolt hole" d="M521.64 643.71 C487.92 663.17 433.26 663.17 399.55 643.71 C365.83 624.24 365.83 592.68 399.55 573.22 C433.26 553.75 487.92 553.75 521.64 573.22 C555.35 592.68 555.35 624.24 521.64 643.71 Z" fill="url(#linear-gradient-6)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<clipPath id="clip-path-1">
<path d="M512 141 L825.3 321.88 L730.48 376.62 L417.19 195.74 Z M401.19 457.26 L714.48 638.14 L512 755.04 L198.7 574.16 Z M521.64 643.71 C555.35 624.24 555.35 592.68 521.64 573.22 C487.92 553.75 433.26 553.75 399.55 573.22 C365.83 592.68 365.83 624.24 399.55 643.71 C433.26 663.17 487.92 663.17 521.64 643.71 Z M409.19 209.6 L722.48 390.48 L722.48 624.28 L409.19 443.4 Z M190.7 588.02 L504 768.9 L504 878.38 L190.7 697.5 Z M833.3 697.5 L520 878.38 L520 768.9 L738.48 642.76 L738.48 390.48 L833.3 335.74 Z"/>
</clipPath>
</defs>
<title>ModelCraft icon mesh</title>
<g id="Mesh" clip-path="url(#clip-path-1)">
<path id="hexahedral_mesh" data-name="hexahedral mesh" d="M460.59 482.32 L242.11 608.46 M679.07 608.46 L460.59 734.6 M354.57 484.18 L405.97 513.86 M624.45 640 L675.86 669.67 M299.95 515.71 L351.35 545.39 M569.83 671.53 L621.24 701.21 M245.32 547.25 L296.73 576.92 M515.21 703.07 L566.62 732.75 M460.59 482.32 L460.59 230.04 M563.41 170.68 L460.59 230.04 M515.21 513.86 L515.21 261.57 M618.03 202.22 L515.21 261.57 M569.83 545.39 L569.83 293.11 M672.65 233.75 L569.83 293.11 M624.45 576.93 L624.45 324.65 M727.27 265.29 L624.45 324.65 M679.07 608.46 L679.07 356.18 M781.89 296.82 L679.07 356.18 M409.19 389.57 L730.48 575.07 M409.19 326.5 L730.48 512 M409.19 263.43 L730.48 448.93 M460.59 170.68 L781.89 356.18 M242.11 727.18 L242.11 608.46 M296.73 758.72 L296.73 640 M351.35 790.25 L351.35 671.53 M405.97 821.79 L405.97 703.07 M460.59 853.32 L460.59 734.6 M190.7 638.14 L512 823.64 M781.89 727.18 L781.89 356.18 M730.48 756.86 L730.48 638.14 M675.86 788.39 L675.86 669.67 M621.24 819.93 L621.24 701.21 M566.62 851.47 L566.62 732.75 M833.3 638.14 L512 823.64 M833.3 578.78 L730.48 638.14 M833.3 515.71 L730.48 575.07 M833.3 452.64 L730.48 512 M833.3 389.57 L730.48 448.93 M460.59 558.62 L460.59 482.32 M433.29 561.17 L405.97 513.86 M399.55 573.22 L351.35 545.39 M378.69 592.7 L296.73 576.92 M374.26 608.46 L242.11 608.46 M487.89 561.17 L515.21 513.86 M378.69 624.22 L296.73 640 M521.64 573.22 L569.83 545.39 M399.55 643.71 L351.35 671.53 M542.49 592.7 L624.45 576.92 M433.29 655.75 L405.97 703.06 M546.92 608.46 L679.07 608.46 M542.49 624.22 L624.45 640 M521.64 643.71 L569.83 671.53 M487.89 655.75 L515.21 703.07 M460.59 658.3 L460.59 734.6 M545.74 657.62 L540.09 660.8 L534.3 663.92 L528.33 666.99 L522.16 670.03 L515.76 673.05 L509.11 676.08 L502.15 679.15 L494.85 682.27 L487.14 685.51 L478.94 688.9 L470.14 692.52 L460.59 696.45 L451.05 692.52 L442.25 688.9 L434.05 685.51 L426.34 682.27 L419.03 679.15 L412.08 676.08 L405.42 673.05 L399.02 670.03 L392.86 666.99 L386.89 663.92 L381.09 660.8 L375.45 657.62 L369.94 654.36 L364.54 651.01 L359.22 647.57 L353.95 644.01 L348.72 640.31 L343.47 636.47 L338.16 632.45 L332.74 628.24 L327.14 623.79 L321.26 619.05 L315 613.97 L308.19 608.46 L315 602.95 L321.26 597.87 L327.14 593.13 L332.74 588.68 L338.16 584.47 L343.47 580.45 L348.72 576.61 L353.95 572.91 L359.22 569.35 L364.54 565.91 L369.94 562.56 L375.45 559.3 L381.09 556.12 L386.89 553 L392.86 549.93 L399.02 546.89 L405.42 543.87 L412.08 540.84 L419.03 537.77 L426.34 534.65 L434.05 531.41 L442.25 528.02 L451.05 524.4 L460.59 520.47 L470.14 524.4 L478.94 528.02 L487.14 531.41 L494.85 534.65 L502.15 537.77 L509.11 540.84 L515.76 543.87 L522.16 546.89 L528.33 549.93 L534.3 553 L540.09 556.12 L545.74 559.3 L551.25 562.56 L556.65 565.91 L561.97 569.35 L567.23 572.91 L572.47 576.61 L577.72 580.45 L583.02 584.47 L588.44 588.68 L594.05 593.13 L599.92 597.87 L606.19 602.95 L613 608.46 L606.19 613.97 L599.92 619.05 L594.05 623.79 L588.44 628.24 L583.02 632.45 L577.72 636.47 L572.47 640.31 L567.23 644.01 L561.97 647.57 L556.65 651.01 L551.25 654.36 Z" fill="none" stroke="#ffffff" stroke-width="4.5" stroke-linejoin="round" opacity="0.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

+116
View File
@@ -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"
}
}
+27
View File
@@ -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`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

+48
View File
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<filter id="filter-1" filterUnits="userSpaceOnUse" x="67" y="67" width="890" height="890" data-vc-blend="multiply" color-interpolation-filters="sRGB"><feGaussianBlur in="SourceAlpha" stdDeviation="7"/><feOffset dx="0" dy="10" result="shadow"/><feFlood flood-color="#1d3b38" flood-opacity="0.22"/><feComposite in2="shadow" operator="in" result="paint"/><feMerge><feMergeNode in="paint"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
<linearGradient id="linear-gradient-1" x1="512" y1="100" x2="512" y2="924" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fbfdfd"/>
<stop offset="1" stop-color="#dbe5e4"/>
</linearGradient>
<linearGradient id="linear-gradient-2" x1="512" y1="141" x2="512" y2="385.86" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#86e3d3"/>
<stop offset="1" stop-color="#4cc4b1"/>
</linearGradient>
<linearGradient id="linear-gradient-3" x1="512" y1="452.64" x2="512" y2="764.28" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#86e3d3"/>
<stop offset="1" stop-color="#4cc4b1"/>
</linearGradient>
<linearGradient id="linear-gradient-4" x1="512" y1="200.36" x2="512" y2="638.14" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#20b39f"/>
<stop offset="1" stop-color="#118c7c"/>
</linearGradient>
<linearGradient id="linear-gradient-5" x1="512" y1="578.78" x2="512" y2="883" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#20b39f"/>
<stop offset="1" stop-color="#118c7c"/>
</linearGradient>
<linearGradient id="linear-gradient-6" x1="512" y1="326.5" x2="512" y2="883" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0f786d"/>
<stop offset="1" stop-color="#0a544e"/>
</linearGradient>
<linearGradient id="linear-gradient-7" x1="512" y1="558.616" x2="512" y2="658.304" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0b665e"/>
<stop offset="1" stop-color="#04302c"/>
</linearGradient>
</defs>
<title>ModelCraft application icon, small sizes</title>
<g id="Backdrop">
<g filter="url(#filter-1)">
<path id="backdrop" d="M320 100 L704 100 C854 100 924 170 924 320 L924 704 C924 854 854 924 704 924 L320 924 C170 924 100 854 100 704 L100 320 C100 170 170 100 320 100 Z" fill="url(#linear-gradient-1)"/>
</g>
</g>
<g id="Part">
<path id="wall_top" data-name="wall top" d="M512 141 L833.3 326.5 L730.48 385.86 L409.19 200.36 Z" fill="url(#linear-gradient-2)"/>
<path id="base_top" data-name="base top" d="M409.19 452.64 L730.48 638.14 L512 764.28 L190.7 578.78 Z M521.64 643.71 C555.35 624.24 555.35 592.68 521.64 573.22 C487.92 553.75 433.26 553.75 399.55 573.22 C365.83 592.68 365.83 624.24 399.55 643.71 C433.26 663.17 487.92 663.17 521.64 643.71 Z" fill="url(#linear-gradient-3)"/>
<path id="wall_front" data-name="wall front" d="M409.19 200.36 L730.48 385.86 L730.48 638.14 L409.19 452.64 Z" fill="url(#linear-gradient-4)"/>
<path id="base_front" data-name="base front" d="M190.7 578.78 L512 764.28 L512 883 L190.7 697.5 Z" fill="url(#linear-gradient-5)"/>
<path id="end" d="M833.3 697.5 L512 883 L512 764.28 L730.48 638.14 L730.48 385.86 L833.3 326.5 Z" fill="url(#linear-gradient-6)"/>
<path id="bolt_hole" data-name="bolt hole" d="M521.64 643.71 C487.92 663.17 433.26 663.17 399.55 643.71 C365.83 624.24 365.83 592.68 399.55 573.22 C433.26 553.75 487.92 553.75 521.64 573.22 C555.35 592.68 555.35 624.24 521.64 643.71 Z" fill="url(#linear-gradient-7)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

+66
View File
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<filter id="filter-1" filterUnits="userSpaceOnUse" x="67" y="67" width="890" height="890" data-vc-blend="multiply" color-interpolation-filters="sRGB"><feGaussianBlur in="SourceAlpha" stdDeviation="7"/><feOffset dx="0" dy="10" result="shadow"/><feFlood flood-color="#1d3b38" flood-opacity="0.22"/><feComposite in2="shadow" operator="in" result="paint"/><feMerge><feMergeNode in="paint"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
<linearGradient id="linear-gradient-1" x1="512" y1="100" x2="512" y2="924" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fbfdfd"/>
<stop offset="1" stop-color="#dbe5e4"/>
</linearGradient>
<clipPath id="clip-path-1">
<path d="M320 100 L704 100 C854 100 924 170 924 320 L924 704 C924 854 854 924 704 924 L320 924 C170 924 100 854 100 704 L100 320 C100 170 170 100 320 100 Z"/>
</clipPath>
<radialGradient id="radial-gradient-1" cx="0" cy="0" r="578" gradientTransform="matrix(1 0 0 0.5 512 720)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#7fa8a2"/>
<stop offset="0.3" stop-color="#7fa8a2" stop-opacity="0.9"/>
<stop offset="0.65" stop-color="#7fa8a2" stop-opacity="0.35"/>
<stop offset="1" stop-color="#7fa8a2" stop-opacity="0"/>
</radialGradient>
<linearGradient id="linear-gradient-2" x1="512" y1="141" x2="512" y2="376.622" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#86e3d3"/>
<stop offset="1" stop-color="#4cc4b1"/>
</linearGradient>
<linearGradient id="linear-gradient-3" x1="512" y1="457.259" x2="512" y2="755.042" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#86e3d3"/>
<stop offset="1" stop-color="#4cc4b1"/>
</linearGradient>
<linearGradient id="linear-gradient-4" x1="512" y1="209.598" x2="512" y2="624.284" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#20b39f"/>
<stop offset="1" stop-color="#118c7c"/>
</linearGradient>
<linearGradient id="linear-gradient-5" x1="512" y1="588.018" x2="512" y2="878.381" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#20b39f"/>
<stop offset="1" stop-color="#118c7c"/>
</linearGradient>
<linearGradient id="linear-gradient-6" x1="512" y1="335.738" x2="512" y2="878.381" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0f786d"/>
<stop offset="1" stop-color="#0a544e"/>
</linearGradient>
<linearGradient id="linear-gradient-7" x1="512" y1="558.616" x2="512" y2="658.304" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0b665e"/>
<stop offset="1" stop-color="#04302c"/>
</linearGradient>
<clipPath id="clip-path-2">
<path d="M512 141 L825.3 321.88 L730.48 376.62 L417.19 195.74 Z M401.19 457.26 L714.48 638.14 L512 755.04 L198.7 574.16 Z M521.64 643.71 C555.35 624.24 555.35 592.68 521.64 573.22 C487.92 553.75 433.26 553.75 399.55 573.22 C365.83 592.68 365.83 624.24 399.55 643.71 C433.26 663.17 487.92 663.17 521.64 643.71 Z M409.19 209.6 L722.48 390.48 L722.48 624.28 L409.19 443.4 Z M190.7 588.02 L504 768.9 L504 878.38 L190.7 697.5 Z M833.3 697.5 L520 878.38 L520 768.9 L738.48 642.76 L738.48 390.48 L833.3 335.74 Z"/>
</clipPath>
</defs>
<title>ModelCraft application icon</title>
<g id="Backdrop">
<g filter="url(#filter-1)">
<path id="backdrop" d="M320 100 L704 100 C854 100 924 170 924 320 L924 704 C924 854 854 924 704 924 L320 924 C170 924 100 854 100 704 L100 320 C100 170 170 100 320 100 Z" fill="url(#linear-gradient-1)"/>
</g>
</g>
<g id="Floor_grid" data-name="Floor grid" clip-path="url(#clip-path-1)">
<path id="floor_grid" data-name="floor grid" d="M154.62 100 L100 131.53 M869.38 100 L924 131.53 M368.82 100 L100 255.2 M655.18 100 L924 255.2 M583.01 100 L100 378.87 M440.99 100 L924 378.87 M797.21 100 L100 502.53 M226.79 100 L924 502.53 M924 150.47 L100 626.2 M100 150.47 L924 626.2 M924 274.13 L100 749.87 M100 274.13 L924 749.87 M924 397.8 L100 873.53 M100 397.8 L924 873.53 M924 521.47 L226.79 924 M100 521.47 L797.21 924 M924 645.13 L440.99 924 M100 645.13 L583.01 924 M924 768.8 L655.18 924 M100 768.8 L368.82 924 M924 892.47 L869.38 924 M100 892.47 L154.62 924" fill="none" stroke="url(#radial-gradient-1)" stroke-width="3" stroke-linejoin="round"/>
</g>
<g id="Part">
<path id="wall_top" data-name="wall top" d="M512 141 L825.3 321.88 L730.48 376.62 L417.19 195.74 Z" fill="url(#linear-gradient-2)"/>
<path id="base_top" data-name="base top" d="M401.19 457.26 L714.48 638.14 L512 755.04 L198.7 574.16 Z M521.64 643.71 C555.35 624.24 555.35 592.68 521.64 573.22 C487.92 553.75 433.26 553.75 399.55 573.22 C365.83 592.68 365.83 624.24 399.55 643.71 C433.26 663.17 487.92 663.17 521.64 643.71 Z" fill="url(#linear-gradient-3)"/>
<path id="wall_front" data-name="wall front" d="M409.19 209.6 L722.48 390.48 L722.48 624.28 L409.19 443.4 Z" fill="url(#linear-gradient-4)"/>
<path id="base_front" data-name="base front" d="M190.7 588.02 L504 768.9 L504 878.38 L190.7 697.5 Z" fill="url(#linear-gradient-5)"/>
<path id="end" d="M833.3 697.5 L520 878.38 L520 768.9 L738.48 642.76 L738.48 390.48 L833.3 335.74 Z" fill="url(#linear-gradient-6)"/>
<path id="bolt_hole" data-name="bolt hole" d="M521.64 643.71 C487.92 663.17 433.26 663.17 399.55 643.71 C365.83 624.24 365.83 592.68 399.55 573.22 C433.26 553.75 487.92 553.75 521.64 573.22 C555.35 592.68 555.35 624.24 521.64 643.71 Z" fill="url(#linear-gradient-7)"/>
</g>
<g id="Mesh" clip-path="url(#clip-path-2)">
<path id="hexahedral_mesh" data-name="hexahedral mesh" d="M460.59 482.32 L242.11 608.46 M679.07 608.46 L460.59 734.6 M354.57 484.18 L405.97 513.86 M624.45 640 L675.86 669.67 M299.95 515.71 L351.35 545.39 M569.83 671.53 L621.24 701.21 M245.32 547.25 L296.73 576.92 M515.21 703.07 L566.62 732.75 M460.59 482.32 L460.59 230.04 M563.41 170.68 L460.59 230.04 M515.21 513.86 L515.21 261.57 M618.03 202.22 L515.21 261.57 M569.83 545.39 L569.83 293.11 M672.65 233.75 L569.83 293.11 M624.45 576.93 L624.45 324.65 M727.27 265.29 L624.45 324.65 M679.07 608.46 L679.07 356.18 M781.89 296.82 L679.07 356.18 M409.19 389.57 L730.48 575.07 M409.19 326.5 L730.48 512 M409.19 263.43 L730.48 448.93 M460.59 170.68 L781.89 356.18 M242.11 727.18 L242.11 608.46 M296.73 758.72 L296.73 640 M351.35 790.25 L351.35 671.53 M405.97 821.79 L405.97 703.07 M460.59 853.32 L460.59 734.6 M190.7 638.14 L512 823.64 M781.89 727.18 L781.89 356.18 M730.48 756.86 L730.48 638.14 M675.86 788.39 L675.86 669.67 M621.24 819.93 L621.24 701.21 M566.62 851.47 L566.62 732.75 M833.3 638.14 L512 823.64 M833.3 578.78 L730.48 638.14 M833.3 515.71 L730.48 575.07 M833.3 452.64 L730.48 512 M833.3 389.57 L730.48 448.93 M460.59 558.62 L460.59 482.32 M433.29 561.17 L405.97 513.86 M399.55 573.22 L351.35 545.39 M378.69 592.7 L296.73 576.92 M374.26 608.46 L242.11 608.46 M487.89 561.17 L515.21 513.86 M378.69 624.22 L296.73 640 M521.64 573.22 L569.83 545.39 M399.55 643.71 L351.35 671.53 M542.49 592.7 L624.45 576.92 M433.29 655.75 L405.97 703.06 M546.92 608.46 L679.07 608.46 M542.49 624.22 L624.45 640 M521.64 643.71 L569.83 671.53 M487.89 655.75 L515.21 703.07 M460.59 658.3 L460.59 734.6 M545.74 657.62 L540.09 660.8 L534.3 663.92 L528.33 666.99 L522.16 670.03 L515.76 673.05 L509.11 676.08 L502.15 679.15 L494.85 682.27 L487.14 685.51 L478.94 688.9 L470.14 692.52 L460.59 696.45 L451.05 692.52 L442.25 688.9 L434.05 685.51 L426.34 682.27 L419.03 679.15 L412.08 676.08 L405.42 673.05 L399.02 670.03 L392.86 666.99 L386.89 663.92 L381.09 660.8 L375.45 657.62 L369.94 654.36 L364.54 651.01 L359.22 647.57 L353.95 644.01 L348.72 640.31 L343.47 636.47 L338.16 632.45 L332.74 628.24 L327.14 623.79 L321.26 619.05 L315 613.97 L308.19 608.46 L315 602.95 L321.26 597.87 L327.14 593.13 L332.74 588.68 L338.16 584.47 L343.47 580.45 L348.72 576.61 L353.95 572.91 L359.22 569.35 L364.54 565.91 L369.94 562.56 L375.45 559.3 L381.09 556.12 L386.89 553 L392.86 549.93 L399.02 546.89 L405.42 543.87 L412.08 540.84 L419.03 537.77 L426.34 534.65 L434.05 531.41 L442.25 528.02 L451.05 524.4 L460.59 520.47 L470.14 524.4 L478.94 528.02 L487.14 531.41 L494.85 534.65 L502.15 537.77 L509.11 540.84 L515.76 543.87 L522.16 546.89 L528.33 549.93 L534.3 553 L540.09 556.12 L545.74 559.3 L551.25 562.56 L556.65 565.91 L561.97 569.35 L567.23 572.91 L572.47 576.61 L577.72 580.45 L583.02 584.47 L588.44 588.68 L594.05 593.13 L599.92 597.87 L606.19 602.95 L613 608.46 L606.19 613.97 L599.92 619.05 L594.05 623.79 L588.44 628.24 L583.02 632.45 L577.72 636.47 L572.47 640.31 L567.23 644.01 L561.97 647.57 L556.65 651.01 L551.25 654.36 Z" fill="none" stroke="#ffffff" stroke-width="4.5" stroke-linejoin="round" opacity="0.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+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..]));