craft-stencilcraft-document: share the native document model

This commit is contained in:
Dylan M. Taylor committed 2026-11-13 03:46:47 -05:00
1 parent 370f6714ed
commit 166d0d2127
10 files changed
+1986 -7

No files matched your search

Generated
+13
View File
@@ -2198,6 +2198,19 @@ dependencies = [
"zip",
]
[[package]]
name = "craft-stencilcraft-document"
version = "0.1.0"
dependencies = [
"craft-atomic",
"craft-stencilcraft-format",
"craft-svg",
"kurbo 0.13.1",
"serde",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "craft-stencilcraft-format"
version = "0.1.0"
+1
View File
@@ -112,6 +112,7 @@ craft-settings = { path = "crates/settings" }
craft-silhouette-studio = { path = "crates/silhouette-studio" }
craft-svg = { path = "crates/svg" }
craft-stencilcraft-format = { path = "crates/stencilcraft-format" }
craft-stencilcraft-document = { path = "crates/stencilcraft-document" }
craft-svg-paths = { path = "crates/svg-paths" }
craft-stream-codecs = { path = "crates/stream-codecs" }
craft-text = { path = "crates/text" }
+2 -1
View File
@@ -325,7 +325,8 @@ See the [release notes](CHANGELOG.md) for included functionality and known limit
- [`craft-sheet`](crates/sheet/README.md): spreadsheet vocabulary shared by `craft-xls`, `craft-xlsx` and `craft-ods`: cell positions and A1 text, cell values and error values, sheet-name quoting, Excel's built-in number formats and indexed colour palette. Not a file format; no dependencies.
- [`craft-sf2`](crates/sf2/README.md): SoundFont 2 (`.sf2`) reader: the sample pool and, per preset, the key and velocity regions a note sounds, with tuning, loop points, pan, level and volume envelope resolved from the preset and instrument zones. Bounded on hostile input. From LoopCraft.
- [`craft-silhouette-studio`](crates/silhouette-studio/src/lib.rs): bounded, fail-closed reader for Silhouette Studio `.studio` and `.studio3` documents: outlines, line colours and page size in millimetres. Protected designs are refused.
- [`craft-stencilcraft-format`](crates/stencilcraft-format/README.md): the versioned `.stencilcraft` JSON wire schema and envelope: required members, version 1 defaults, optional fields, and header checks. The app retains its document model and domain validation.
- [`craft-stencilcraft-format`](crates/stencilcraft-format/README.md): the versioned `.stencilcraft` JSON wire schema and envelope: required members, version 1 defaults, optional fields, and header checks.
- `craft-stencilcraft-document`: StencilCraft's typed document model, geometry helpers, validation, and native `.stencilcraft` codec.
- [`craft-sketch`](crates/sketch/README.md): clean-room reader for the `.sketch` container (Sketch 48 and newer): the ZIP archive, the version cut, `document.json`, the pages in document order and images, as the JSON the file holds. Bounded on hostile input. Carved from FigCraft.
- [`craft-svg`](crates/svg/src/lib.rs): SVG. `sniff` recognises SVG and SVGZ data; `svgz` unpacks
SVGZ within a byte limit, decodes UTF-8, UTF-16 and ISO 8859-1, and compresses; `guard` bounds
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "craft-stencilcraft-document"
description = "StencilCraft document model, validation, geometry, and native file codec"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
craft-atomic = { workspace = true }
craft-stencilcraft-format = { workspace = true }
craft-svg = { workspace = true }
kurbo = { workspace = true, features = ["serde"] }
serde = { workspace = true, features = ["rc"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
+9
View File
@@ -0,0 +1,9 @@
# craft-stencilcraft-document
Shared StencilCraft document model, validation, geometry helpers, and native file codec.
Applications re-export these model types rather than defining a second set of serializable
document types. The `.stencilcraft` header and typed wire schema are provided by
`craft-stencilcraft-format`.
The crate's `Document::load_json` and `Document::save_json` APIs read and write the versioned
native format. File path I/O helpers preserve application paths in errors and use atomic writes.
+580
View File
@@ -0,0 +1,580 @@
//! Canvas model beyond shapes: pages (artboards), page presets, ruler guides, grid and
//! snap settings, document metadata and groups. Everything here is plain data stored in
//! the `.stencilcraft` file; behaviour lives in the engine.
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::{Board, DocError, Document, Shape};
/// Most pages a document may hold (the main board included).
pub const MAX_PAGES: usize = 100;
/// Most ruler guides a document may hold.
pub const MAX_GUIDES: usize = 1000;
/// Most groups a document may hold.
pub const MAX_GROUPS: usize = 100_000;
/// Deepest group nesting accepted.
pub const MAX_GROUP_DEPTH: usize = 64;
/// Longest title, page/layer/group name, colour or mat label, in bytes.
pub const MAX_NAME_BYTES: usize = 1024;
/// Longest notes text, in bytes.
pub const MAX_NOTES_BYTES: usize = 64 * 1024;
/// Largest stored thumbnail, in bytes.
pub const MAX_THUMBNAIL_BYTES: usize = 256 * 1024;
const INCH_MM: f64 = 25.4;
/// Labels that describe a page beyond its size: the mat it sits on, the media loaded and
/// the page colour shown on the canvas. All optional; empty means "not set".
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct PageSetup {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mat: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub media: Option<String>,
/// Page colour, `#rrggbb`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
/// Cut mirrored (heat-transfer material is cut face down).
#[serde(default, skip_serializing_if = "crate::is_false")]
pub mirror: bool,
/// The page is printed with registration marks for Print & Cut.
#[serde(default, skip_serializing_if = "crate::is_false")]
pub registration_marks: bool,
}
impl PageSetup {
pub fn is_empty(&self) -> bool {
self == &PageSetup::default()
}
}
/// An extra page (artboard) placed on the document plane. The main board is page 0 at the
/// origin; extra pages sit beside it, and a shape belongs to the page under its centre.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Page {
pub x_mm: f64,
pub y_mm: f64,
pub width_mm: f64,
pub height_mm: f64,
#[serde(flatten)]
pub setup: PageSetup,
}
impl Page {
pub fn rect(&self) -> kurbo::Rect {
kurbo::Rect::new(self.x_mm, self.y_mm, self.x_mm + self.width_mm, self.y_mm + self.height_mm)
}
}
/// Common page and mat sizes. Sizes are portrait (width ≤ height); see [`Orientation`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PagePreset {
#[serde(rename = "letter")]
Letter,
#[serde(rename = "a4")]
A4,
#[serde(rename = "mat-12x12")]
Mat12x12,
#[serde(rename = "mat-12x24")]
Mat12x24,
#[serde(rename = "joy-mat-4.5x6.5")]
JoyMat4_5x6_5,
#[serde(rename = "joy-mat-4.5x12")]
JoyMat4_5x12,
#[serde(rename = "joy-xtra-mat-8.5x12")]
JoyXtraMat8_5x12,
/// Width and height are given explicitly.
#[serde(rename = "custom")]
Custom,
}
impl PagePreset {
/// Portrait size in millimetres; `None` for `Custom`.
pub fn size_mm(self) -> Option<(f64, f64)> {
let inches = |w: f64, h: f64| Some((w * INCH_MM, h * INCH_MM));
match self {
PagePreset::Letter => inches(8.5, 11.0),
PagePreset::A4 => Some((210.0, 297.0)),
PagePreset::Mat12x12 => inches(12.0, 12.0),
PagePreset::Mat12x24 => inches(12.0, 24.0),
PagePreset::JoyMat4_5x6_5 => inches(4.5, 6.5),
PagePreset::JoyMat4_5x12 => inches(4.5, 12.0),
PagePreset::JoyXtraMat8_5x12 => inches(8.5, 12.0),
PagePreset::Custom => None,
}
}
/// The mat label a mat preset implies (`None` for paper sizes and custom).
pub fn mat_label(self) -> Option<&'static str> {
match self {
PagePreset::Mat12x12 => Some("12x12"),
PagePreset::Mat12x24 => Some("12x24"),
PagePreset::JoyMat4_5x6_5 => Some("joy-4.5x6.5"),
PagePreset::JoyMat4_5x12 => Some("joy-4.5x12"),
PagePreset::JoyXtraMat8_5x12 => Some("joy-xtra-8.5x12"),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Orientation {
Portrait,
Landscape,
}
impl Orientation {
/// `(w, h)` ordered for this orientation.
pub fn apply(self, (w, h): (f64, f64)) -> (f64, f64) {
let (short, long) = if w <= h { (w, h) } else { (h, w) };
match self {
Orientation::Portrait => (short, long),
Orientation::Landscape => (long, short),
}
}
}
/// Which way a ruler guide runs. A horizontal guide is the line `y = position`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum GuideAxis {
Horizontal,
Vertical,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Guide {
pub axis: GuideAxis,
pub position_mm: f64,
#[serde(default, skip_serializing_if = "crate::is_false")]
pub locked: bool,
}
/// The background grid: major lines every `spacing_mm`, split into `subdivisions`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Grid {
pub spacing_mm: f64,
pub subdivisions: u32,
#[serde(default)]
pub visible: bool,
}
impl Default for Grid {
fn default() -> Self {
Self { spacing_mm: 10.0, subdivisions: 1, visible: false }
}
}
impl Grid {
/// Distance between snap lines: spacing over subdivisions.
pub fn step_mm(&self) -> f64 {
self.spacing_mm / f64::from(self.subdivisions.max(1))
}
}
/// Which targets the snapping service considers, and how close counts.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SnapSettings {
#[serde(default)]
pub grid: bool,
#[serde(default = "crate::yes")]
pub guides: bool,
/// Edges and centres of other shapes and of pages.
#[serde(default = "crate::yes")]
pub objects: bool,
/// Smart guides: equal spacing between neighbours.
#[serde(default = "crate::yes")]
pub spacing: bool,
/// Snap to point: shapes' anchor points (Illustrator's View › Snap to Point).
#[serde(default)]
pub points: bool,
#[serde(default = "default_tolerance")]
pub tolerance_mm: f64,
}
fn default_tolerance() -> f64 {
2.0
}
impl Default for SnapSettings {
fn default() -> Self {
Self { grid: false, guides: true, objects: true, spacing: true, points: false, tolerance_mm: default_tolerance() }
}
}
/// Title, notes and a small preview, stored in the file for file browsers and recovery.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct Metadata {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub title: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub notes: String,
/// A small standalone SVG of the design (see `Document::render_thumbnail`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thumbnail_svg: Option<String>,
}
impl Metadata {
pub fn is_empty(&self) -> bool {
self == &Metadata::default()
}
}
/// A member of a group: a shape or another group.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum GroupChild {
Shape(u64),
Group(u64),
}
/// Shapes and groups that move and select together. Grouping never changes how shapes
/// cut, nor their stacking order, which stays with their layers.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Group {
pub id: u64,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub name: String,
pub children: Vec<GroupChild>,
}
/// `#rrggbb` (or `#rgb`) to RGB; `None` for anything else.
pub fn parse_hex_color(text: &str) -> Option<[u8; 3]> {
let hex = text.strip_prefix('#')?;
let digit = |i: usize| u8::from_str_radix(hex.get(i..i + 1)?, 16).ok();
match hex.len() {
6 => {
let byte = |i: usize| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok();
Some([byte(0)?, byte(2)?, byte(4)?])
}
3 => Some([digit(0)? * 17, digit(1)? * 17, digit(2)? * 17]),
_ => None,
}
}
/// Canonical lowercase `#rrggbb`.
pub fn format_hex_color([r, g, b]: [u8; 3]) -> String {
format!("#{r:02x}{g:02x}{b:02x}")
}
/// Euclidean RGB distance (0 to about 441.7).
pub fn color_distance(a: [u8; 3], b: [u8; 3]) -> f64 {
a.iter().zip(b).map(|(x, y)| (f64::from(*x) - f64::from(y)).powi(2)).sum::<f64>().sqrt()
}
fn check_name(text: &str, what: &str) -> Result<(), DocError> {
if text.len() > MAX_NAME_BYTES {
return Err(DocError::Model(format!("{what}: longer than {MAX_NAME_BYTES} bytes")));
}
Ok(())
}
fn check_color(color: Option<&str>, what: &str) -> Result<(), DocError> {
match color {
Some(c) if parse_hex_color(c).is_none() => {
Err(DocError::Model(format!("{what}: `{}` is not a #rrggbb colour", c.chars().take(16).collect::<String>())))
}
_ => Ok(()),
}
}
fn check_setup(setup: &PageSetup, what: &str) -> Result<(), DocError> {
check_name(&setup.name, what)?;
for label in [&setup.mat, &setup.media].into_iter().flatten() {
check_name(label, what)?;
}
check_color(setup.color.as_deref(), what)
}
impl Document {
/// Pages, guides, grid, snapping, colours, metadata and groups: finite, within caps,
/// and groups form a forest (unknown shape members are allowed: they are pruned).
pub(crate) fn validate_canvas(&self) -> Result<(), DocError> {
let bad = |message: String| Err(DocError::Model(message));
if self.pages.len() >= MAX_PAGES {
return bad(format!("pages: more than {MAX_PAGES}"));
}
check_setup(&self.board_setup, "board")?;
for (i, page) in self.pages.iter().enumerate() {
let finite = [page.x_mm, page.y_mm, page.width_mm, page.height_mm].iter().all(|v| v.is_finite() && v.abs() < 1e7);
if !finite || page.width_mm <= 0.0 || page.height_mm <= 0.0 {
return bad(format!("page {}: position and size must be finite, size positive", i + 1));
}
check_setup(&page.setup, "page")?;
}
if self.guides.len() > MAX_GUIDES {
return bad(format!("guides: more than {MAX_GUIDES}"));
}
if self.guides.iter().any(|g| !g.position_mm.is_finite()) {
return bad("guides: positions must be finite".to_string());
}
if !(self.grid.spacing_mm.is_finite() && (0.01..=10_000.0).contains(&self.grid.spacing_mm) && (1..=100).contains(&self.grid.subdivisions)) {
return bad("grid: spacing must be 0.01-10000 mm, subdivisions 1-100".to_string());
}
if !(0.0..=1000.0).contains(&self.snap.tolerance_mm) {
return bad("snap: tolerance must be 0-1000 mm".to_string());
}
for layer in &self.layers {
check_name(&layer.name, "layer name")?;
check_color(layer.color.as_deref(), "layer colour")?;
}
check_name(&self.meta.title, "title")?;
if self.meta.notes.len() > MAX_NOTES_BYTES {
return bad(format!("notes: longer than {MAX_NOTES_BYTES} bytes"));
}
if self.meta.thumbnail_svg.as_ref().is_some_and(|t| t.len() > MAX_THUMBNAIL_BYTES) {
return bad(format!("thumbnail: larger than {MAX_THUMBNAIL_BYTES} bytes"));
}
self.validate_groups()
}
fn validate_groups(&self) -> Result<(), DocError> {
let bad = |message: String| Err(DocError::Model(message));
if self.groups.len() > MAX_GROUPS {
return bad(format!("groups: more than {MAX_GROUPS}"));
}
let ids: BTreeSet<u64> = self.groups.iter().map(|g| g.id).collect();
if ids.len() != self.groups.len() {
return bad("groups: ids must be unique".to_string());
}
let mut parents: BTreeMap<GroupChild, u64> = BTreeMap::new();
for group in &self.groups {
check_name(&group.name, "group name")?;
for child in &group.children {
if let GroupChild::Group(id) = child
&& !ids.contains(id)
{
return bad(format!("group {}: member group {id} does not exist", group.id));
}
if parents.insert(*child, group.id).is_some() {
return bad(format!("groups: {child:?} belongs to two groups"));
}
}
}
// With one parent each, a cycle is a walk up that never reaches a root.
for id in &ids {
let mut current = GroupChild::Group(*id);
let mut depth = 0;
while let Some(parent) = parents.get(&current) {
depth += 1;
if depth > MAX_GROUP_DEPTH {
return bad(format!("groups: nesting deeper than {MAX_GROUP_DEPTH}, or a cycle, at group {id}"));
}
current = GroupChild::Group(*parent);
}
}
Ok(())
}
/// Whether shape `id` may not be edited: the shape or its layer is locked.
pub fn shape_locked(&self, id: u64) -> bool {
self.layers.iter().any(|layer| layer.shapes.iter().any(|s| s.id == id && (s.locked || layer.locked)))
}
/// Index of the layer holding shape `id`.
pub fn layer_of_shape(&self, id: u64) -> Option<usize> {
self.layers.iter().position(|layer| layer.shapes.iter().any(|s| s.id == id))
}
pub fn group(&self, id: u64) -> Option<&Group> {
self.groups.iter().find(|g| g.id == id)
}
/// The group directly holding `child`.
pub fn group_parent(&self, child: GroupChild) -> Option<u64> {
self.groups.iter().find(|g| g.children.contains(&child)).map(|g| g.id)
}
/// Each group member's direct parent group; build once for many [`group_chain_in`] calls.
pub fn group_parent_map(&self) -> BTreeMap<GroupChild, u64> {
self.groups.iter().flat_map(|g| g.children.iter().map(move |c| (*c, g.id))).collect()
}
/// Groups holding `child`, outermost first.
pub fn group_chain(&self, child: GroupChild) -> Vec<u64> {
group_chain_in(&self.group_parent_map(), child)
}
/// Every existing shape inside group `id`, nested groups included, in document order.
pub fn group_shapes(&self, id: u64) -> Vec<u64> {
let mut members = BTreeSet::new();
let mut seen = BTreeSet::new();
let mut stack = vec![id];
while let Some(gid) = stack.pop() {
if !seen.insert(gid) {
continue;
}
for child in self.group(gid).map_or(&[][..], |g| &g.children) {
match child {
GroupChild::Shape(s) => {
members.insert(*s);
}
GroupChild::Group(g) => stack.push(*g),
}
}
}
self.shapes().map(|(_, s)| s.id).filter(|s| members.contains(s)).collect()
}
/// The groups without members that no longer exist; groups left empty go too.
pub fn pruned_groups(&self) -> Vec<Group> {
let shapes: BTreeSet<u64> = self.shapes().map(|(_, s)| s.id).collect();
let mut groups = self.groups.clone();
loop {
let before: usize = groups.iter().map(|g| g.children.len() + 1).sum();
let live: BTreeSet<u64> = groups.iter().map(|g| g.id).collect();
for group in &mut groups {
group.children.retain(|child| match child {
GroupChild::Shape(id) => shapes.contains(id),
GroupChild::Group(id) => live.contains(id),
});
}
groups.retain(|g| !g.children.is_empty());
let after: usize = groups.iter().map(|g| g.children.len() + 1).sum();
if after == before {
return groups;
}
}
}
/// A free group id.
pub fn next_group_id(&self) -> u64 {
self.groups.iter().map(|g| g.id).max().map_or(1, |m| m.saturating_add(1))
}
/// All pages: the main board (index 0, at the origin) then the extra pages.
pub fn all_pages(&self) -> Vec<Page> {
let mut pages =
vec![Page { x_mm: 0.0, y_mm: 0.0, width_mm: self.board.width_mm, height_mm: self.board.height_mm, setup: self.board_setup.clone() }];
pages.extend(self.pages.iter().cloned());
pages
}
/// The page a shape belongs to: the first extra page under its bounds centre, else 0.
pub fn page_of_shape(&self, shape: &Shape) -> usize {
let Some(center) = shape.bounds().map(|b| b.center()) else {
return 0;
};
self.pages.iter().position(|p| p.rect().contains(center)).map_or(0, |i| i + 1)
}
/// One page as its own document, ready to export or cut: the board is the page, and
/// the shapes on that page are moved to its origin. `None` for an unknown page.
pub fn page_document(&self, index: usize) -> Option<Document> {
let page = self.all_pages().get(index)?.clone();
let mut out = self.clone();
out.board = Board { width_mm: page.width_mm, height_mm: page.height_mm };
out.board_setup = page.setup.clone();
out.pages.clear();
out.guides.clear();
let shift = kurbo::Affine::translate((-page.x_mm, -page.y_mm));
for layer in &mut out.layers {
layer.shapes.retain(|s| self.page_of_shape(s) == index);
for shape in &mut layer.shapes {
shape.path.apply_affine(shift);
}
}
out.groups = out.pruned_groups();
Some(out)
}
/// A small standalone SVG preview of the board's output shapes, at most `max_bytes`
/// long: shapes past the budget are left out. Coordinates keep two decimals.
pub fn render_thumbnail(&self, max_bytes: usize) -> String {
let (w, h) = (self.board.width_mm, self.board.height_mm);
// Documents are y-up; flip into SVG's y-down space. The shared SVG writer owns markup,
// escaping, numeric formatting, and path-data formatting.
let mut svg = thumbnail_writer(w, h);
let mut empty = svg.clone();
empty.line(1);
empty.close();
empty.line(0);
empty.close();
let empty_len = empty.finish().len();
if empty_len > max_bytes {
return String::new();
}
let mut remaining = max_bytes - empty_len;
'layers: for layer in self.layers.iter().filter(|l| l.is_output()) {
for shape in layer.shapes.iter().filter(|s| !s.hidden) {
let mut element = craft_svg::write::SvgWriter::fragment();
element.line(2);
element.tag("path").attr("d", craft_svg::write::path_data_fixed(&shape.path, 2)).empty();
let element = element.finish();
if element.len() > remaining {
break 'layers;
}
remaining -= element.len();
svg.raw(&element);
}
}
svg.line(1);
svg.close();
svg.line(0);
svg.close();
svg.finish()
}
}
fn thumbnail_writer(width_mm: f64, height_mm: f64) -> craft_svg::write::SvgWriter {
use craft_svg::write::{SvgWriter, num};
let mut writer = SvgWriter::fragment();
writer.root().attr("viewBox", format!("0 0 {} {}", num(width_mm, 2), num(height_mm, 2))).open();
writer
.tag("g")
.attr("transform", format!("matrix(1 0 0 -1 0 {})", num(height_mm, 2)))
.attr("fill", "none")
.attr("stroke", "#000")
.attr("stroke-width", num((width_mm.max(height_mm) / 200.0).max(0.01), 2))
.open();
writer
}
/// Groups holding `child` according to `parents` ([`Document::group_parent_map`]),
/// outermost first. Stops at a cycle or past the depth cap.
pub fn group_chain_in(parents: &BTreeMap<GroupChild, u64>, child: GroupChild) -> Vec<u64> {
let mut chain = Vec::new();
let mut current = child;
while let Some(parent) = parents.get(&current) {
if chain.contains(parent) || chain.len() > MAX_GROUP_DEPTH {
break;
}
chain.push(*parent);
current = GroupChild::Group(*parent);
}
chain.reverse();
chain
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn presets_and_orientation() {
let (w, h) = PagePreset::Letter.size_mm().unwrap();
assert!((w - 215.9).abs() < 1e-9 && (h - 279.4).abs() < 1e-9);
assert_eq!(Orientation::Landscape.apply((210.0, 297.0)), (297.0, 210.0));
assert_eq!(Orientation::Portrait.apply((297.0, 210.0)), (210.0, 297.0));
assert!(PagePreset::Custom.size_mm().is_none());
let json = serde_json::to_string(&PagePreset::JoyMat4_5x6_5).unwrap();
assert_eq!(json, "\"joy-mat-4.5x6.5\"");
}
#[test]
fn hex_colors() {
assert_eq!(parse_hex_color("#FF0080"), Some([255, 0, 128]));
assert_eq!(parse_hex_color("#f08"), Some([255, 0, 136]));
assert_eq!(parse_hex_color("ff0080"), None);
assert_eq!(parse_hex_color("#ff00"), None);
assert_eq!(parse_hex_color("#é0000"), None);
assert_eq!(format_hex_color([1, 2, 255]), "#0102ff");
assert!(color_distance([0, 0, 0], [3, 4, 0]) - 5.0 < 1e-9);
}
}
+305
View File
@@ -0,0 +1,305 @@
//! Geometry primitives for StencilCraft: kurbo re-exports, millimetre helpers,
//! path flattening.
//!
//! The bottom layer of the workspace (L1 in `docs/architecture.md`): nothing here
//! depends on the document model, kerf, exports or the engine. All coordinates are
//! millimetres; coordinate-axis orientation is defined at each format boundary.
pub use kurbo::{Affine, BezPath, Circle, CubicBez, Ellipse, Line, ParamCurve, Point, QuadBez, Rect, Shape, Vec2};
#[derive(Debug, thiserror::Error)]
#[error("geometry: {0}")]
pub struct GeomError(String);
pub type Result<T> = std::result::Result<T, GeomError>;
/// The smallest length that can meaningfully exist on an artboard; below this a
/// value is treated as zero. Values above this but non-finite are always an error.
pub const MM_EPSILON: f64 = 1e-9;
/// Millimetres per inch: the one inch/mm conversion factor (inch-based mats and page sizes).
pub const MM_PER_INCH: f64 = 25.4;
/// Converts inches at an input boundary to document millimetres.
pub const fn inches_to_mm(inches: f64) -> f64 {
inches * MM_PER_INCH
}
/// Converts document millimetres to inches at a display boundary.
pub const fn mm_to_inches(mm: f64) -> f64 {
mm / MM_PER_INCH
}
/// Map an artboard's downward-growing Y coordinate to a Cartesian Y coordinate.
///
/// The board's top is `height_mm` and its bottom is zero in the resulting space.
/// Values outside the board remain outside; non-finite inputs or results are rejected.
pub fn artboard_y_to_cartesian(y_mm: f64, height_mm: f64) -> Result<f64> {
check_finite_mm(y_mm, "artboard y")?;
check_finite_mm(height_mm, "artboard height")?;
check_finite_mm(height_mm - y_mm, "cartesian y")
}
/// Rejects a non-finite millimetre length before it can poison a document.
pub fn check_finite_mm(value: f64, what: &'static str) -> Result<f64> {
if !value.is_finite() {
return Err(GeomError(format!("{what}: not a finite length")));
}
let _ = MM_EPSILON;
Ok(value)
}
/// A flattened subpath: the raw points of a contour, plus whether the source path
/// closed it. Used by kerf (offsetting) and every polyline-based exporter.
#[derive(Debug, Clone, PartialEq)]
pub struct Polyline {
pub points: Vec<Point>,
pub closed: bool,
}
impl Polyline {
pub fn from_path(path: &BezPath) -> Vec<Polyline> {
path_to_polylines(path, DEFAULT_FLATTEN_STEPS)
}
}
/// Flattening resolution: samples per Bézier segment. 32 steps at artboard scale
/// keeps chord error far below a typical kerf width.
pub const DEFAULT_FLATTEN_STEPS: usize = 32;
/// Convert a path into polylines by sampling Bézier segments (`steps` samples per
/// segment). Closed subpaths produce `closed: true` polylines that do not repeat
/// the start point.
pub fn path_to_polylines(path: &BezPath, steps: usize) -> Vec<Polyline> {
let steps = steps.max(2);
let mut out: Vec<Polyline> = Vec::new();
let mut current: Option<Polyline> = None;
let mut start = Point::ZERO;
let mut cursor = Point::ZERO;
for elem in path.elements() {
match *elem {
kurbo::PathEl::MoveTo(p) => {
if let Some(done) = current.take() {
out.push(done);
}
start = p;
cursor = p;
current = Some(Polyline { points: vec![p], closed: false });
}
kurbo::PathEl::LineTo(p) => {
if let Some(pl) = current.as_mut() {
pl.points.push(p);
}
cursor = p;
}
kurbo::PathEl::ClosePath => {
if let Some(pl) = current.as_mut() {
pl.closed = true;
}
cursor = start;
}
kurbo::PathEl::QuadTo(c, p) => {
if let Some(pl) = current.as_mut() {
let quad = QuadBez::new(cursor, c, p);
for i in 1..steps {
let t = i as f64 / steps as f64;
pl.points.push(quad.eval(t));
}
}
cursor = p;
}
kurbo::PathEl::CurveTo(c1, c2, p) => {
if let Some(pl) = current.as_mut() {
let cubic = CubicBez::new(cursor, c1, c2, p);
for i in 1..steps {
let t = i as f64 / steps as f64;
pl.points.push(cubic.eval(t));
}
}
cursor = p;
}
}
}
if let Some(done) = current.take() {
out.push(done);
}
out
}
/// Signed area of a closed polyline (shoelace): positive when wound
/// counter-clockwise with y up. An open polyline contributes 0.
pub fn signed_area(points: &[Point]) -> f64 {
if points.len() < 3 {
return 0.0;
}
let mut sum = 0.0;
for i in 0..points.len() {
let a = points[i];
let b = points[(i + 1) % points.len()];
sum += a.x * b.y - b.x * a.y;
}
sum / 2.0
}
/// Bounds of a set of points, or `None` when empty.
pub fn bounds_of_points(points: &[Point]) -> Option<Rect> {
let mut it = points.iter();
let first = *it.next()?;
let mut acc = Rect::from_points(first, first);
for p in it {
acc = acc.union(Rect::from_points(*p, *p));
}
Some(acc)
}
/// Axis-aligned bounds of every point of every polyline.
pub fn bounds_of_polylines(lines: &[Polyline]) -> Option<Rect> {
let mut acc: Option<Rect> = None;
for pl in lines {
if let Some(r) = bounds_of_points(&pl.points) {
acc = Some(match acc {
Some(prev) => prev.union(r),
None => r,
});
}
}
acc
}
/// Exact (non-flattened) control-point bounds of a path through kurbo.
pub fn bounds_of_shape(path: &BezPath) -> Option<Rect> {
path.bounding_box().as_rect()
}
/// Display units. The document is always millimetres; convert only where a value meets
/// the user (fields, rulers, callouts), through these helpers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Unit {
#[default]
Mm,
Cm,
In,
Pt,
}
impl Unit {
pub const ALL: [Unit; 4] = [Unit::Mm, Unit::Cm, Unit::In, Unit::Pt];
/// Millimetres in one of this unit.
pub fn mm_per_unit(self) -> f64 {
match self {
Unit::Mm => 1.0,
Unit::Cm => 10.0,
Unit::In => 25.4,
Unit::Pt => 25.4 / 72.0,
}
}
pub fn to_mm(self, value: f64) -> f64 {
value * self.mm_per_unit()
}
pub fn from_mm(self, mm: f64) -> f64 {
mm / self.mm_per_unit()
}
pub fn suffix(self) -> &'static str {
match self {
Unit::Mm => "mm",
Unit::Cm => "cm",
Unit::In => "in",
Unit::Pt => "pt",
}
}
/// `mm` shown in this unit with `decimals` places and the suffix, e.g. `1.000 in`.
pub fn format(self, mm: f64, decimals: usize) -> String {
format!("{:.*} {}", decimals.min(9), self.from_mm(mm), self.suffix())
}
/// Parses `12.5`, `12.5mm`, `1 in`, `3cm`, `72 pt` or `2"` into millimetres; a bare
/// number is in this unit. `None` for anything else or a non-finite result.
pub fn parse_to_mm(self, text: &str) -> Option<f64> {
let text = text.trim();
let (number, unit) = Unit::ALL
.iter()
.find_map(|u| text.strip_suffix(u.suffix()).map(|n| (n, *u)))
.or_else(|| text.strip_suffix('"').map(|n| (n, Unit::In)))
.unwrap_or((text, self));
let value: f64 = number.trim().parse().ok()?;
let mm = unit.to_mm(value);
mm.is_finite().then_some(mm)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn units_convert_format_and_parse() {
assert_eq!(Unit::In.to_mm(1.0), 25.4);
assert!((Unit::Pt.from_mm(25.4) - 72.0).abs() < 1e-12);
assert_eq!(Unit::Cm.from_mm(15.0), 1.5);
assert_eq!(Unit::In.format(25.4, 3), "1.000 in");
assert_eq!(Unit::Mm.parse_to_mm("12.5"), Some(12.5));
assert_eq!(Unit::In.parse_to_mm("2"), Some(50.8));
assert_eq!(Unit::Mm.parse_to_mm(" 1 in "), Some(25.4));
assert_eq!(Unit::Mm.parse_to_mm("2\""), Some(50.8));
assert_eq!(Unit::Mm.parse_to_mm("3cm"), Some(30.0));
assert_eq!(Unit::Mm.parse_to_mm("abc"), None);
assert_eq!(Unit::Mm.parse_to_mm("1e400"), None);
for unit in Unit::ALL {
assert!((unit.to_mm(unit.from_mm(123.4)) - 123.4).abs() < 1e-9);
}
}
fn square(side: f64) -> BezPath {
let mut p = BezPath::new();
p.move_to(Point::ZERO);
p.line_to(Point::new(side, 0.0));
p.line_to(Point::new(side, side));
p.line_to(Point::new(0.0, side));
p.close_path();
p
}
#[test]
fn flatten_square_closes() {
let lines = path_to_polylines(&square(10.0), 4);
assert_eq!(lines.len(), 1);
let pl = &lines[0];
assert!(pl.closed);
assert_eq!(pl.points.len(), 4);
assert_eq!(pl.points[0], Point::ZERO);
assert_eq!(pl.points[2], Point::new(10.0, 10.0));
}
#[test]
fn area_sign_of_square() {
let lines = path_to_polylines(&square(10.0), 4);
let area = signed_area(&lines[0].points);
assert!((area - 100.0).abs() < 1e-9, "got {area}");
}
#[test]
fn cubic_flattens_to_steps_per_segment() {
let mut p = BezPath::new();
p.move_to(Point::ZERO);
p.curve_to(Point::new(10.0, 0.0), Point::new(20.0, 10.0), Point::new(0.0, 20.0));
p.close_path();
let lines = path_to_polylines(&p, 8);
assert_eq!(lines.len(), 1);
// The move plus 7 interior samples; the segment's endpoint is the move origin
// after close, so it is not repeated.
assert_eq!(lines[0].points.len(), 8);
}
#[test]
fn bounds_union_works() {
let lines = path_to_polylines(&square(10.0), 4);
let b = bounds_of_polylines(&lines).unwrap();
assert_eq!(b, Rect::new(0.0, 0.0, 10.0, 10.0));
}
}
+841
View File
@@ -0,0 +1,841 @@
//! StencilCraft document model and domain validation.
//!
//! A document holds an artboard in millimetres, kerf settings and layers of shapes.
//! Every read path returns `Result` with a calm, printable error: a malformed file
//! must never crash (`AGENTS.md` › "Never crash"), because it is untrusted input.
pub mod canvas;
pub mod geom;
use crate::geom::{GeomError, bounds_of_shape, check_finite_mm};
pub use canvas::{Grid, Group, GroupChild, Guide, GuideAxis, Metadata, Orientation, Page, PagePreset, PageSetup, SnapSettings};
use kurbo::BezPath;
use serde::{Deserialize, Serialize};
pub mod production;
pub use production::{CombineOp, GroupKind, Operation, SendAssign, ShapeProduction, SourceGroup};
/// Native format identity and supported version, defined by the shared wire-format crate.
pub use craft_stencilcraft_format::{FORMAT_NAME, FORMAT_VERSION};
#[derive(Debug, thiserror::Error)]
pub enum DocError {
#[error("{path}: {source}")]
Io { path: String, source: std::io::Error },
#[error("{path}: not a stencilcraft file")]
NotAStencilcraftFile { path: String },
#[error("{path}: format version {found}, this build reads {expected}")]
FormatVersion { path: String, found: u64, expected: u64 },
#[error("{path}: {source}")]
Parse { path: String, source: serde_json::Error },
#[error("{path}: {message}")]
Structure { path: String, message: String },
#[error("document: {0}")]
Model(String),
#[error(transparent)]
Geom(#[from] GeomError),
}
/// How a shape is physically produced on a machine.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CutStyle {
/// Trace the closed outline with the cutting tool.
Cut,
/// Trace without cutting: scoring, engraving outlines, marking.
Engrave,
}
/// Kerf settings held in the document; applying them is `stencilcraft-kerf`'s job.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct KerfSettings {
/// Nominal kerf (material removed by the tool), in millimetres. Compensation
/// offsets contours by `width_mm / 2`.
pub width_mm: f64,
/// Whether exports should carry compensated contours already.
pub apply_to_exports: bool,
}
impl Default for KerfSettings {
fn default() -> Self {
Self { width_mm: 0.0, apply_to_exports: false }
}
}
/// The cutting sheet: artboard size in millimetres.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Board {
pub width_mm: f64,
pub height_mm: f64,
}
impl Default for Board {
fn default() -> Self {
// A default sheet for vinyl work: 100 × 150 mm.
Self { width_mm: 100.0, height_mm: 150.0 }
}
}
/// One layer of the document: a group of shapes with a production style and a
/// visibility flag.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Layer {
pub name: String,
pub kind: CutStyle,
pub visible: bool,
pub shapes: Vec<Shape>,
/// A locked layer's shapes cannot be selected or edited.
#[serde(default, skip_serializing_if = "is_false")]
pub locked: bool,
/// A template (guide) layer is shown on the canvas but never exported or cut.
#[serde(default, skip_serializing_if = "is_false")]
pub template: bool,
/// Material colour label, `#rrggbb`; Color Sync merges similar ones.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
}
impl Layer {
pub fn new(name: impl Into<String>, kind: CutStyle) -> Self {
Self { name: name.into(), kind, visible: true, shapes: Vec::new(), locked: false, template: false, color: None }
}
/// Whether exports and cuts include this layer: visible and not a template.
pub fn is_output(&self) -> bool {
self.visible && !self.template
}
/// Whether newly created shapes may land here: not locked, not a template.
pub fn takes_new_shapes(&self) -> bool {
!self.locked && !self.template
}
/// Sum of per-shape bounds; `None` when the layer has no shapes or only empty
/// paths.
pub fn bounds(&self) -> Option<kurbo::Rect> {
let mut acc: Option<kurbo::Rect> = None;
for shape in &self.shapes {
if let Some(r) = shape.bounds() {
acc = Some(match acc {
Some(prev) => prev.union(r),
None => r,
});
}
}
acc
}
}
/// A path plus its production style. kurbo's `BezPath` serialises with the
/// workspace's `serde` feature on — no wrapper needed.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Shape {
pub id: u64,
pub path: BezPath,
pub style: CutStyle,
/// Hidden shapes are not drawn, selected or exported.
#[serde(default, skip_serializing_if = "is_false")]
pub hidden: bool,
/// Locked shapes cannot be selected or edited until unlocked.
#[serde(default, skip_serializing_if = "is_false")]
pub locked: bool,
/// Live text: the parameters `path` was laid out from, so the text stays editable
/// (`text.edit` lays it out again). Absent for plain paths and in older files.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<Box<TextSpec>>,
/// A tool-specific operation for this path (score, emboss, engrave, foil, punch);
/// `None` is a plain cut or engrave pass by `style`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool: Option<ToolOp>,
/// Production metadata (operation, colours, attach, contours, combine sources).
#[serde(default, skip_serializing_if = "ShapeProduction::is_default")]
pub production: ShapeProduction,
}
/// Score and emboss tool tip.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Tip {
#[default]
Fine,
Wide,
}
fn one_pass() -> u32 {
1
}
/// A tool-specific operation a machine backend runs instead of a plain cut/engrave pass.
/// Paths tagged with one live on an engrave layer (they must never be cut through), except
/// `punch`, whose path is the hole outline for display and fallback cutting.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "kebab-case")]
pub enum ToolOp {
/// Crease along the path (scoring stylus/wheel).
Score {
#[serde(default)]
tip: Tip,
#[serde(default = "one_pass")]
passes: u32,
},
/// Press the path into the material (emboss, or deboss from the other side).
Emboss {
#[serde(default)]
tip: Tip,
#[serde(default = "one_pass")]
passes: u32,
#[serde(default)]
deboss: bool,
},
/// Rotary/power engraving tool following the path.
PowerEngrave,
/// Heat pen pressing foil along the path.
HeatPen,
/// One punch per contour, at the contour's bounds centre: a backend with a punch tool
/// plunges there instead of cutting the circle.
Punch { diameter_mm: f64 },
}
fn is_false(value: &bool) -> bool {
!*value
}
/// A live text object's parameters. The shape's `path` always holds the laid-out outlines,
/// so readers that ignore `text` (older builds, every export) still cut the right thing.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TextSpec {
pub text: String,
pub font: FontSpec,
/// Em size in points (1 pt = 25.4/72 mm).
pub size: f64,
/// Anchor in mm: the first line's baseline at the alignment point.
pub x: f64,
pub y: f64,
#[serde(default)]
pub alignment: TextAlign,
/// Space added between letters, in percent of the em (0 = the font's own spacing).
#[serde(default)]
pub letter_spacing: f64,
/// Distance between lines in percent of the normal line distance (100 = normal).
#[serde(default = "hundred")]
pub line_spacing: f64,
/// Pair kerning from the font's tables.
#[serde(default = "yes")]
pub kerning: bool,
/// Top-to-bottom columns instead of left-to-right lines.
#[serde(default, skip_serializing_if = "is_false")]
pub vertical: bool,
/// Merge overlapping letter outlines into one cut path (script fonts).
#[serde(default, skip_serializing_if = "is_false")]
pub weld: bool,
#[serde(default, skip_serializing_if = "TextFlow::is_straight")]
pub flow: TextFlow,
/// Per-letter nudges, by character index into `text` (newlines count).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub letters: Vec<LetterOffset>,
/// Affine `[a, b, c, d, e, f]` applied after layout: the moves, turns and scales made to
/// the shape since its text was laid out.
#[serde(default = "identity", skip_serializing_if = "is_identity")]
pub transform: [f64; 6],
}
/// A font by family and style, optionally from a font file.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FontSpec {
pub family: String,
#[serde(default = "regular")]
pub style: String,
/// The font file the family came from, loaded again when the text is laid out again.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TextAlign {
#[default]
Left,
#[serde(alias = "center", alias = "centre")]
Centre,
Right,
/// Every line but the last is stretched to the widest line.
Justify,
}
/// How the text runs: straight lines, one line bent round a circle, or one line along a path.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum TextFlow {
#[default]
Straight,
/// One line bent into an arc of this diameter in mm: positive arches up, negative dips.
Curve { diameter_mm: f64 },
/// One line along `path` (mm). `start` is where the anchor sits, as a fraction of the
/// path's length; `offset_mm` lifts the letters off the path; `flip` runs the text the
/// other way round, on the other side. `shape` names the shape the path was copied from.
OnPath {
#[serde(default)]
path: BezPath,
#[serde(default, skip_serializing_if = "Option::is_none")]
shape: Option<u64>,
#[serde(default)]
start: f64,
#[serde(default)]
offset_mm: f64,
#[serde(default)]
flip: bool,
},
}
impl TextFlow {
pub fn is_straight(&self) -> bool {
matches!(self, TextFlow::Straight)
}
}
/// A nudge to one letter: moved by `dx_mm`, `dy_mm` and turned `rotate_deg` about its origin.
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct LetterOffset {
pub index: usize,
#[serde(default)]
pub dx_mm: f64,
#[serde(default)]
pub dy_mm: f64,
#[serde(default)]
pub rotate_deg: f64,
}
fn hundred() -> f64 {
100.0
}
fn yes() -> bool {
true
}
fn regular() -> String {
"Regular".to_string()
}
/// The identity affine in `TextSpec::transform` form.
pub const IDENTITY_TRANSFORM: [f64; 6] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
fn identity() -> [f64; 6] {
IDENTITY_TRANSFORM
}
fn is_identity(xf: &[f64; 6]) -> bool {
*xf == IDENTITY_TRANSFORM
}
impl Shape {
pub fn bounds(&self) -> Option<kurbo::Rect> {
bounds_of_shape(&self.path)
}
}
/// Most placed raster images one document may hold.
pub const MAX_IMAGES: usize = 256;
/// Most base64 PNG bytes one placed image may carry (about 96 MiB of PNG).
pub const MAX_IMAGE_DATA: usize = 128 * 1024 * 1024;
/// A placed raster image (a trace source, never cut). Pixels are PNG, base64-encoded so
/// the document stays JSON; `Arc` keeps undo snapshots from copying the bytes.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RasterImage {
/// Shares the shape id counter, so an id names one object in the document.
pub id: u64,
pub name: String,
pub x_mm: f64,
pub y_mm: f64,
pub width_mm: f64,
pub height_mm: f64,
pub width_px: u32,
pub height_px: u32,
pub png_base64: std::sync::Arc<str>,
}
impl RasterImage {
fn validate(&self) -> Result<(), DocError> {
for (value, what) in [(self.x_mm, "image x"), (self.y_mm, "image y"), (self.width_mm, "image width"), (self.height_mm, "image height")] {
check_finite_mm(value, what)?;
}
if self.width_mm <= 0.0 || self.height_mm <= 0.0 || self.width_px == 0 || self.height_px == 0 {
return Err(DocError::Model(format!("image {}: width and height must be positive", self.id)));
}
if self.png_base64.len() > MAX_IMAGE_DATA {
return Err(DocError::Model(format!("image {}: pixel data exceeds the 128 MiB limit", self.id)));
}
Ok(())
}
}
/// A StencilCraft document: artboard, kerf configuration, layers.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Document {
pub board: Board,
pub kerf: KerfSettings,
pub layers: Vec<Layer>,
/// Next free shape id; maintained by whoever mutates the document.
#[serde(default)]
pub next_shape_id: u64,
/// Placed raster images, bottom first, drawn under the cut layers.
#[serde(default)]
pub images: Vec<RasterImage>,
/// Mat, media, colour and name of the main board (page 0).
#[serde(default)]
pub board_setup: PageSetup,
/// Extra pages beside the main board.
#[serde(default)]
pub pages: Vec<Page>,
#[serde(default)]
pub guides: Vec<Guide>,
#[serde(default)]
pub grid: Grid,
#[serde(default)]
pub snap: SnapSettings,
/// Groups; members that no longer exist are ignored and dropped on save.
#[serde(default)]
pub groups: Vec<Group>,
#[serde(default)]
pub meta: Metadata,
}
/// Typed fields in the native wire format. Field names and version-1 defaults are owned by
/// `craft-stencilcraft-format`; this alias supplies only StencilCraft's domain types.
type NativeFileFields = craft_stencilcraft_format::DocumentFields<
Board,
KerfSettings,
Vec<Layer>,
RasterImage,
PageSetup,
Vec<Page>,
Vec<Guide>,
Grid,
SnapSettings,
Vec<Group>,
Metadata,
>;
impl Default for Document {
fn default() -> Self {
Self {
board: Board::default(),
kerf: KerfSettings::default(),
layers: vec![Layer::new("cut", CutStyle::Cut)],
next_shape_id: 1,
images: Vec::new(),
board_setup: PageSetup::default(),
pages: Vec::new(),
guides: Vec::new(),
grid: Grid::default(),
snap: SnapSettings::default(),
groups: Vec::new(),
meta: Metadata::default(),
}
}
}
impl Document {
/// Shapes across all layers, in order, paired with their layer index.
pub fn shapes(&self) -> impl Iterator<Item = (usize, &Shape)> {
self.layers.iter().enumerate().flat_map(|(li, l)| l.shapes.iter().map(move |s| (li, s)))
}
pub fn shape_count(&self) -> usize {
self.layers.iter().map(|l| l.shapes.len()).sum()
}
pub fn get_shape(&self, id: u64) -> Option<&Shape> {
self.shapes().map(|(_, s)| s).find(|s| s.id == id)
}
pub fn get_shape_mut(&mut self, id: u64) -> Option<&mut Shape> {
self.layers.iter_mut().flat_map(|l| l.shapes.iter_mut()).find(|s| s.id == id)
}
/// Allocates the next shape id and records the shape in `layer_index`.
/// Errors come out printable: messages name the layer and the fault.
pub fn push_shape(&mut self, layer_index: usize, path: BezPath, style: CutStyle) -> Result<Shape, DocError> {
if layer_index >= self.layers.len() {
return Err(DocError::Model(format!("layer {layer_index}: no such layer (document has {})", self.layers.len())));
}
let Some(layer) = self.layers.get_mut(layer_index) else {
return Err(DocError::Model(format!("layer {layer_index}: disappeared")));
};
let id = self.next_shape_id;
self.next_shape_id = self.next_shape_id.checked_add(1).ok_or_else(|| DocError::Model("shape ids exhausted".to_string()))?;
let shape = Shape { id, path, style, hidden: false, locked: false, tool: None, text: None, production: ShapeProduction::default() };
layer.shapes.push(shape.clone());
Ok(shape)
}
/// Places a raster image with the next object id and returns that id.
pub fn push_image(&mut self, mut image: RasterImage) -> Result<u64, DocError> {
if self.images.len() >= MAX_IMAGES {
return Err(DocError::Model(format!("a document holds at most {MAX_IMAGES} images")));
}
image.id = self.next_shape_id;
image.validate()?;
self.next_shape_id = self.next_shape_id.checked_add(1).ok_or_else(|| DocError::Model("shape ids exhausted".to_string()))?;
let id = image.id;
self.images.push(image);
Ok(id)
}
pub fn get_image(&self, id: u64) -> Option<&RasterImage> {
self.images.iter().find(|image| image.id == id)
}
/// Checks the board is a sane sheet: positive, finite dimensions.
pub fn validate(&self) -> Result<(), DocError> {
check_finite_mm(self.board.width_mm, "board width")?;
check_finite_mm(self.board.height_mm, "board height")?;
check_finite_mm(self.kerf.width_mm, "kerf width")?;
if self.board.width_mm <= 0.0 || self.board.height_mm <= 0.0 {
return Err(DocError::Model("board: width and height must be positive".to_string()));
}
if self.kerf.width_mm < 0.0 {
return Err(DocError::Model("kerf: width cannot be negative".to_string()));
}
if self.images.len() > MAX_IMAGES {
return Err(DocError::Model(format!("a document holds at most {MAX_IMAGES} images")));
}
self.images.iter().try_for_each(RasterImage::validate)?;
self.validate_canvas()
}
/// Serialises with the format header. The header wraps the document so a reader
/// knows what it is before reading the rest. Canvas fields are written only when
/// set, so a plain document saves exactly as it did before they existed.
pub fn save_json(&self) -> Result<String, DocError> {
self.validate()?;
let fields = NativeFileFields {
board: self.board.clone(),
kerf: self.kerf.clone(),
layers: self.layers.clone(),
next_shape_id: self.next_shape_id,
images: self.images.clone(),
board_setup: self.board_setup.clone(),
pages: self.pages.clone(),
guides: self.guides.clone(),
grid: self.grid.clone(),
snap: self.snap.clone(),
groups: self.pruned_groups(),
meta: self.meta.clone(),
};
craft_stencilcraft_format::File::<NativeFileFields>::new(fields)
.to_json_pretty()
.map_err(|e| DocError::Structure { path: "output".to_string(), message: e.to_string() })
}
/// Parses a document from JSON with calm, positioned error messages.
pub fn load_json(json: &str) -> Result<Document, DocError> {
load_json_named(json, "input")
}
}
fn load_json_named(json: &str, path: &str) -> Result<Document, DocError> {
let file = craft_stencilcraft_format::File::<NativeFileFields>::read_typed(json).map_err(|e| match e {
craft_stencilcraft_format::Error::Json(source) => DocError::Parse { path: path.to_string(), source },
craft_stencilcraft_format::Error::NotStencilCraft => DocError::NotAStencilcraftFile { path: path.to_string() },
craft_stencilcraft_format::Error::Version { found, supported } => {
DocError::FormatVersion { path: path.to_string(), found, expected: supported }
}
craft_stencilcraft_format::Error::Document { source } => structure_err(path, &source),
_ => DocError::NotAStencilcraftFile { path: path.to_string() },
})?;
let fields = file.document;
let mut doc = Document {
board: fields.board,
kerf: fields.kerf,
layers: fields.layers,
next_shape_id: fields.next_shape_id,
images: fields.images,
board_setup: fields.board_setup,
pages: fields.pages,
guides: fields.guides,
grid: fields.grid,
snap: fields.snap,
groups: fields.groups,
meta: fields.meta,
};
doc.validate()?;
doc.groups = doc.pruned_groups();
Ok(doc)
}
fn structure_err(path: &str, e: &serde_json::Error) -> DocError {
DocError::Structure { path: path.to_string(), message: e.to_string() }
}
/// File helpers: errors keep the path so the message names the file.
pub fn save_to(doc: &Document, path: &str) -> Result<(), DocError> {
let json = doc.save_json()?;
craft_atomic::atomic_write(std::path::Path::new(path), json.as_bytes()).map_err(|source| DocError::Io { path: path.to_string(), source })
}
pub fn load_from(path: &str) -> Result<Document, DocError> {
let json = std::fs::read_to_string(path).map_err(|source| DocError::Io { path: path.to_string(), source })?;
load_json_named(&json, path)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geom::Point;
fn rect_path(x: f64, y: f64, w: f64, h: f64) -> BezPath {
let mut p = BezPath::new();
p.move_to(Point::new(x, y));
p.line_to(Point::new(x + w, y));
p.line_to(Point::new(x + w, y + h));
p.line_to(Point::new(x, y + h));
p.close_path();
p
}
fn sample() -> Document {
let mut doc = Document::default();
doc.kerf.width_mm = 0.2;
doc.push_shape(0, rect_path(10.0, 20.0, 30.0, 40.0), CutStyle::Cut).unwrap();
doc
}
#[test]
fn round_trip() {
let doc = sample();
let json = doc.save_json().unwrap();
let back = Document::load_json(&json).unwrap();
assert_eq!(doc, back);
}
#[test]
fn flags_round_trip_and_default_off() {
let mut doc = sample();
let plain = doc.save_json().unwrap();
assert!(!plain.contains("hidden") && !plain.contains("locked"));
doc.layers[0].shapes[0].hidden = true;
doc.layers[0].shapes[0].locked = true;
let back = Document::load_json(&doc.save_json().unwrap()).unwrap();
assert_eq!(doc, back);
}
#[test]
fn text_round_trips_and_minimal_text_loads() {
let mut doc = sample();
let plain = doc.save_json().unwrap();
assert!(!plain.contains("\"text\""));
doc.layers[0].shapes[0].text = Some(Box::new(TextSpec {
text: "Hi".into(),
font: FontSpec { family: "Sans".into(), style: "Bold".into(), file: None },
size: 72.0,
x: 1.0,
y: 2.0,
alignment: TextAlign::Centre,
letter_spacing: -5.0,
line_spacing: 120.0,
kerning: false,
vertical: false,
weld: true,
flow: TextFlow::Curve { diameter_mm: 50.0 },
letters: vec![LetterOffset { index: 1, dx_mm: 1.0, ..LetterOffset::default() }],
transform: IDENTITY_TRANSFORM,
}));
let back = Document::load_json(&doc.save_json().unwrap()).unwrap();
assert_eq!(doc, back);
// A minimal hand-written text object fills in the defaults.
let json = plain.replace(
"\"style\": \"cut\"",
"\"style\": \"cut\", \"text\": {\"text\": \"A\", \"font\": {\"family\": \"X\"}, \"size\": 12, \"x\": 0, \"y\": 0}",
);
let spec = Document::load_json(&json).unwrap().layers[0].shapes[0].text.clone().unwrap();
assert_eq!(spec.line_spacing, 100.0);
assert!(spec.kerning && spec.flow.is_straight());
assert_eq!(spec.font.style, "Regular");
}
#[test]
fn header_written() {
let json = sample().save_json().unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
let h = &v["stencilcraft"];
assert_eq!(h["name"].as_str(), Some(FORMAT_NAME));
assert_eq!(h["version"].as_u64(), Some(FORMAT_VERSION));
}
#[test]
fn junk_is_an_error_not_a_crash() {
assert!(Document::load_json("not json at all").is_err());
assert!(matches!(Document::load_json("{}"), Err(DocError::NotAStencilcraftFile { .. })));
}
#[test]
fn future_version_is_rejected_calmly() {
let json = r#"{"stencilcraft": {"name": "stencilcraft", "version": 99}}"#;
assert!(matches!(Document::load_json(json), Err(DocError::FormatVersion { found: 99, expected: 1, .. })));
}
#[test]
fn missing_field_has_structure_error() {
let json = r#"{"stencilcraft": {"name": "stencilcraft", "version": 1},
"board": {"width_mm": 1, "height_mm": 1}}"#;
assert!(matches!(
Document::load_json(json),
Err(DocError::Structure { ref message, .. }) if message.contains("kerf")
));
}
#[test]
fn images_round_trip_and_share_the_id_counter() {
let mut doc = canvas_sample();
let id = doc
.push_image(RasterImage {
id: 0,
name: "photo".to_string(),
x_mm: 0.0,
y_mm: 0.0,
width_mm: 10.0,
height_mm: 5.0,
width_px: 2,
height_px: 1,
png_base64: "AAAA".into(),
})
.unwrap();
assert_eq!(id, 3);
let back = Document::load_json(&doc.save_json().unwrap()).unwrap();
assert_eq!(back, doc);
let mut bad = doc.clone();
bad.images[0].width_mm = -1.0;
assert!(bad.validate().is_err());
}
#[test]
fn push_shape_assigns_ids() {
let mut doc = Document::default();
let s = doc.push_shape(0, rect_path(0.0, 0.0, 5.0, 5.0), CutStyle::Cut).unwrap();
assert_eq!(s.id, 1);
let s2 = doc.push_shape(0, rect_path(5.0, 5.0, 5.0, 5.0), CutStyle::Cut).unwrap();
assert_eq!(s2.id, 2);
assert!(matches!(doc.push_shape(9, rect_path(0.0, 0.0, 1.0, 1.0), CutStyle::Cut), Err(DocError::Model(_))));
}
fn canvas_sample() -> Document {
let mut doc = sample();
doc.push_shape(0, rect_path(50.0, 50.0, 5.0, 5.0), CutStyle::Cut).unwrap();
doc.board_setup.mat = Some("12x12".to_string());
doc.pages.push(Page {
x_mm: 120.0,
y_mm: 0.0,
width_mm: 210.0,
height_mm: 297.0,
setup: PageSetup { name: "A4".to_string(), color: Some("#ffeedd".to_string()), ..PageSetup::default() },
});
doc.guides.push(Guide { axis: GuideAxis::Vertical, position_mm: 12.5, locked: true });
doc.grid.spacing_mm = 5.0;
doc.grid.subdivisions = 4;
doc.snap.grid = true;
doc.layers[0].color = Some("#112233".to_string());
doc.layers.push(Layer { template: true, locked: true, ..Layer::new("guide", CutStyle::Cut) });
doc.groups = vec![
Group { id: 1, name: "inner".to_string(), children: vec![GroupChild::Shape(1)] },
Group { id: 2, name: String::new(), children: vec![GroupChild::Group(1), GroupChild::Shape(2)] },
];
doc.meta.title = "Card".to_string();
doc.meta.notes = "for mum".to_string();
doc.meta.thumbnail_svg = Some(doc.render_thumbnail(4096));
doc
}
#[test]
fn canvas_fields_round_trip() {
let doc = canvas_sample();
let back = Document::load_json(&doc.save_json().unwrap()).unwrap();
assert_eq!(doc, back);
assert_eq!(back.group_chain(GroupChild::Shape(1)), vec![2, 1]);
assert_eq!(back.group_shapes(2), vec![1, 2]);
}
#[test]
fn plain_documents_save_without_canvas_keys() {
let json = sample().save_json().unwrap();
for key in ["pages", "guides", "grid", "snap", "groups", "meta", "board-setup", "template"] {
assert!(!json.contains(key), "{key} written for a plain document");
}
}
#[test]
fn version_1_files_without_canvas_fields_still_load() {
for name in ["starter", "poster", "stencil-frame"] {
let path = format!("{}/../../examples/{name}.stencilcraft", env!("CARGO_MANIFEST_DIR"));
let doc = load_from(&path).unwrap();
assert!(doc.pages.is_empty() && doc.groups.is_empty() && doc.guides.is_empty());
assert_eq!(doc.grid, Grid::default());
assert!(doc.layers.iter().all(|l| !l.template && !l.locked));
}
}
#[test]
fn dangling_group_members_are_pruned_not_fatal() {
let mut doc = canvas_sample();
doc.layers[0].shapes.retain(|s| s.id != 1);
// Group 1 loses its only member and disappears; group 2 keeps shape 2.
let back = Document::load_json(&doc.save_json().unwrap()).unwrap();
assert_eq!(back.groups.len(), 1);
assert_eq!(back.groups[0].children, vec![GroupChild::Shape(2)]);
}
#[test]
fn bad_canvas_data_is_rejected_calmly() {
type DocumentMutation = Box<dyn Fn(&mut Document)>;
let cases: Vec<DocumentMutation> = vec![
Box::new(|d| d.groups[0].children.push(GroupChild::Group(2))),
Box::new(|d| d.groups[1].children.push(GroupChild::Shape(1))),
Box::new(|d| d.groups[0].id = 2),
Box::new(|d| d.pages[0].width_mm = f64::NAN),
Box::new(|d| d.guides[0].position_mm = f64::INFINITY),
Box::new(|d| d.grid.subdivisions = 0),
Box::new(|d| d.layers[0].color = Some("red".to_string())),
Box::new(|d| d.meta.notes = "x".repeat(canvas::MAX_NOTES_BYTES + 1)),
Box::new(|d| d.snap.tolerance_mm = f64::NAN),
];
for (i, corrupt) in cases.iter().enumerate() {
let mut doc = canvas_sample();
corrupt(&mut doc);
assert!(doc.validate().is_err(), "case {i} validated");
assert!(doc.save_json().is_err(), "case {i} saved");
}
// The same faults in a file are load errors.
let json = canvas_sample().save_json().unwrap().replace("#112233", "#zz");
assert!(Document::load_json(&json).is_err());
}
#[test]
fn page_document_takes_the_shapes_on_that_page() {
let mut doc = canvas_sample();
doc.push_shape(0, rect_path(130.0, 10.0, 10.0, 10.0), CutStyle::Cut).unwrap();
let page = doc.page_document(1).unwrap();
assert_eq!((page.board.width_mm, page.board.height_mm), (210.0, 297.0));
assert_eq!(page.shape_count(), 1);
let b = page.layers[0].shapes[0].bounds().unwrap();
assert!((b.x0 - 10.0).abs() < 1e-9 && (b.y0 - 10.0).abs() < 1e-9);
assert_eq!(doc.page_document(0).unwrap().shape_count(), 2);
assert!(doc.page_document(5).is_none());
}
#[test]
fn thumbnail_respects_its_budget() {
let doc = canvas_sample();
let full = doc.render_thumbnail(64 * 1024);
assert!(full.starts_with("<svg") && full.ends_with("</svg>"));
assert_eq!(full.matches("<path").count(), 2);
let small = doc.render_thumbnail(full.len() - 1);
assert!(small.len() < full.len() && small.ends_with("</svg>"));
}
}
@@ -0,0 +1,206 @@
//! Per-shape production metadata: what the machine does with a shape (operation, colour and
//! material keys for mats and cut groups), Attach groups, hidden contours, a manual mat, and the
//! retained source shapes of a non-destructive Combine or a Flatten. Everything defaults to
//! "unset" and is skipped when saving, so older files and files without production data are
//! unchanged.
use kurbo::{Affine, BezPath, PathEl};
use serde::{Deserialize, Serialize};
use crate::Shape;
/// What the machine does with a shape. `None` on a shape means "follow its cut/engrave style".
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Operation {
/// Cut the outline (the default for cut shapes).
Cut,
/// Sticker kiss cut: through the face material only, the backing stays whole.
KissCut,
/// Sticker die cut: through the face material and the backing.
DieCut,
/// Draw the outline with a pen.
Draw,
/// Crease a fold line.
Score,
/// Engrave the line (the default for engrave shapes).
Engrave,
/// A dashed tear line.
Perforate,
/// Print on a home printer, then cut around the print using registration marks.
PrintThenCut,
/// Shown on the canvas only, never sent to the machine or printer.
Guide,
}
/// A per-shape tool assignment from the send preview, overriding the group settings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SendAssign {
/// Every line of the shape with tool 1.
Tool1,
/// Only the shape's outer outline with tool 1 (inner contours are skipped).
Tool1Outline,
Tool2,
Tool2Outline,
/// Leave the shape out of the job.
Ignore,
}
/// A Combine's shape operation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CombineOp {
Unite,
/// The back-most member minus every other member.
Subtract,
Intersect,
/// Area covered by an odd number of members.
Exclude,
}
/// Why a shape keeps its sources.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", tag = "kind", content = "operation")]
pub enum GroupKind {
/// Non-destructive Combine: the path is the operation's result; Uncombine restores the
/// members.
Combine(CombineOp),
/// Flatten: the members are the print artwork, the path is its cut outline.
Flatten,
}
/// The shapes a Combine or Flatten replaced, kept so the action can be undone later.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SourceGroup {
pub kind: GroupKind,
pub members: Vec<Shape>,
/// The grouped shape's path when the group was made. Comparing it with the current path
/// tells how the group was moved, rotated or scaled since, so the members follow.
pub path: BezPath,
}
impl SourceGroup {
/// The members moved the way the grouped shape moved since grouping (`current` is its
/// path now). An edit that is not an affine map of the old outline (node edits) falls back
/// to mapping the old bounds onto the new ones.
pub fn members_now(&self, current: &BezPath) -> Vec<Shape> {
let map = fit_affine(&self.path, current).unwrap_or_else(|| bounds_map(&self.path, current));
self.members
.iter()
.map(|m| {
let mut m = m.clone();
m.path = map * m.path;
m
})
.collect()
}
}
fn endpoints(path: &BezPath) -> Vec<kurbo::Point> {
path.elements().iter().filter_map(|el| el.end_point()).collect()
}
/// The affine map taking `old` onto `new` point for point, when the two have the same
/// structure and one exists (within 0.01 mm).
fn fit_affine(old: &BezPath, new: &BezPath) -> Option<Affine> {
let (a, b) = (endpoints(old), endpoints(new));
if a.len() != b.len() || a.len() < 3 {
return None;
}
let p0 = *a.first()?;
// The point farthest from p0, then the one making the largest triangle.
let i = (0..a.len()).max_by(|&i, &j| (a[i] - p0).hypot2().total_cmp(&(a[j] - p0).hypot2()))?;
let j = (0..a.len()).max_by(|&j, &k| (a[i] - p0).cross(a[j] - p0).abs().total_cmp(&(a[i] - p0).cross(a[k] - p0).abs()))?;
let (u, v) = (a[i] - p0, a[j] - p0);
let det = u.cross(v);
if det.abs() < 1e-9 {
// Collinear outline (or no movement information): a pure translation still works.
let t = Affine::translate(*b.first()? - p0);
return a.iter().zip(&b).all(|(p, q)| (t * *p - *q).hypot() < 0.01).then_some(t);
}
let q0 = *b.first()?;
let (s, t) = (b[i] - q0, b[j] - q0);
// M maps u→s and v→t: M = [s t]·[u v]⁻¹.
let inv = [v.y / det, -v.x / det, -u.y / det, u.x / det];
let m = [s.x * inv[0] + t.x * inv[2], s.y * inv[0] + t.y * inv[2], s.x * inv[1] + t.x * inv[3], s.y * inv[1] + t.y * inv[3]];
let linear = Affine::new([m[0], m[1], m[2], m[3], 0.0, 0.0]);
let map = Affine::translate(q0.to_vec2()) * linear * Affine::translate(-p0.to_vec2());
a.iter().zip(&b).all(|(p, q)| (map * *p - *q).hypot() < 0.01).then_some(map)
}
fn bounds_map(old: &BezPath, new: &BezPath) -> Affine {
use kurbo::Shape as _;
let (a, b) = (old.bounding_box(), new.bounding_box());
let sx = if a.width() > 1e-9 { b.width() / a.width() } else { 1.0 };
let sy = if a.height() > 1e-9 { b.height() / a.height() } else { 1.0 };
Affine::translate((b.x0, b.y0)) * Affine::scale_non_uniform(sx, sy) * Affine::translate((-a.x0, -a.y0))
}
/// Production metadata of one shape. Every field is optional.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ShapeProduction {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operation: Option<Operation>,
/// Canonical `#rrggbb` line colour: the cut-by-line-colour key.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line_color: Option<String>,
/// Canonical `#rrggbb` fill colour: the print colour and cut-by-fill-colour key.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fill_color: Option<String>,
/// Material name; shapes on different materials go on different mats.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub material: Option<String>,
/// Attach group: shapes sharing it keep their relative positions on one mat.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attach: Option<u64>,
/// Manual mat: the piece goes on this mat index instead of its automatic one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mat: Option<u32>,
/// Tool assignment from the send preview.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub send: Option<SendAssign>,
/// Contours (subpath indices) hidden by Contour: kept in the path, left out of exports.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hidden_contours: Vec<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<Box<SourceGroup>>,
}
impl ShapeProduction {
pub fn is_default(&self) -> bool {
*self == Self::default()
}
}
impl Shape {
/// The path the machine sees: the shape's path without the contours Contour hid.
pub fn cut_path(&self) -> std::borrow::Cow<'_, BezPath> {
if self.production.hidden_contours.is_empty() {
return std::borrow::Cow::Borrowed(&self.path);
}
let mut out = BezPath::new();
let mut contour = 0usize;
let mut started = false;
for el in self.path.elements() {
if matches!(el, PathEl::MoveTo(_)) {
if started {
contour += 1;
}
started = true;
}
if !self.production.hidden_contours.contains(&contour) {
out.push(*el);
}
}
std::borrow::Cow::Owned(out)
}
/// The shape's operation, falling back to its cut/engrave style.
pub fn operation(&self) -> Operation {
self.production.operation.unwrap_or(match self.style {
crate::CutStyle::Cut => Operation::Cut,
crate::CutStyle::Engrave => Operation::Engrave,
})
}
}
+9 -6
View File
@@ -3,8 +3,10 @@
Reader and writer for the versioned `.stencilcraft` JSON file. The JSON header is
`{"stencilcraft":{"name":"stencilcraft","version":1}}`; the document fields share the
top-level object with the header. `DocumentFields` owns field names, flattening, required fields,
version 1 defaults, and omitted optional members. Its values remain JSON so the consuming app
owns the domain model, geometry validation, and field-to-model mapping.
version 1 defaults, and omission of default canvas members. Its type parameters let consumers use
their typed model fields directly; `File::<DocumentFields<...>>::read_typed` and
`to_json_pretty` handle the shared schema and JSON conversion. StencilCraft's typed model and
domain validation live in `craft-stencilcraft-document`.
The crate does not open files or set a file-size policy. Callers perform bounded file I/O before
calling `File::read`.
@@ -13,10 +15,11 @@ calling `File::read`.
craft-stencilcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "<commit>" }
```
`File::read` parses JSON, validates the name and exact supported version, reads `DocumentFields`,
and ignores unknown members for version 1 compatibility. `File::new` and `to_json_pretty` write
the current header and flattened fields. Absent `next-shape-id` defaults to 1 and absent `images`
defaults to an empty list, matching early version 1 files.
`File::read` parses JSON into the default JSON-valued schema. For typed models, use
`File::<DocumentFields<...>>::read_typed`; both readers validate the name and exact supported
version and ignore unknown members for version 1 compatibility. `File::new` and `to_json_pretty`
write the current header and flattened fields. Absent `next-shape-id` defaults to 1 and absent
`images` defaults to an empty list, matching early version 1 files.
`cargo test -p craft-stencilcraft-format` covers round-trip wire shape, version 1 defaults,
unknown members, wrong and missing headers, unsupported versions, malformed JSON, and missing