mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
Merge pull request #1179 from dylanmtaylor/codex/stencilcraft-typed-format
StencilCraft: share the native document model and codec
This commit is contained in:
15 files changed
+2510
-48
No files matched your search
Generated
+13
@@ -2253,6 +2253,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"
|
||||
|
||||
@@ -118,6 +118,7 @@ craft-time = { path = "crates/time" }
|
||||
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" }
|
||||
|
||||
@@ -327,7 +327,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
|
||||
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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(¤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<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(¤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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,846 @@
|
||||
//! 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;
|
||||
pub mod metadata;
|
||||
|
||||
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 metadata::{LibraryIndex, LibraryTrashEntry, MAX_RECENT_FILES, RecentFiles};
|
||||
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 json in [
|
||||
include_str!("../tests/fixtures/starter.stencilcraft"),
|
||||
include_str!("../tests/fixtures/poster.stencilcraft"),
|
||||
include_str!("../tests/fixtures/stencil-frame.stencilcraft"),
|
||||
] {
|
||||
let doc = Document::load_json(json).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,85 @@
|
||||
//! Small JSON records used by StencilCraft's local library and recent-files list.
|
||||
//!
|
||||
//! These records are separate from a document, but their on-disk field names, defaults and
|
||||
//! JSON encoding are part of StencilCraft's supported persisted data.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::DocError;
|
||||
|
||||
/// One item in the local library recycle bin.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LibraryTrashEntry {
|
||||
/// File name inside the bin.
|
||||
pub name: String,
|
||||
/// Where it was, for restoring.
|
||||
pub original: String,
|
||||
}
|
||||
|
||||
/// The `.library.json` record stored at the root of a StencilCraft library.
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct LibraryIndex {
|
||||
#[serde(default)]
|
||||
pub favourites: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub trash: Vec<LibraryTrashEntry>,
|
||||
}
|
||||
|
||||
impl LibraryIndex {
|
||||
/// Parse the library index, applying defaults for fields absent in older indexes.
|
||||
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
|
||||
serde_json::from_str(json)
|
||||
}
|
||||
|
||||
/// Serialize the library index using the canonical pretty JSON representation.
|
||||
pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
|
||||
serde_json::to_string_pretty(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum number of paths retained in the recent-files record.
|
||||
pub const MAX_RECENT_FILES: usize = 10;
|
||||
|
||||
/// Most recently used document paths, newest first, without duplicates.
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct RecentFiles {
|
||||
#[serde(default)]
|
||||
pub paths: Vec<String>,
|
||||
}
|
||||
|
||||
impl RecentFiles {
|
||||
/// Parse recent files from their persisted JSON representation.
|
||||
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
|
||||
serde_json::from_str(json)
|
||||
}
|
||||
|
||||
/// Serialize recent files using the canonical pretty JSON representation.
|
||||
pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
|
||||
serde_json::to_string_pretty(self)
|
||||
}
|
||||
|
||||
/// Moves `path` to the front, keeping at most [`MAX_RECENT_FILES`].
|
||||
pub fn push(&mut self, path: &str) {
|
||||
self.paths.retain(|p| p != path);
|
||||
self.paths.insert(0, path.to_string());
|
||||
self.paths.truncate(MAX_RECENT_FILES);
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, path: &str) {
|
||||
self.paths.retain(|p| p != path);
|
||||
}
|
||||
|
||||
/// Reads the list; a missing or damaged file gives an empty list.
|
||||
pub fn load(file: &Path) -> Self {
|
||||
let mut list = std::fs::read_to_string(file).ok().and_then(|text| Self::from_json(&text).ok()).unwrap_or_default();
|
||||
list.paths.truncate(MAX_RECENT_FILES);
|
||||
list
|
||||
}
|
||||
|
||||
pub fn save(&self, file: &Path) -> Result<(), DocError> {
|
||||
let json = self.to_json_pretty().map_err(|e| DocError::Structure { path: file.display().to_string(), message: e.to_string() })?;
|
||||
craft_atomic::atomic_write(file, json.as_bytes()).map_err(|source| DocError::Io { path: file.display().to_string(), source })
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"board": {
|
||||
"height_mm": 400.0,
|
||||
"width_mm": 500.0
|
||||
},
|
||||
"kerf": {
|
||||
"apply_to_exports": false,
|
||||
"width_mm": 0.0
|
||||
},
|
||||
"layers": [
|
||||
{
|
||||
"kind": "cut",
|
||||
"name": "cut",
|
||||
"shapes": [
|
||||
{
|
||||
"id": 1,
|
||||
"path": [
|
||||
{
|
||||
"MoveTo": {
|
||||
"x": 10.0,
|
||||
"y": 20.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"LineTo": {
|
||||
"x": 70.0,
|
||||
"y": 20.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"LineTo": {
|
||||
"x": 70.0,
|
||||
"y": 80.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"LineTo": {
|
||||
"x": 10.0,
|
||||
"y": 80.0
|
||||
}
|
||||
},
|
||||
"ClosePath"
|
||||
],
|
||||
"style": "cut"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"path": [
|
||||
{
|
||||
"MoveTo": {
|
||||
"x": 10.0,
|
||||
"y": 390.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"LineTo": {
|
||||
"x": 490.0,
|
||||
"y": 10.0
|
||||
}
|
||||
}
|
||||
],
|
||||
"style": "cut"
|
||||
}
|
||||
],
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"kind": "engrave",
|
||||
"name": "engrave",
|
||||
"shapes": [
|
||||
{
|
||||
"id": 3,
|
||||
"path": [
|
||||
{
|
||||
"MoveTo": {
|
||||
"x": 100.0,
|
||||
"y": 200.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"LineTo": {
|
||||
"x": 400.0,
|
||||
"y": 200.0
|
||||
}
|
||||
}
|
||||
],
|
||||
"style": "engrave"
|
||||
}
|
||||
],
|
||||
"visible": true
|
||||
}
|
||||
],
|
||||
"next-shape-id": 4,
|
||||
"stencilcraft": {
|
||||
"name": "stencilcraft",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"board": {
|
||||
"height_mm": 150.0,
|
||||
"width_mm": 100.0
|
||||
},
|
||||
"kerf": {
|
||||
"apply_to_exports": false,
|
||||
"width_mm": 0.0
|
||||
},
|
||||
"layers": [
|
||||
{
|
||||
"kind": "cut",
|
||||
"name": "cut",
|
||||
"shapes": [
|
||||
{
|
||||
"id": 1,
|
||||
"path": [
|
||||
{
|
||||
"MoveTo": {
|
||||
"x": 10.0,
|
||||
"y": 10.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"LineTo": {
|
||||
"x": 60.0,
|
||||
"y": 10.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"LineTo": {
|
||||
"x": 60.0,
|
||||
"y": 60.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"LineTo": {
|
||||
"x": 10.0,
|
||||
"y": 60.0
|
||||
}
|
||||
},
|
||||
"ClosePath"
|
||||
],
|
||||
"style": "cut"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"path": [
|
||||
{
|
||||
"MoveTo": {
|
||||
"x": 70.0,
|
||||
"y": 20.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"LineTo": {
|
||||
"x": 85.0,
|
||||
"y": 20.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"LineTo": {
|
||||
"x": 85.0,
|
||||
"y": 60.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"LineTo": {
|
||||
"x": 70.0,
|
||||
"y": 60.0
|
||||
}
|
||||
},
|
||||
"ClosePath"
|
||||
],
|
||||
"style": "cut"
|
||||
}
|
||||
],
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"kind": "engrave",
|
||||
"name": "engrave",
|
||||
"shapes": [
|
||||
{
|
||||
"id": 3,
|
||||
"path": [
|
||||
{
|
||||
"MoveTo": {
|
||||
"x": 10.0,
|
||||
"y": 90.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"LineTo": {
|
||||
"x": 90.0,
|
||||
"y": 90.0
|
||||
}
|
||||
}
|
||||
],
|
||||
"style": "engrave"
|
||||
}
|
||||
],
|
||||
"visible": true
|
||||
}
|
||||
],
|
||||
"next-shape-id": 4,
|
||||
"stencilcraft": {
|
||||
"name": "stencilcraft",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"board": {
|
||||
"height_mm": 150.0,
|
||||
"width_mm": 120.0
|
||||
},
|
||||
"kerf": {
|
||||
"apply_to_exports": true,
|
||||
"width_mm": 0.2
|
||||
},
|
||||
"layers": [
|
||||
{
|
||||
"kind": "cut",
|
||||
"name": "cut",
|
||||
"shapes": [
|
||||
{
|
||||
"id": 1,
|
||||
"path": [
|
||||
{
|
||||
"MoveTo": {
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"LineTo": {
|
||||
"x": 100.0,
|
||||
"y": 0.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"LineTo": {
|
||||
"x": 100.0,
|
||||
"y": 100.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"LineTo": {
|
||||
"x": 0.0,
|
||||
"y": 100.0
|
||||
}
|
||||
},
|
||||
"ClosePath"
|
||||
],
|
||||
"style": "cut"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"path": [
|
||||
{
|
||||
"MoveTo": {
|
||||
"x": 30.0,
|
||||
"y": 30.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"LineTo": {
|
||||
"x": 70.0,
|
||||
"y": 30.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"LineTo": {
|
||||
"x": 70.0,
|
||||
"y": 70.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"LineTo": {
|
||||
"x": 30.0,
|
||||
"y": 70.0
|
||||
}
|
||||
},
|
||||
"ClosePath"
|
||||
],
|
||||
"style": "cut"
|
||||
}
|
||||
],
|
||||
"visible": true
|
||||
}
|
||||
],
|
||||
"next-shape-id": 3,
|
||||
"stencilcraft": {
|
||||
"name": "stencilcraft",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -16,10 +16,9 @@
|
||||
//! ```
|
||||
//!
|
||||
//! [`DocumentFields`] owns the JSON field names, required and defaulted fields, and the
|
||||
//! flattening rule. Its values are JSON because the consuming app owns the document model,
|
||||
//! domain validation, and conversion between that model and the wire fields. The crate does not
|
||||
//! open files or set a file-size policy; callers handle bounded file I/O. [`File::read_from`]
|
||||
//! lets callers parse from a reader without first buffering source bytes in a string.
|
||||
//! flattening rule. Its generic form lets an application use its typed domain values directly,
|
||||
//! while keeping the wire schema and JSON encoding here. The crate does not open files or set a
|
||||
//! file-size policy; callers handle bounded file I/O. [`File::read_from`] parses from a reader.
|
||||
|
||||
use serde::de::{self, IgnoredAny, MapAccess, SeqAccess, Visitor};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
@@ -37,62 +36,96 @@ pub const FORMAT_VERSION: u64 = 1;
|
||||
pub const HEADER_KEY: &str = "stencilcraft";
|
||||
|
||||
/// Wire fields in the flattened native document object.
|
||||
///
|
||||
/// The type parameters are the consuming app's typed model values. Field names, required fields,
|
||||
/// version-1 defaults, and omission of default canvas values remain owned by this crate.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DocumentFields {
|
||||
#[serde(bound(
|
||||
serialize = "Board: Serialize, Kerf: Serialize, Layers: Serialize, Image: Serialize, BoardSetup: Serialize + Default + PartialEq, Pages: Serialize + Default + PartialEq, Guides: Serialize + Default + PartialEq, Grid: Serialize + Default + PartialEq, Snap: Serialize + Default + PartialEq, Groups: Serialize + Default + PartialEq, Meta: Serialize + Default + PartialEq",
|
||||
deserialize = "Board: Deserialize<'de>, Kerf: Deserialize<'de>, Layers: Deserialize<'de>, Image: Deserialize<'de>, BoardSetup: Deserialize<'de> + Default, Pages: Deserialize<'de> + Default, Guides: Deserialize<'de> + Default, Grid: Deserialize<'de> + Default, Snap: Deserialize<'de> + Default, Groups: Deserialize<'de> + Default, Meta: Deserialize<'de> + Default"
|
||||
))]
|
||||
pub struct DocumentFields<
|
||||
Board = Value,
|
||||
Kerf = Value,
|
||||
Layers = Value,
|
||||
Image = Value,
|
||||
BoardSetup = Option<Value>,
|
||||
Pages = Option<Value>,
|
||||
Guides = Option<Value>,
|
||||
Grid = Option<Value>,
|
||||
Snap = Option<Value>,
|
||||
Groups = Option<Value>,
|
||||
Meta = Option<Value>,
|
||||
> {
|
||||
/// Required millimetre artboard dimensions.
|
||||
pub board: Value,
|
||||
pub board: Board,
|
||||
/// Required kerf settings.
|
||||
pub kerf: Value,
|
||||
pub kerf: Kerf,
|
||||
/// Required drawing layers.
|
||||
pub layers: Value,
|
||||
pub layers: Layers,
|
||||
/// Next document object id; absent in early version 1 files, where it defaults to 1.
|
||||
#[serde(rename = "next-shape-id", default = "default_next_shape_id")]
|
||||
pub next_shape_id: u64,
|
||||
/// Embedded raster images; absent in early version 1 files, where it defaults to empty.
|
||||
#[serde(default)]
|
||||
pub images: Vec<Value>,
|
||||
pub images: Vec<Image>,
|
||||
/// Optional canvas page setup, omitted when absent.
|
||||
#[serde(rename = "board-setup", default, skip_serializing_if = "Option::is_none")]
|
||||
pub board_setup: Option<Value>,
|
||||
#[serde(rename = "board-setup", default, skip_serializing_if = "is_default")]
|
||||
pub board_setup: BoardSetup,
|
||||
/// Optional pages, omitted when absent.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pages: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
pub pages: Pages,
|
||||
/// Optional guides, omitted when absent.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub guides: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
pub guides: Guides,
|
||||
/// Optional grid settings, omitted when absent.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub grid: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
pub grid: Grid,
|
||||
/// Optional snap settings, omitted when absent.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub snap: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
pub snap: Snap,
|
||||
/// Optional groups, omitted when absent.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub groups: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
pub groups: Groups,
|
||||
/// Optional user metadata, omitted when absent.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
pub meta: Meta,
|
||||
}
|
||||
|
||||
fn is_default<T: Default + PartialEq>(value: &T) -> bool {
|
||||
value == &T::default()
|
||||
}
|
||||
|
||||
fn default_next_shape_id() -> u64 {
|
||||
1
|
||||
}
|
||||
|
||||
/// A native document and its flattened wire fields.
|
||||
/// A native document and its flattened, typed wire fields.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct File {
|
||||
/// The fields after the header have been parsed, with app-domain objects still encoded as
|
||||
/// JSON values.
|
||||
pub document: DocumentFields,
|
||||
pub struct File<T = DocumentFields> {
|
||||
/// Typed document fields after the shared codec has checked the header and schema.
|
||||
pub document: T,
|
||||
}
|
||||
|
||||
impl File {
|
||||
impl<T> File<T> {
|
||||
/// Builds a native document from its wire fields.
|
||||
pub fn new(document: DocumentFields) -> Self {
|
||||
pub fn new(document: T) -> Self {
|
||||
Self { document }
|
||||
}
|
||||
|
||||
/// Parses JSON, validates the format header and version, then reads the wire fields.
|
||||
/// Writes the current format header and flattened wire fields as indented JSON.
|
||||
pub fn to_json_pretty(&self) -> Result<String, Error>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
let raw = RawFileRef { header: HeaderRef { name: FORMAT_NAME, version: FORMAT_VERSION }, document: &self.document };
|
||||
let value = serde_json::to_value(raw).map_err(Error::Json)?;
|
||||
serde_json::to_string_pretty(&value).map_err(Error::Json)
|
||||
}
|
||||
}
|
||||
|
||||
impl File<DocumentFields> {
|
||||
/// Parses JSON, validates the format header and version, then reads the untyped wire fields.
|
||||
pub fn read(text: &str) -> Result<Self, Error> {
|
||||
let wire: RawWire = serde_json::from_str(text).map_err(Error::Json)?;
|
||||
Self::from_wire(wire)
|
||||
@@ -118,15 +151,40 @@ impl File {
|
||||
if version != FORMAT_VERSION {
|
||||
return Err(Error::Version { found: version, supported: FORMAT_VERSION });
|
||||
}
|
||||
let raw: RawFile = serde_json::from_value(Value::Object(wire.fields)).map_err(|source| Error::Document { source })?;
|
||||
let raw: RawFile<DocumentFields> = serde_json::from_value(Value::Object(wire.fields)).map_err(|source| Error::Document { source })?;
|
||||
Ok(Self { document: raw.document })
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the current format header and flattened wire fields as indented JSON.
|
||||
pub fn to_json_pretty(&self) -> Result<String, Error> {
|
||||
let raw = RawFileRef { header: HeaderRef { name: FORMAT_NAME, version: FORMAT_VERSION }, document: &self.document };
|
||||
let value = serde_json::to_value(raw).map_err(Error::Json)?;
|
||||
serde_json::to_string_pretty(&value).map_err(Error::Json)
|
||||
impl<T> File<T>
|
||||
where
|
||||
T: for<'de> Deserialize<'de>,
|
||||
{
|
||||
/// Parses JSON into a typed document model after validating the shared format header.
|
||||
pub fn read_typed(text: &str) -> Result<Self, Error> {
|
||||
let wire: RawWire = serde_json::from_str(text).map_err(Error::Json)?;
|
||||
Self::from_typed_wire(wire)
|
||||
}
|
||||
|
||||
/// Parses JSON from a reader into a typed document model.
|
||||
pub fn read_typed_from(reader: impl Read) -> Result<Self, Error> {
|
||||
let wire: RawWire = serde_json::from_reader(reader).map_err(Error::Json)?;
|
||||
Self::from_typed_wire(wire)
|
||||
}
|
||||
|
||||
fn from_typed_wire(wire: RawWire) -> Result<Self, Error> {
|
||||
let header = wire.fields.get(HEADER_KEY).and_then(Value::as_object).ok_or(Error::NotStencilCraft)?;
|
||||
if header.get("name").and_then(Value::as_str) != Some(FORMAT_NAME) {
|
||||
return Err(Error::NotStencilCraft);
|
||||
}
|
||||
let Some(version) = header.get("version").and_then(Value::as_u64) else {
|
||||
return Err(Error::NotStencilCraft);
|
||||
};
|
||||
if version != FORMAT_VERSION {
|
||||
return Err(Error::Version { found: version, supported: FORMAT_VERSION });
|
||||
}
|
||||
let raw: RawFile<T> = serde_json::from_value(Value::Object(wire.fields)).map_err(|source| Error::Document { source })?;
|
||||
Ok(Self { document: raw.document })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,11 +195,11 @@ struct Header {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawFile {
|
||||
struct RawFile<T = DocumentFields> {
|
||||
#[serde(rename = "stencilcraft")]
|
||||
_header: Header,
|
||||
#[serde(flatten)]
|
||||
document: DocumentFields,
|
||||
document: T,
|
||||
}
|
||||
|
||||
/// A top-level streaming visitor that stores only fields owned by this wire schema.
|
||||
@@ -277,11 +335,11 @@ struct HeaderRef<'a> {
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RawFileRef<'a> {
|
||||
struct RawFileRef<'a, T> {
|
||||
#[serde(rename = "stencilcraft")]
|
||||
header: HeaderRef<'a>,
|
||||
#[serde(flatten)]
|
||||
document: &'a DocumentFields,
|
||||
document: &'a T,
|
||||
}
|
||||
|
||||
/// A file was malformed, belongs to another format, or uses an unsupported version.
|
||||
@@ -306,6 +364,7 @@ pub enum Error {
|
||||
mod tests {
|
||||
use std::io::{self, Cursor, Read};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{DocumentFields, Error, FORMAT_VERSION, File};
|
||||
@@ -411,4 +470,43 @@ mod tests {
|
||||
assert!(matches!(File::read("{"), Err(Error::Json(_))));
|
||||
assert!(matches!(File::read(r#"{"stencilcraft":{"name":"stencilcraft","version":1}}"#), Err(Error::Document { .. })));
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize, Deserialize)]
|
||||
struct TypedBoard {
|
||||
width_mm: f64,
|
||||
height_mm: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize, Deserialize)]
|
||||
struct TypedFields {
|
||||
board: TypedBoard,
|
||||
#[serde(default, rename = "board-setup", skip_serializing_if = "Option::is_none")]
|
||||
board_setup: Option<TypedBoard>,
|
||||
}
|
||||
|
||||
fn typed_fields() -> TypedFields {
|
||||
TypedFields { board: TypedBoard { width_mm: 100.0, height_mm: 150.0 }, board_setup: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_readers_round_trip_typed_fields_and_preserve_header_errors() -> Result<(), Error> {
|
||||
let file = File::new(typed_fields());
|
||||
let text = file.to_json_pretty()?;
|
||||
assert!(!text.contains("board-setup"));
|
||||
assert_eq!(File::read_typed(&text)?, file);
|
||||
assert_eq!(File::read_typed_from(Cursor::new(text.as_bytes()))?, file);
|
||||
|
||||
let malformed = r#"{"stencilcraft":{"name":"stencilcraft","version":1},"board":"wrong"}"#;
|
||||
assert!(matches!(File::<TypedFields>::read_typed(malformed), Err(Error::Document { .. })));
|
||||
assert!(matches!(File::<TypedFields>::read_typed_from(Cursor::new(malformed)), Err(Error::Document { .. })));
|
||||
|
||||
let wrong_header = r#"{"stencilcraft":{"name":"other","version":1},"board":{}}"#;
|
||||
assert!(matches!(File::<TypedFields>::read_typed(wrong_header), Err(Error::NotStencilCraft)));
|
||||
assert!(matches!(File::<TypedFields>::read_typed_from(Cursor::new(wrong_header)), Err(Error::NotStencilCraft)));
|
||||
|
||||
let future_version = r#"{"stencilcraft":{"name":"stencilcraft","version":2},"board":{}}"#;
|
||||
assert!(matches!(File::<TypedFields>::read_typed(future_version), Err(Error::Version { found: 2, supported: 1 })));
|
||||
assert!(matches!(File::<TypedFields>::read_typed_from(Cursor::new(future_version)), Err(Error::Version { found: 2, supported: 1 })));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user