diff --git a/Cargo.lock b/Cargo.lock index fd986012c..e9a21eda5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 170ad8641..12d961be1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/README.md b/README.md index c79fa2aff..d94b57690 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/stencilcraft-document/Cargo.toml b/crates/stencilcraft-document/Cargo.toml new file mode 100644 index 000000000..6d5ff6b25 --- /dev/null +++ b/crates/stencilcraft-document/Cargo.toml @@ -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 } diff --git a/crates/stencilcraft-document/README.md b/crates/stencilcraft-document/README.md new file mode 100644 index 000000000..d286c5789 --- /dev/null +++ b/crates/stencilcraft-document/README.md @@ -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. diff --git a/crates/stencilcraft-document/src/canvas.rs b/crates/stencilcraft-document/src/canvas.rs new file mode 100644 index 000000000..69878cb5e --- /dev/null +++ b/crates/stencilcraft-document/src/canvas.rs @@ -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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub media: Option, + /// Page colour, `#rrggbb`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color: Option, + /// 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, +} + +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, +} + +/// `#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::().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::()))) + } + _ => 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 = 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 = 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(¤t) { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + let shapes: BTreeSet = 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 = 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 { + 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 { + 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, child: GroupChild) -> Vec { + let mut chain = Vec::new(); + let mut current = child; + while let Some(parent) = parents.get(¤t) { + 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); + } +} diff --git a/crates/stencilcraft-document/src/geom.rs b/crates/stencilcraft-document/src/geom.rs new file mode 100644 index 000000000..e569afeec --- /dev/null +++ b/crates/stencilcraft-document/src/geom.rs @@ -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 = std::result::Result; + +/// 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 { + 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 { + 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, + pub closed: bool, +} + +impl Polyline { + pub fn from_path(path: &BezPath) -> Vec { + 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 { + let steps = steps.max(2); + let mut out: Vec = Vec::new(); + let mut current: Option = 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 { + 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 { + let mut acc: Option = 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 { + 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 { + 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)); + } +} diff --git a/crates/stencilcraft-document/src/lib.rs b/crates/stencilcraft-document/src/lib.rs new file mode 100644 index 000000000..a966536c5 --- /dev/null +++ b/crates/stencilcraft-document/src/lib.rs @@ -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, + /// 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, +} + +impl Layer { + pub fn new(name: impl Into, 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 { + let mut acc: Option = 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>, + /// 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, + /// 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, + /// 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, +} + +#[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, + #[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 { + 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, +} + +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, + /// 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, + /// 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, + #[serde(default)] + pub guides: Vec, + #[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, + #[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, + RasterImage, + PageSetup, + Vec, + Vec, + Grid, + SnapSettings, + Vec, + 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 { + 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 { + 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 { + 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 { + 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::::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 { + load_json_named(json, "input") + } +} + +fn load_json_named(json: &str, path: &str) -> Result { + let file = craft_stencilcraft_format::File::::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 { + 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; + let cases: Vec = 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("")); + assert_eq!(full.matches("")); + } +} diff --git a/crates/stencilcraft-document/src/production.rs b/crates/stencilcraft-document/src/production.rs new file mode 100644 index 000000000..c03717c0b --- /dev/null +++ b/crates/stencilcraft-document/src/production.rs @@ -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, + /// 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 { + 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 { + 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 { + 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, + /// Canonical `#rrggbb` line colour: the cut-by-line-colour key. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub line_color: Option, + /// 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, + /// Material name; shapes on different materials go on different mats. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub material: Option, + /// Attach group: shapes sharing it keep their relative positions on one mat. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attach: Option, + /// 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, + /// Tool assignment from the send preview. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub send: Option, + /// 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group: Option>, +} + +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, + }) + } +} diff --git a/crates/stencilcraft-format/README.md b/crates/stencilcraft-format/README.md index 2391d3c56..c5b32582d 100644 --- a/crates/stencilcraft-format/README.md +++ b/crates/stencilcraft-format/README.md @@ -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::>::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 = "" } ``` -`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::>::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