mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 23:01:02 +00:00
Merge branch 'main' of github.com:storytold/vectorcraft into 5421-line-arrowhead
# Conflicts: # docs/target-app-parity.md
This commit is contained in:
Generated
+8
@@ -6367,6 +6367,13 @@ dependencies = [
|
||||
"ruzstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-apischema"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.21.0"
|
||||
@@ -6494,6 +6501,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-affinity",
|
||||
"vectorcraft-apischema",
|
||||
"vectorcraft-brush",
|
||||
"vectorcraft-cad",
|
||||
"vectorcraft-color",
|
||||
|
||||
@@ -55,6 +55,7 @@ vectorcraft-plugins = { path = "crates/plugins" }
|
||||
vectorcraft-affinity = { path = "crates/affinity" }
|
||||
vectorcraft-scriptcheck = { path = "crates/scriptcheck" }
|
||||
vectorcraft-js = { path = "crates/js" }
|
||||
vectorcraft-apischema = { path = "crates/apischema" }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "vectorcraft-apischema"
|
||||
description = "Machine-readable parameter schemas for VectorCraft commands, with a validator and a params text writer"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,174 @@
|
||||
//! For tests: a check of JSON Schema 2020-12 documents, and a validator of instances against them,
|
||||
//! written from the keyword rules of the Core and Validation specifications
|
||||
//! (<https://json-schema.org/draft/2020-12>). It reads only the JSON and shares no code with the
|
||||
//! writer it checks. It covers the keywords the writer uses and reports any other keyword that
|
||||
//! isn't an `x-` annotation.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
const TYPES: &[&str] = &["null", "boolean", "object", "array", "number", "string", "integer"];
|
||||
|
||||
fn unique(items: &[Value]) -> bool {
|
||||
items.iter().enumerate().all(|(i, x)| items.iter().take(i).all(|y| y != x))
|
||||
}
|
||||
|
||||
fn schema_list(items: &Value, at: &str, key: &str) -> Result<(), String> {
|
||||
let list = items.as_array().filter(|l| !l.is_empty()).ok_or_else(|| format!("{at}: `{key}` must be a nonempty array"))?;
|
||||
for (i, x) in list.iter().enumerate() {
|
||||
check_schema(x, &format!("{at}/{key}/{i}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether `schema` follows the 2020-12 rules for the keywords it uses, with where it doesn't.
|
||||
pub fn check_schema(schema: &Value, at: &str) -> Result<(), String> {
|
||||
let object = match schema {
|
||||
Value::Bool(_) => return Ok(()),
|
||||
Value::Object(o) => o,
|
||||
other => return Err(format!("{at}: a schema must be an object or a boolean, not {other}")),
|
||||
};
|
||||
for (key, value) in object {
|
||||
let number = || if value.is_number() { Ok(()) } else { Err(format!("{at}: `{key}` must be a number")) };
|
||||
match key.as_str() {
|
||||
"$schema" => {
|
||||
let uri = value.as_str().ok_or_else(|| format!("{at}: `$schema` must be a string"))?;
|
||||
if !uri.contains("://") || uri.ends_with('#') {
|
||||
return Err(format!("{at}: `$schema` must be a normalized URI"));
|
||||
}
|
||||
}
|
||||
"type" => {
|
||||
let known = |v: &Value| v.as_str().is_some_and(|s| TYPES.contains(&s));
|
||||
let ok = known(value) || value.as_array().is_some_and(|l| !l.is_empty() && unique(l) && l.iter().all(known));
|
||||
if !ok {
|
||||
return Err(format!("{at}: `type` must name JSON types, not {value}"));
|
||||
}
|
||||
}
|
||||
"enum" => {
|
||||
if !value.as_array().is_some_and(|l| !l.is_empty() && unique(l)) {
|
||||
return Err(format!("{at}: `enum` must be a nonempty array of unique values"));
|
||||
}
|
||||
}
|
||||
"properties" => {
|
||||
let props = value.as_object().ok_or_else(|| format!("{at}: `properties` must be an object"))?;
|
||||
for (name, x) in props {
|
||||
check_schema(x, &format!("{at}/properties/{name}"))?;
|
||||
}
|
||||
}
|
||||
"required" => {
|
||||
if !value.as_array().is_some_and(|l| unique(l) && l.iter().all(Value::is_string)) {
|
||||
return Err(format!("{at}: `required` must be an array of unique strings"));
|
||||
}
|
||||
}
|
||||
"additionalProperties" | "items" => check_schema(value, &format!("{at}/{key}"))?,
|
||||
"prefixItems" | "anyOf" => schema_list(value, at, key)?,
|
||||
"minimum" | "maximum" | "exclusiveMinimum" | "exclusiveMaximum" => number()?,
|
||||
"minItems" => {
|
||||
if value.as_u64().is_none() {
|
||||
return Err(format!("{at}: `minItems` must be a non-negative integer"));
|
||||
}
|
||||
}
|
||||
"description" => {
|
||||
if !value.is_string() {
|
||||
return Err(format!("{at}: `description` must be a string"));
|
||||
}
|
||||
}
|
||||
"deprecated" => {
|
||||
if !value.is_boolean() {
|
||||
return Err(format!("{at}: `deprecated` must be a boolean"));
|
||||
}
|
||||
}
|
||||
"default" => {
|
||||
if !instance_valid(schema, value) {
|
||||
return Err(format!("{at}: `default` {value} does not validate against its schema"));
|
||||
}
|
||||
}
|
||||
k if k.starts_with("x-") => {}
|
||||
other => return Err(format!("{at}: unexpected keyword `{other}`")),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn has_type(v: &Value, name: &str) -> bool {
|
||||
match name {
|
||||
"null" => v.is_null(),
|
||||
"boolean" => v.is_boolean(),
|
||||
"object" => v.is_object(),
|
||||
"array" => v.is_array(),
|
||||
"string" => v.is_string(),
|
||||
"number" => v.is_number(),
|
||||
"integer" => v.is_i64() || v.is_u64() || v.as_f64().is_some_and(|x| x.fract() == 0.0),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the instance `v` is valid against `schema`.
|
||||
pub fn instance_valid(schema: &Value, v: &Value) -> bool {
|
||||
let s = match schema {
|
||||
Value::Bool(b) => return *b,
|
||||
Value::Object(o) => o,
|
||||
_ => return false,
|
||||
};
|
||||
let type_ok = match s.get("type") {
|
||||
None => true,
|
||||
Some(Value::String(t)) => has_type(v, t),
|
||||
Some(Value::Array(l)) => l.iter().filter_map(Value::as_str).any(|t| has_type(v, t)),
|
||||
Some(_) => false,
|
||||
};
|
||||
if !type_ok {
|
||||
return false;
|
||||
}
|
||||
if let Some(list) = s.get("enum").and_then(Value::as_array)
|
||||
&& !list.contains(v)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(any) = s.get("anyOf").and_then(Value::as_array)
|
||||
&& !any.iter().any(|x| instance_valid(x, v))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(x) = v.as_f64() {
|
||||
let bound = |key: &str| s.get(key).and_then(Value::as_f64);
|
||||
if bound("minimum").is_some_and(|m| x < m)
|
||||
|| bound("maximum").is_some_and(|m| x > m)
|
||||
|| bound("exclusiveMinimum").is_some_and(|m| x <= m)
|
||||
|| bound("exclusiveMaximum").is_some_and(|m| x >= m)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(items) = v.as_array() {
|
||||
if s.get("minItems").and_then(Value::as_u64).is_some_and(|n| (items.len() as u64) < n) {
|
||||
return false;
|
||||
}
|
||||
let prefix = s.get("prefixItems").and_then(Value::as_array).map_or(&[][..], Vec::as_slice);
|
||||
if items.iter().zip(prefix).any(|(x, p)| !instance_valid(p, x)) {
|
||||
return false;
|
||||
}
|
||||
if let Some(rest) = s.get("items")
|
||||
&& items.iter().skip(prefix.len()).any(|x| !instance_valid(rest, x))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(object) = v.as_object() {
|
||||
let required = s.get("required").and_then(Value::as_array).map_or(&[][..], Vec::as_slice);
|
||||
if required.iter().filter_map(Value::as_str).any(|k| !object.contains_key(k)) {
|
||||
return false;
|
||||
}
|
||||
let props = s.get("properties").and_then(Value::as_object);
|
||||
for (key, x) in object {
|
||||
match props.and_then(|p| p.get(key)) {
|
||||
Some(p) if !instance_valid(p, x) => return false,
|
||||
Some(_) => {}
|
||||
None => {
|
||||
if s.get("additionalProperties").is_some_and(|a| !instance_valid(a, x)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -0,0 +1,940 @@
|
||||
//! Machine-readable parameter schemas for VectorCraft commands. A [`ParamSchema`] lists what each
|
||||
//! parameter of a command is ([`Param`], [`Type`]): its type, whether it is required, what an
|
||||
//! omitted value means, whether null counts as omitted, and the API levels it was added and
|
||||
//! deprecated at. From one schema come:
|
||||
//!
|
||||
//! - [`ParamSchema::validate`], which checks a params object and names the parameter of the first
|
||||
//! problem it finds ([`ParamError`]), and fills in the defaults of omitted values;
|
||||
//! - [`ParamSchema::definition_problem`], which checks the schema itself;
|
||||
//! - [`ParamSchema::params_text`], the params text a command lists;
|
||||
//! - [`ParamSchema::to_json`], the schema as a JSON object in the shape of a JSON Schema 2020-12
|
||||
//! object schema (<https://json-schema.org/draft/2020-12>), with VectorCraft's own fields as
|
||||
//! `x-` keywords;
|
||||
//! - [`hash`], a hash of a set of command schemas, which changes whenever any of them changes.
|
||||
//!
|
||||
//! Checking a value walks the schema, so its depth is the schema's, at most [`MAX_DEPTH`]. An error
|
||||
//! shows a bounded part of a refused value and of a refused key, and the value is not written out
|
||||
//! first, so a huge or deeply nested value costs no more to refuse than a small one.
|
||||
//!
|
||||
//! The crate knows no command and depends on no other VectorCraft crate.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
/// The JSON Schema dialect [`ParamSchema::to_json`] names in `$schema`.
|
||||
pub const DIALECT: &str = "https://json-schema.org/draft/2020-12/schema";
|
||||
|
||||
/// How many objects and lists may nest inside a schema's params object.
|
||||
pub const MAX_DEPTH: usize = 16;
|
||||
|
||||
/// The largest object id [`Type::Id`] takes: 2^53 - 1, the largest integer every JSON reader holds
|
||||
/// exactly.
|
||||
pub const MAX_ID: u64 = (1 << 53) - 1;
|
||||
|
||||
/// How many characters of a refused value an error shows.
|
||||
const SHOWN_CHARS: usize = 40;
|
||||
|
||||
/// How many characters of a key an error shows.
|
||||
const KEY_CHARS: usize = 64;
|
||||
|
||||
/// One end of a number's range.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Bound {
|
||||
/// The value may equal the bound.
|
||||
Inclusive(f64),
|
||||
/// The value must be past the bound.
|
||||
Exclusive(f64),
|
||||
}
|
||||
|
||||
/// The type of a parameter's value.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Type {
|
||||
/// A JSON number in the range the bounds give, measured in `unit` (`"pt"`, `"deg"`) when set.
|
||||
Number { min: Option<Bound>, max: Option<Bound>, unit: Option<&'static str> },
|
||||
/// A JSON number with no fractional part, in the range given. [`ParamSchema::validate`] gives
|
||||
/// the command `2.0` as `2`.
|
||||
Integer { min: Option<i64>, max: Option<i64> },
|
||||
/// `true` or `false`.
|
||||
Boolean,
|
||||
/// Any string.
|
||||
String,
|
||||
/// One of the strings listed.
|
||||
Enum(&'static [&'static str]),
|
||||
/// An object id: an integer from 0 to [`MAX_ID`], read as [`Type::Integer`] reads one.
|
||||
Id,
|
||||
/// A list of object ids.
|
||||
Ids,
|
||||
/// A color: a string (`"#rrggbb"`, a color key), `[r, g, b]` or an object. The command reads
|
||||
/// which color it is.
|
||||
Color,
|
||||
/// A point: `[x, y]`, two numbers.
|
||||
Point,
|
||||
/// An object with the parameters listed and no other key.
|
||||
Object(&'static [Param]),
|
||||
/// A list of values of one type.
|
||||
Array(&'static Type),
|
||||
}
|
||||
|
||||
impl Type {
|
||||
/// A number with no range and no unit.
|
||||
pub const NUMBER: Type = Type::Number { min: None, max: None, unit: None };
|
||||
}
|
||||
|
||||
/// What an omitted value means.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Omitted {
|
||||
/// The parameter is required, or the command describes the omitted case in its summary.
|
||||
Nothing,
|
||||
/// The value, as JSON text, that [`ParamSchema::validate`] fills in.
|
||||
Value(&'static str),
|
||||
/// A default the command works out itself (`"toggle"`, `"the selected text"`).
|
||||
Note(&'static str),
|
||||
}
|
||||
|
||||
/// What a command changes when it runs.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Writes {
|
||||
/// Nothing: the command only reads.
|
||||
Nothing,
|
||||
/// State of the running app outside the document and the preferences (view state, the Caps
|
||||
/// Lock state).
|
||||
Session,
|
||||
/// The preferences.
|
||||
Preferences,
|
||||
/// The document, with an undo step.
|
||||
Document,
|
||||
}
|
||||
|
||||
impl Writes {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Writes::Nothing => "nothing",
|
||||
Writes::Session => "session",
|
||||
Writes::Preferences => "preferences",
|
||||
Writes::Document => "document",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One parameter of a command.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Param {
|
||||
pub name: &'static str,
|
||||
pub ty: Type,
|
||||
pub required: bool,
|
||||
pub omitted: Omitted,
|
||||
/// A null value is read as if the parameter were omitted. Otherwise null is refused as a value
|
||||
/// of the wrong type.
|
||||
pub null_omitted: bool,
|
||||
/// What the params text says about the value, in parentheses after its type.
|
||||
pub doc: &'static str,
|
||||
/// The API level the parameter was added at.
|
||||
pub since: u32,
|
||||
/// The API level the parameter was deprecated at.
|
||||
pub deprecated: Option<u32>,
|
||||
/// A value of the wrong type or out of range is not refused here: the command reads it as it
|
||||
/// did before it had a schema. [`ParamSchema::to_json`] still writes the documented type, so a
|
||||
/// JSON Schema validator refuses a value this passes.
|
||||
pub lenient: bool,
|
||||
}
|
||||
|
||||
impl Param {
|
||||
/// A required parameter, added at API level 1.
|
||||
pub const fn new(name: &'static str, ty: Type) -> Param {
|
||||
Param { name, ty, required: true, omitted: Omitted::Nothing, null_omitted: false, doc: "", since: 1, deprecated: None, lenient: false }
|
||||
}
|
||||
/// The parameter may be omitted.
|
||||
pub const fn optional(self) -> Param {
|
||||
Param { required: false, ..self }
|
||||
}
|
||||
/// The parameter may be omitted, and then takes the value `json` (JSON text).
|
||||
pub const fn defaults_to(self, json: &'static str) -> Param {
|
||||
Param { required: false, omitted: Omitted::Value(json), ..self }
|
||||
}
|
||||
/// The parameter may be omitted, and the command then works out the value `note` describes.
|
||||
pub const fn default_note(self, note: &'static str) -> Param {
|
||||
Param { required: false, omitted: Omitted::Note(note), ..self }
|
||||
}
|
||||
/// A null value reads as an omitted one.
|
||||
pub const fn null_omitted(self) -> Param {
|
||||
Param { null_omitted: true, ..self }
|
||||
}
|
||||
pub const fn doc(self, doc: &'static str) -> Param {
|
||||
Param { doc, ..self }
|
||||
}
|
||||
pub const fn since(self, level: u32) -> Param {
|
||||
Param { since: level, ..self }
|
||||
}
|
||||
pub const fn deprecated(self, level: u32) -> Param {
|
||||
Param { deprecated: Some(level), ..self }
|
||||
}
|
||||
pub const fn lenient(self) -> Param {
|
||||
Param { lenient: true, ..self }
|
||||
}
|
||||
}
|
||||
|
||||
/// The parameters of one command, and what the command does with them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ParamSchema {
|
||||
pub params: &'static [Param],
|
||||
/// What the command does, after the params object in the params text.
|
||||
pub summary: &'static str,
|
||||
/// What the command returns, after `→` in the params text; empty when the text leaves it out.
|
||||
pub returns: &'static str,
|
||||
/// The API level the command was added at.
|
||||
pub since: u32,
|
||||
/// The API level the command was deprecated at.
|
||||
pub deprecated: Option<u32>,
|
||||
/// What one run costs, in units of a light command (1), for callers that limit a script's work.
|
||||
pub cost: u32,
|
||||
pub writes: Writes,
|
||||
}
|
||||
|
||||
impl ParamSchema {
|
||||
/// A command added at API level 1, costing 1, that changes the document.
|
||||
pub const fn new(params: &'static [Param], summary: &'static str) -> ParamSchema {
|
||||
ParamSchema { params, summary, returns: "", since: 1, deprecated: None, cost: 1, writes: Writes::Document }
|
||||
}
|
||||
pub const fn returns(self, returns: &'static str) -> ParamSchema {
|
||||
ParamSchema { returns, ..self }
|
||||
}
|
||||
pub const fn writes(self, writes: Writes) -> ParamSchema {
|
||||
ParamSchema { writes, ..self }
|
||||
}
|
||||
pub const fn cost(self, cost: u32) -> ParamSchema {
|
||||
ParamSchema { cost, ..self }
|
||||
}
|
||||
pub const fn since(self, level: u32) -> ParamSchema {
|
||||
ParamSchema { since: level, ..self }
|
||||
}
|
||||
pub const fn deprecated(self, level: u32) -> ParamSchema {
|
||||
ParamSchema { deprecated: Some(level), ..self }
|
||||
}
|
||||
|
||||
/// The parameter named `name`.
|
||||
pub fn param(&self, name: &str) -> Option<&'static Param> {
|
||||
self.params.iter().find(|p| p.name == name)
|
||||
}
|
||||
|
||||
/// The names of the top-level parameters, in order.
|
||||
pub fn names(&self) -> Vec<&'static str> {
|
||||
self.params.iter().map(|p| p.name).collect()
|
||||
}
|
||||
|
||||
/// `params` checked against the schema: the first problem as an error naming the parameter,
|
||||
/// or, when it has none, `params` with the defaults of its omitted values filled in (`None`
|
||||
/// when no default was filled in, so `params` is used as it is). Null params read as `{}`.
|
||||
pub fn validate(&self, params: &Value) -> Result<Option<Value>, ParamError> {
|
||||
self.validate_as(params, false)
|
||||
}
|
||||
|
||||
/// [`ParamSchema::validate`] for saved steps recorded by this version or an older one: a key
|
||||
/// the schema doesn't name passes, at any depth, and the command does not read it. Values of
|
||||
/// the parameters the schema names are checked as `validate` checks them.
|
||||
pub fn validate_saved(&self, params: &Value) -> Result<Option<Value>, ParamError> {
|
||||
self.validate_as(params, true)
|
||||
}
|
||||
|
||||
fn validate_as(&self, params: &Value, saved: bool) -> Result<Option<Value>, ParamError> {
|
||||
static EMPTY: std::sync::OnceLock<Map<String, Value>> = std::sync::OnceLock::new();
|
||||
let given = match params {
|
||||
Value::Object(o) => o,
|
||||
Value::Null => EMPTY.get_or_init(Map::new),
|
||||
v => return Err(ParamError { path: String::new(), problem: Problem::NotAnObject { got: shown(v) } }),
|
||||
};
|
||||
Ok(check_object(&At::Root, 0, self.params, given, saved)?.map(Value::Object))
|
||||
}
|
||||
|
||||
/// What is wrong with the schema itself, or `None`: a parameter name that is empty, repeated
|
||||
/// or holds `.`, `[` or `]`; a required parameter with a default; a default that isn't JSON or
|
||||
/// doesn't pass its parameter's type; an enum with no names or a repeated name; a range with
|
||||
/// an end that isn't finite or that no value can be in; nesting past [`MAX_DEPTH`].
|
||||
pub fn definition_problem(&self) -> Option<String> {
|
||||
object_problem("", self.params, 0)
|
||||
}
|
||||
|
||||
/// The params text: the params object (`{name?: string, zoom: number > 0}`), then the summary,
|
||||
/// then `→` and what the command returns.
|
||||
pub fn params_text(&self) -> String {
|
||||
let mut s = object_text(self.params, 0);
|
||||
if !self.summary.is_empty() {
|
||||
s.push(' ');
|
||||
s.push_str(self.summary);
|
||||
}
|
||||
if !self.returns.is_empty() {
|
||||
s.push_str(" → ");
|
||||
s.push_str(self.returns);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The schema as a JSON Schema 2020-12 object schema. Defaults written as JSON go in
|
||||
/// `default`, a parameter that accepts null as omitted is an `anyOf` with `{"type": "null"}`,
|
||||
/// and VectorCraft's own fields are `x-since`, `x-deprecatedSince`, `x-lenient`, `x-unit`,
|
||||
/// `x-kind`, `x-cost`, `x-writes` and `x-returns`. The root `type` is `["object", "null"]` when
|
||||
/// no parameter is required, because null params read as `{}`, and `"object"` otherwise. A JSON
|
||||
/// Schema validator accepts the values [`ParamSchema::validate`] accepts, except that an
|
||||
/// `x-lenient` parameter's schema states only the documented type.
|
||||
pub fn to_json(&self) -> Value {
|
||||
let mut o = object_schema(self.params, 0);
|
||||
if !self.params.iter().any(|p| p.required) {
|
||||
o.insert("type".into(), json!(["object", "null"]));
|
||||
}
|
||||
o.insert("$schema".into(), DIALECT.into());
|
||||
if !self.summary.is_empty() {
|
||||
o.insert("description".into(), self.summary.into());
|
||||
}
|
||||
if !self.returns.is_empty() {
|
||||
o.insert("x-returns".into(), self.returns.into());
|
||||
}
|
||||
o.insert("x-since".into(), self.since.into());
|
||||
if let Some(level) = self.deprecated {
|
||||
o.insert("deprecated".into(), true.into());
|
||||
o.insert("x-deprecatedSince".into(), level.into());
|
||||
}
|
||||
o.insert("x-cost".into(), self.cost.into());
|
||||
o.insert("x-writes".into(), self.writes.name().into());
|
||||
Value::Object(o)
|
||||
}
|
||||
}
|
||||
|
||||
/// A problem [`ParamSchema::validate`] found, at `path`: the parameter's name, with the keys and
|
||||
/// indices leading to a value inside one (`edits[1].name`). The path is empty for the params
|
||||
/// object itself.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ParamError {
|
||||
pub path: String,
|
||||
pub problem: Problem,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Problem {
|
||||
/// The params are not an object.
|
||||
NotAnObject { got: String },
|
||||
/// The key at `path` is not a parameter of the object it is in, which takes `keys`.
|
||||
Unknown { keys: Vec<&'static str> },
|
||||
/// A required parameter is missing.
|
||||
Missing,
|
||||
/// The value is not of the parameter's type: `expected` says what it must be.
|
||||
WrongType { expected: String, got: String },
|
||||
/// The schema nests deeper than [`MAX_DEPTH`] here ([`ParamSchema::definition_problem`]).
|
||||
TooDeep,
|
||||
}
|
||||
|
||||
impl fmt::Display for ParamError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let path = &self.path;
|
||||
match &self.problem {
|
||||
Problem::NotAnObject { got } => write!(f, "the params must be an object, not {got}"),
|
||||
Problem::Unknown { keys } if keys.is_empty() => write!(f, "unknown parameter `{path}`; the object takes no keys"),
|
||||
Problem::Unknown { keys } => write!(f, "unknown parameter `{path}`; it takes: {}", keys.join(", ")),
|
||||
Problem::Missing => write!(f, "missing `{path}`"),
|
||||
Problem::WrongType { expected, got } => write!(f, "`{path}` must be {expected}, not {got}"),
|
||||
Problem::TooDeep => write!(f, "`{path}` is nested too deeply"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ParamError {}
|
||||
|
||||
/// FNV-1a over the bytes of `text`, from `h`.
|
||||
fn fnv1a(mut h: u64, text: &str) -> u64 {
|
||||
for b in text.bytes() {
|
||||
h ^= u64::from(b);
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// `v` as JSON text with the keys of each object in sorted order, whichever order the map keeps
|
||||
/// them in.
|
||||
fn canonical(v: &Value, out: &mut String) {
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
let mut keys: Vec<&String> = o.keys().collect();
|
||||
keys.sort();
|
||||
out.push('{');
|
||||
for (i, k) in keys.into_iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str(&Value::from(k.as_str()).to_string());
|
||||
out.push(':');
|
||||
if let Some(x) = o.get(k) {
|
||||
canonical(x, out);
|
||||
}
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
Value::Array(a) => {
|
||||
out.push('[');
|
||||
for (i, x) in a.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
canonical(x, out);
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
other => out.push_str(&other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A 64-bit FNV-1a hash of the commands' schemas, taken in the order of their ids: each id and
|
||||
/// its [`ParamSchema::to_json`] text, with the keys of each object sorted. Any change to a schema,
|
||||
/// or a schema added or removed, changes it.
|
||||
pub fn hash<'a>(schemas: impl IntoIterator<Item = (&'a str, &'a ParamSchema)>) -> u64 {
|
||||
let mut all: Vec<(&str, &ParamSchema)> = schemas.into_iter().collect();
|
||||
all.sort_by_key(|(id, _)| *id);
|
||||
all.iter().fold(0xcbf2_9ce4_8422_2325, |h, (id, schema)| {
|
||||
let mut text = String::new();
|
||||
canonical(&schema.to_json(), &mut text);
|
||||
let h = fnv1a(h, id);
|
||||
let h = fnv1a(h, "\n");
|
||||
let h = fnv1a(h, &text);
|
||||
fnv1a(h, "\n")
|
||||
})
|
||||
}
|
||||
|
||||
/// `key` as an error message shows it: cut to [`KEY_CHARS`] characters.
|
||||
pub fn shown_key(key: &str) -> String {
|
||||
let mut chars = key.chars();
|
||||
let head: String = chars.by_ref().take(KEY_CHARS).collect();
|
||||
if chars.next().is_some() { format!("{head}…") } else { head }
|
||||
}
|
||||
|
||||
/// The JSON text of a value, stopping when its characters run out.
|
||||
struct Shown {
|
||||
text: String,
|
||||
left: usize,
|
||||
}
|
||||
|
||||
impl Shown {
|
||||
/// Adds `s`; false when it didn't fit.
|
||||
fn push(&mut self, s: &str) -> bool {
|
||||
for c in s.chars() {
|
||||
if self.left == 0 {
|
||||
return false;
|
||||
}
|
||||
self.left -= 1;
|
||||
self.text.push(c);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Adds the JSON text of string `s`, reading no more of it than can be shown.
|
||||
fn push_string(&mut self, s: &str) -> bool {
|
||||
let head: String = s.chars().take(self.left + 1).collect();
|
||||
self.push(&Value::from(head).to_string())
|
||||
}
|
||||
|
||||
/// Adds the JSON text of `v`; false when it didn't fit. Each object or list writes a bracket,
|
||||
/// so the characters left bound the recursion.
|
||||
fn push_value(&mut self, v: &Value) -> bool {
|
||||
match v {
|
||||
Value::String(s) => self.push_string(s),
|
||||
Value::Array(items) => {
|
||||
self.push("[") && items.iter().enumerate().all(|(i, x)| (i == 0 || self.push(",")) && self.push_value(x)) && self.push("]")
|
||||
}
|
||||
Value::Object(o) => {
|
||||
self.push("{")
|
||||
&& o.iter()
|
||||
.enumerate()
|
||||
.all(|(i, (k, x))| (i == 0 || self.push(",")) && self.push_string(k) && self.push(":") && self.push_value(x))
|
||||
&& self.push("}")
|
||||
}
|
||||
other => self.push(&other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A value as an error message shows it: its JSON, cut to [`SHOWN_CHARS`] characters. Only what is
|
||||
/// shown is read, however large the value is.
|
||||
fn shown(v: &Value) -> String {
|
||||
let mut out = Shown { text: String::new(), left: SHOWN_CHARS };
|
||||
if !out.push_value(v) {
|
||||
out.text.push('…');
|
||||
}
|
||||
out.text
|
||||
}
|
||||
|
||||
fn join(path: &str, key: &str) -> String {
|
||||
if path.is_empty() { key.to_string() } else { format!("{path}.{key}") }
|
||||
}
|
||||
|
||||
/// Where a value is, from the value up to the params object. The path's text is written only for
|
||||
/// an error.
|
||||
#[derive(Clone, Copy)]
|
||||
enum At<'a> {
|
||||
Root,
|
||||
Key(&'a At<'a>, &'a str),
|
||||
Index(&'a At<'a>, usize),
|
||||
}
|
||||
|
||||
impl At<'_> {
|
||||
/// The path, `edits[1].name`, with each key as [`shown_key`] shows it.
|
||||
fn text(&self) -> String {
|
||||
let mut steps = Vec::new();
|
||||
let mut at = self;
|
||||
while let At::Key(up, _) | At::Index(up, _) = at {
|
||||
steps.push(at);
|
||||
at = up;
|
||||
}
|
||||
let mut out = String::new();
|
||||
for step in steps.into_iter().rev() {
|
||||
match step {
|
||||
At::Key(_, key) => {
|
||||
if !out.is_empty() {
|
||||
out.push('.');
|
||||
}
|
||||
out.push_str(&shown_key(key));
|
||||
}
|
||||
At::Index(_, i) => out.push_str(&format!("[{i}]")),
|
||||
At::Root => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `v` nests no deeper than [`MAX_DEPTH`] counted from `depth`. The walk stops at the
|
||||
/// limit, so it recurses at most [`MAX_DEPTH`] levels and allocates nothing.
|
||||
fn nests_within(v: &Value, depth: usize) -> bool {
|
||||
match v {
|
||||
Value::Array(a) => depth < MAX_DEPTH && a.iter().all(|x| nests_within(x, depth + 1)),
|
||||
Value::Object(o) => depth < MAX_DEPTH && o.values().all(|x| nests_within(x, depth + 1)),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The object `given` checked against `params` (unknown keys first, then each parameter in
|
||||
/// order): with the defaults of omitted values filled in when there are any. `saved` keeps a key
|
||||
/// the schema doesn't name, unless its value nests past [`MAX_DEPTH`].
|
||||
fn check_object(
|
||||
at: &At<'_>,
|
||||
depth: usize,
|
||||
params: &'static [Param],
|
||||
given: &Map<String, Value>,
|
||||
saved: bool,
|
||||
) -> Result<Option<Map<String, Value>>, ParamError> {
|
||||
let refused = |key: &String, v: &Value| !params.iter().any(|p| p.name == key.as_str()) && (!saved || !nests_within(v, depth));
|
||||
if let Some((key, _)) = given.iter().find(|(k, v)| refused(k, v)) {
|
||||
return Err(ParamError { path: At::Key(at, key).text(), problem: Problem::Unknown { keys: params.iter().map(|p| p.name).collect() } });
|
||||
}
|
||||
// The changes are applied once every value has passed, so no refused value is ever copied.
|
||||
let mut changes: Vec<(&'static str, Value)> = Vec::new();
|
||||
for p in params {
|
||||
let here = At::Key(at, p.name);
|
||||
match given.get(p.name) {
|
||||
Some(Value::Null) if p.null_omitted => {}
|
||||
Some(v) => {
|
||||
if let Some(changed) = check_value(&here, depth, &p.ty, v, p.lenient, saved)? {
|
||||
changes.push((p.name, changed));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
None if p.required => return Err(ParamError { path: here.text(), problem: Problem::Missing }),
|
||||
None => {}
|
||||
}
|
||||
// Omitted, or null read as omitted.
|
||||
if let Omitted::Value(json) = p.omitted
|
||||
&& let Ok(v) = serde_json::from_str::<Value>(json)
|
||||
{
|
||||
changes.push((p.name, v));
|
||||
}
|
||||
}
|
||||
if changes.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut filled = given.clone();
|
||||
for (name, v) in changes {
|
||||
filled.insert(name.to_string(), v);
|
||||
}
|
||||
Ok(Some(filled))
|
||||
}
|
||||
|
||||
/// What a value of type `ty` must be, as an error message says it.
|
||||
fn expected(ty: &Type) -> String {
|
||||
match ty {
|
||||
Type::Number { min, max, unit } => {
|
||||
format!("a number{}{}", range_text(min.as_ref(), max.as_ref()), unit.map(|u| format!(" ({u})")).unwrap_or_default())
|
||||
}
|
||||
Type::Integer { min, max } => {
|
||||
format!("an integer{}", range_text(min.map(|m| Bound::Inclusive(m as f64)).as_ref(), max.map(|m| Bound::Inclusive(m as f64)).as_ref()))
|
||||
}
|
||||
Type::Boolean => "true or false".into(),
|
||||
Type::String => "a string".into(),
|
||||
Type::Enum(names) => format!("one of {}", names.iter().map(|n| format!("\"{n}\"")).collect::<Vec<_>>().join(", ")),
|
||||
Type::Id => "an object id (a non-negative integer)".into(),
|
||||
Type::Ids => "a list of object ids".into(),
|
||||
Type::Color => "a color (a string, [r, g, b] or an object)".into(),
|
||||
Type::Point => "[x, y] (two numbers)".into(),
|
||||
Type::Object(_) => "an object".into(),
|
||||
Type::Array(_) => "a list".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// ` > 0`, ` 0 to 100`, ` >= 1 and < 10`: the range text after a number's type.
|
||||
fn range_text(min: Option<&Bound>, max: Option<&Bound>) -> String {
|
||||
match (min, max) {
|
||||
(Some(Bound::Inclusive(a)), Some(Bound::Inclusive(b))) => format!(" {a} to {b}"),
|
||||
(Some(a), Some(b)) => format!("{} and{}", low_text(a), high_text(b)),
|
||||
(Some(a), None) => low_text(a),
|
||||
(None, Some(b)) => high_text(b),
|
||||
(None, None) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn low_text(b: &Bound) -> String {
|
||||
match b {
|
||||
Bound::Inclusive(a) => format!(" >= {a}"),
|
||||
Bound::Exclusive(a) => format!(" > {a}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn high_text(b: &Bound) -> String {
|
||||
match b {
|
||||
Bound::Inclusive(a) => format!(" <= {a}"),
|
||||
Bound::Exclusive(a) => format!(" < {a}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn in_range(x: f64, min: Option<&Bound>, max: Option<&Bound>) -> bool {
|
||||
let low = match min {
|
||||
Some(Bound::Inclusive(a)) => x >= *a,
|
||||
Some(Bound::Exclusive(a)) => x > *a,
|
||||
None => true,
|
||||
};
|
||||
let high = match max {
|
||||
Some(Bound::Inclusive(b)) => x <= *b,
|
||||
Some(Bound::Exclusive(b)) => x < *b,
|
||||
None => true,
|
||||
};
|
||||
low && high
|
||||
}
|
||||
|
||||
/// The integer a JSON number with no fractional part stands for, as an integer value when it fits
|
||||
/// in one (`2.0` is `2`); `None` for anything else.
|
||||
fn whole(v: &Value) -> Option<Value> {
|
||||
if v.is_i64() || v.is_u64() {
|
||||
return Some(v.clone());
|
||||
}
|
||||
const TWO_63: f64 = 9_223_372_036_854_775_808.0;
|
||||
const TWO_64: f64 = 18_446_744_073_709_551_616.0;
|
||||
let x = v.as_f64().filter(|x| x.fract() == 0.0)?;
|
||||
Some(if (-TWO_63..TWO_63).contains(&x) {
|
||||
json!(x as i64)
|
||||
} else if (0.0..TWO_64).contains(&x) {
|
||||
json!(x as u64)
|
||||
} else {
|
||||
v.clone()
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the [`whole`] value `w` is in the range.
|
||||
fn whole_in_range(w: &Value, min: Option<i64>, max: Option<i64>) -> bool {
|
||||
match (w.as_i64(), w.as_u64(), w.as_f64()) {
|
||||
(Some(x), _, _) => min.is_none_or(|m| x >= m) && max.is_none_or(|m| x <= m),
|
||||
// Past i64::MAX: above any i64 bound.
|
||||
(None, Some(_), _) => max.is_none(),
|
||||
// Past what u64 holds: above any i64 bound.
|
||||
(None, None, Some(x)) if x > 0.0 => max.is_none(),
|
||||
// Below what i64 holds: under any i64 bound, so it meets every maximum and no minimum.
|
||||
(None, None, Some(_)) => min.is_none(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_number(v: &Value) -> bool {
|
||||
v.as_f64().is_some_and(f64::is_finite)
|
||||
}
|
||||
|
||||
/// `v` checked against `ty` at `at`: with the defaults of omitted values inside it filled in, and
|
||||
/// whole numbers written with a fraction made integers, when there are any. A lenient value of
|
||||
/// the wrong type passes as it is, unless it nests past [`MAX_DEPTH`].
|
||||
fn check_value(at: &At<'_>, depth: usize, ty: &Type, v: &Value, lenient: bool, saved: bool) -> Result<Option<Value>, ParamError> {
|
||||
let done = |ok: bool| {
|
||||
if ok || (lenient && nests_within(v, depth)) {
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(ParamError { path: at.text(), problem: Problem::WrongType { expected: expected(ty), got: shown(v) } })
|
||||
}
|
||||
};
|
||||
let too_deep = || ParamError { path: at.text(), problem: Problem::TooDeep };
|
||||
if matches!(ty, Type::Ids | Type::Color | Type::Point) && v.is_array() && depth >= MAX_DEPTH {
|
||||
return Err(too_deep());
|
||||
}
|
||||
match ty {
|
||||
Type::Number { min, max, .. } => done(v.as_f64().is_some_and(|x| x.is_finite() && in_range(x, min.as_ref(), max.as_ref()))),
|
||||
Type::Integer { min, max } => match whole(v).filter(|w| whole_in_range(w, *min, *max)) {
|
||||
Some(w) => Ok((w != *v).then_some(w)),
|
||||
None => done(false),
|
||||
},
|
||||
Type::Boolean => done(v.is_boolean()),
|
||||
Type::String => done(v.is_string()),
|
||||
Type::Enum(names) => done(v.as_str().is_some_and(|s| names.contains(&s))),
|
||||
Type::Id => match whole(v).filter(|w| w.as_u64().is_some_and(|x| x <= MAX_ID)) {
|
||||
Some(w) => Ok((w != *v).then_some(w)),
|
||||
None => done(false),
|
||||
},
|
||||
Type::Ids => match v.as_array() {
|
||||
Some(items) => check_items(at, depth, &Type::Id, items, lenient, saved),
|
||||
None => done(false),
|
||||
},
|
||||
Type::Color => done(match v {
|
||||
Value::String(_) => true,
|
||||
Value::Object(_) => nests_within(v, depth),
|
||||
Value::Array(a) => a.len() >= 3 && a.iter().all(is_number),
|
||||
_ => false,
|
||||
}),
|
||||
Type::Point => done(v.as_array().is_some_and(|a| a.len() == 2 && a.iter().all(is_number))),
|
||||
Type::Object(params) => match v {
|
||||
Value::Object(_) if depth >= MAX_DEPTH => Err(too_deep()),
|
||||
Value::Object(o) => Ok(check_object(at, depth + 1, params, o, saved)?.map(Value::Object)),
|
||||
_ => done(false),
|
||||
},
|
||||
Type::Array(item) => match v.as_array() {
|
||||
Some(_) if depth >= MAX_DEPTH => Err(too_deep()),
|
||||
Some(items) => check_items(at, depth + 1, item, items, lenient, saved),
|
||||
None => done(false),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Each of `items` checked against `item`: the list with what [`check_value`] changed, when it
|
||||
/// changed anything.
|
||||
fn check_items(at: &At<'_>, depth: usize, item: &Type, items: &[Value], lenient: bool, saved: bool) -> Result<Option<Value>, ParamError> {
|
||||
let mut changes: Vec<(usize, Value)> = Vec::new();
|
||||
for (i, x) in items.iter().enumerate() {
|
||||
if let Some(changed) = check_value(&At::Index(at, i), depth, item, x, lenient, saved)? {
|
||||
changes.push((i, changed));
|
||||
}
|
||||
}
|
||||
if changes.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut filled = items.to_vec();
|
||||
for (i, v) in changes {
|
||||
if let Some(slot) = filled.get_mut(i) {
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
Ok(Some(Value::Array(filled)))
|
||||
}
|
||||
|
||||
/// What is wrong with `params`, the object at `path` nested `depth` objects and lists deep.
|
||||
fn object_problem(path: &str, params: &[Param], depth: usize) -> Option<String> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Some(format!("`{path}` nests deeper than {MAX_DEPTH} levels"));
|
||||
}
|
||||
for (i, p) in params.iter().enumerate() {
|
||||
let at = join(path, p.name);
|
||||
if p.name.is_empty() || p.name.contains(['.', '[', ']']) {
|
||||
return Some(format!("the parameter name `{at}` is empty or holds a path character"));
|
||||
}
|
||||
if params.iter().take(i).any(|q| q.name == p.name) {
|
||||
return Some(format!("`{at}` is listed twice"));
|
||||
}
|
||||
if p.required && p.omitted != Omitted::Nothing {
|
||||
return Some(format!("`{at}` is required and has a default"));
|
||||
}
|
||||
if let Some(problem) = type_problem(&at, &p.ty, depth) {
|
||||
return Some(problem);
|
||||
}
|
||||
if let Omitted::Value(text) = p.omitted {
|
||||
let Ok(v) = serde_json::from_str::<Value>(text) else { return Some(format!("the default of `{at}` isn't JSON: {text}")) };
|
||||
if let Err(e) = check_value(&At::Root, 0, &p.ty, &v, false, false) {
|
||||
return Some(format!("the default of `{at}` doesn't pass its type: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn type_problem(at: &str, ty: &Type, depth: usize) -> Option<String> {
|
||||
let bound = |b: &Bound| match *b {
|
||||
Bound::Inclusive(x) => (x, false),
|
||||
Bound::Exclusive(x) => (x, true),
|
||||
};
|
||||
match ty {
|
||||
Type::Number { min, max, .. } => {
|
||||
let (low, high) = (min.as_ref().map(bound), max.as_ref().map(bound));
|
||||
if low.iter().chain(high.iter()).any(|(x, _)| !x.is_finite()) {
|
||||
return Some(format!("the range of `{at}` has an end that isn't finite"));
|
||||
}
|
||||
match (low, high) {
|
||||
(Some((a, open_a)), Some((b, open_b))) if a > b || (a == b && (open_a || open_b)) => {
|
||||
Some(format!("no value can be in the range of `{at}`"))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
Type::Integer { min: Some(a), max: Some(b) } if a > b => Some(format!("no value can be in the range of `{at}`")),
|
||||
Type::Enum([]) => Some(format!("the enum of `{at}` has no names")),
|
||||
Type::Enum(names) if names.iter().enumerate().any(|(i, n)| names.iter().take(i).any(|m| m == n)) => {
|
||||
Some(format!("the enum of `{at}` repeats a name"))
|
||||
}
|
||||
Type::Object(params) => object_problem(at, params, depth + 1),
|
||||
Type::Array(_) if depth >= MAX_DEPTH => Some(format!("`{at}` nests deeper than {MAX_DEPTH} levels")),
|
||||
Type::Array(item) => type_problem(&format!("{at}[]"), item, depth + 1),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `{a: number, b?: bool}`: the params object of a params text, `depth` objects and lists deep.
|
||||
fn object_text(params: &[Param], depth: usize) -> String {
|
||||
format!("{{{}}}", params.iter().map(|p| param_text(p, depth)).collect::<Vec<_>>().join(", "))
|
||||
}
|
||||
|
||||
fn param_text(p: &Param, depth: usize) -> String {
|
||||
let mut s = format!("{}{}: {}", p.name, if p.required { "" } else { "?" }, type_text(&p.ty, depth));
|
||||
match p.omitted {
|
||||
Omitted::Value(v) | Omitted::Note(v) => s.push_str(&format!(" (default: {v})")),
|
||||
Omitted::Nothing => {}
|
||||
}
|
||||
if !p.doc.is_empty() {
|
||||
s.push_str(&format!(" ({})", p.doc));
|
||||
}
|
||||
if p.deprecated.is_some() {
|
||||
s.push_str(" (deprecated)");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn type_text(ty: &Type, depth: usize) -> String {
|
||||
match ty {
|
||||
Type::Object(_) | Type::Array(_) if depth >= MAX_DEPTH => "…".into(),
|
||||
Type::Number { min, max, unit } => {
|
||||
format!("number{}{}", range_text(min.as_ref(), max.as_ref()), unit.map(|u| format!(" {u}")).unwrap_or_default())
|
||||
}
|
||||
Type::Integer { min, max } => {
|
||||
format!("integer{}", range_text(min.map(|m| Bound::Inclusive(m as f64)).as_ref(), max.map(|m| Bound::Inclusive(m as f64)).as_ref()))
|
||||
}
|
||||
Type::Boolean => "bool".into(),
|
||||
Type::String => "string".into(),
|
||||
Type::Enum(names) => names.iter().map(|n| format!("\"{n}\"")).collect::<Vec<_>>().join("|"),
|
||||
Type::Id => "id".into(),
|
||||
Type::Ids => "[id…]".into(),
|
||||
Type::Color => "color".into(),
|
||||
Type::Point => "[x, y]".into(),
|
||||
Type::Object(params) => object_text(params, depth + 1),
|
||||
Type::Array(item) => format!("[{}…]", type_text(item, depth + 1)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `{"type": "object", "properties": …, "required": […], "additionalProperties": false}`.
|
||||
fn object_schema(params: &[Param], depth: usize) -> Map<String, Value> {
|
||||
let mut o = Map::new();
|
||||
o.insert("type".into(), "object".into());
|
||||
o.insert("properties".into(), Value::Object(params.iter().map(|p| (p.name.to_string(), param_schema(p, depth))).collect()));
|
||||
let required: Vec<Value> = params.iter().filter(|p| p.required).map(|p| p.name.into()).collect();
|
||||
if !required.is_empty() {
|
||||
o.insert("required".into(), required.into());
|
||||
}
|
||||
o.insert("additionalProperties".into(), false.into());
|
||||
o
|
||||
}
|
||||
|
||||
fn param_schema(p: &Param, depth: usize) -> Value {
|
||||
let inner = type_schema(&p.ty, depth);
|
||||
let mut o = if p.null_omitted {
|
||||
let mut o = Map::new();
|
||||
o.insert("anyOf".into(), json!([inner, {"type": "null"}]));
|
||||
o
|
||||
} else {
|
||||
match inner {
|
||||
Value::Object(o) => o,
|
||||
_ => Map::new(),
|
||||
}
|
||||
};
|
||||
if !p.doc.is_empty() {
|
||||
o.insert("description".into(), p.doc.into());
|
||||
}
|
||||
match p.omitted {
|
||||
Omitted::Value(json) => {
|
||||
if let Ok(v) = serde_json::from_str::<Value>(json) {
|
||||
o.insert("default".into(), v);
|
||||
}
|
||||
}
|
||||
Omitted::Note(note) => {
|
||||
o.insert("x-default".into(), note.into());
|
||||
}
|
||||
Omitted::Nothing => {}
|
||||
}
|
||||
o.insert("x-since".into(), p.since.into());
|
||||
if let Some(level) = p.deprecated {
|
||||
o.insert("deprecated".into(), true.into());
|
||||
o.insert("x-deprecatedSince".into(), level.into());
|
||||
}
|
||||
if p.lenient {
|
||||
o.insert("x-lenient".into(), true.into());
|
||||
}
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
fn bound_keywords(o: &mut Map<String, Value>, min: Option<&Bound>, max: Option<&Bound>) {
|
||||
let low = match min {
|
||||
Some(Bound::Inclusive(a)) => Some(("minimum", a)),
|
||||
Some(Bound::Exclusive(a)) => Some(("exclusiveMinimum", a)),
|
||||
None => None,
|
||||
};
|
||||
let high = match max {
|
||||
Some(Bound::Inclusive(b)) => Some(("maximum", b)),
|
||||
Some(Bound::Exclusive(b)) => Some(("exclusiveMaximum", b)),
|
||||
None => None,
|
||||
};
|
||||
for (key, x) in low.into_iter().chain(high) {
|
||||
o.insert(key.into(), json!(x));
|
||||
}
|
||||
}
|
||||
|
||||
fn type_schema(ty: &Type, depth: usize) -> Value {
|
||||
match ty {
|
||||
Type::Object(_) | Type::Array(_) if depth >= MAX_DEPTH => Value::Bool(true),
|
||||
Type::Number { min, max, unit } => {
|
||||
let mut o = Map::new();
|
||||
o.insert("type".into(), "number".into());
|
||||
bound_keywords(&mut o, min.as_ref(), max.as_ref());
|
||||
if let Some(u) = unit {
|
||||
o.insert("x-unit".into(), (*u).into());
|
||||
}
|
||||
Value::Object(o)
|
||||
}
|
||||
Type::Integer { min, max } => {
|
||||
let mut o = Map::new();
|
||||
o.insert("type".into(), "integer".into());
|
||||
if let Some(m) = min {
|
||||
o.insert("minimum".into(), (*m).into());
|
||||
}
|
||||
if let Some(m) = max {
|
||||
o.insert("maximum".into(), (*m).into());
|
||||
}
|
||||
Value::Object(o)
|
||||
}
|
||||
Type::Boolean => json!({"type": "boolean"}),
|
||||
Type::String => json!({"type": "string"}),
|
||||
Type::Enum(names) => json!({"enum": names}),
|
||||
Type::Id => json!({"type": "integer", "minimum": 0, "maximum": MAX_ID, "x-kind": "id"}),
|
||||
Type::Ids => json!({"type": "array", "items": {"type": "integer", "minimum": 0, "maximum": MAX_ID, "x-kind": "id"}}),
|
||||
Type::Color => json!({
|
||||
"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "number"}, "minItems": 3}, {"type": "object"}],
|
||||
"x-kind": "color",
|
||||
}),
|
||||
Type::Point => json!({
|
||||
"type": "array",
|
||||
"prefixItems": [{"type": "number"}, {"type": "number"}],
|
||||
"items": false,
|
||||
"minItems": 2,
|
||||
"x-kind": "point",
|
||||
}),
|
||||
Type::Object(params) => Value::Object(object_schema(params, depth + 1)),
|
||||
Type::Array(item) => json!({"type": "array", "items": type_schema(item, depth + 1)}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod conformance;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,536 @@
|
||||
//! Tests of the validator, the params text and the JSON form, on schemas written here.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// The schema with the one parameter `x` of type `ty`.
|
||||
fn one(ty: Type) -> ParamSchema {
|
||||
let params: &'static [Param] = Box::leak(Box::new([Param::new("x", ty)]));
|
||||
ParamSchema::new(params, "")
|
||||
}
|
||||
|
||||
/// Whether `{x: v}` passes the schema of `ty`; on a refusal, the error must name `x`.
|
||||
fn passes(ty: Type, v: Value) -> bool {
|
||||
match one(ty).validate(&json!({ "x": v })) {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
assert!(e.path.starts_with('x'), "{e}");
|
||||
assert!(e.to_string().contains("`x"), "{e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_are_checked_against_their_range() {
|
||||
let any = Type::NUMBER;
|
||||
assert!(passes(any, json!(-3.5)) && passes(any, json!(7)));
|
||||
assert!(!passes(any, json!("7")) && !passes(any, json!(null)) && !passes(any, json!(true)));
|
||||
let positive = Type::Number { min: Some(Bound::Exclusive(0.0)), max: None, unit: None };
|
||||
assert!(passes(positive, json!(0.001)));
|
||||
assert!(!passes(positive, json!(0)) && !passes(positive, json!(-1)));
|
||||
let percent = Type::Number { min: Some(Bound::Inclusive(0.0)), max: Some(Bound::Inclusive(100.0)), unit: Some("%") };
|
||||
assert!(passes(percent, json!(0)) && passes(percent, json!(100)));
|
||||
assert!(!passes(percent, json!(100.5)));
|
||||
let below = Type::Number { min: None, max: Some(Bound::Exclusive(1.0)), unit: None };
|
||||
assert!(passes(below, json!(0.99)) && !passes(below, json!(1)));
|
||||
assert_eq!(one(positive).validate(&json!({"x": 0})).unwrap_err().to_string(), "`x` must be a number > 0, not 0");
|
||||
assert_eq!(one(percent).validate(&json!({"x": 101})).unwrap_err().to_string(), "`x` must be a number 0 to 100 (%), not 101");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integers_take_no_fraction_and_keep_their_range() {
|
||||
let any = Type::Integer { min: None, max: None };
|
||||
assert!(passes(any, json!(-4)) && passes(any, json!(u64::MAX)));
|
||||
assert!(!passes(any, json!(1.5)) && !passes(any, json!("2")));
|
||||
let small = Type::Integer { min: Some(1), max: Some(25) };
|
||||
assert!(passes(small, json!(1)) && passes(small, json!(25)));
|
||||
assert!(!passes(small, json!(0)) && !passes(small, json!(26)) && !passes(small, json!(u64::MAX)));
|
||||
assert!(passes(small, json!(25.0)) && !passes(small, json!(26.0)) && !passes(small, json!(1e300)) && !passes(small, json!(-1e300)));
|
||||
let negative = Type::Integer { min: None, max: Some(-1_000_000_000_000_000_000) };
|
||||
assert!(passes(negative, json!(-1e20)) && !passes(negative, json!(1e20)));
|
||||
let lowest = Type::Integer { min: Some(i64::MIN), max: None };
|
||||
assert!(!passes(lowest, json!(-1e20)) && passes(lowest, json!(1e20)));
|
||||
}
|
||||
|
||||
/// JSON Schema counts a number with no fractional part as an integer: `2.0` passes, and the
|
||||
/// command gets `2`.
|
||||
#[test]
|
||||
fn a_whole_number_written_with_a_fraction_is_an_integer() {
|
||||
let any = Type::Integer { min: None, max: None };
|
||||
assert_eq!(one(any).validate(&json!({"x": 2.0})).unwrap(), Some(json!({"x": 2})));
|
||||
assert_eq!(one(any).validate(&json!({"x": 2})).unwrap(), None);
|
||||
assert!(passes(any, json!(1e300)));
|
||||
assert_eq!(one(Type::Id).validate(&json!({"x": 7.0})).unwrap(), Some(json!({"x": 7})));
|
||||
assert_eq!(one(Type::Ids).validate(&json!({"x": [1, 2.0]})).unwrap(), Some(json!({"x": [1, 2]})));
|
||||
assert!(passes(Type::Id, json!(MAX_ID)) && !passes(Type::Id, json!(MAX_ID + 1)) && !passes(Type::Id, json!(1e300)));
|
||||
assert_eq!(one(Type::Ids).validate(&json!({"x": [1, "2"]})).unwrap_err().path, "x[1]");
|
||||
}
|
||||
|
||||
/// A value with no finite number in it reads as null in JSON, and a number refuses it; the largest
|
||||
/// finite numbers pass a number with no range.
|
||||
#[test]
|
||||
fn numbers_at_the_edges() {
|
||||
assert!(!passes(Type::NUMBER, json!(f64::NAN)) && !passes(Type::NUMBER, json!(f64::INFINITY)));
|
||||
assert!(passes(Type::NUMBER, json!(f64::MAX)) && passes(Type::NUMBER, json!(f64::MIN)));
|
||||
let unit = Type::Number { min: Some(Bound::Inclusive(0.0)), max: Some(Bound::Inclusive(1.0)), unit: None };
|
||||
assert!(passes(unit, json!(0.0)) && passes(unit, json!(1.0)) && !passes(unit, json!(-1e-300)) && !passes(unit, json!(1.0000001)));
|
||||
}
|
||||
|
||||
/// A list too long to name one path per entry costs one pass, and the error names the entry.
|
||||
#[test]
|
||||
fn a_huge_list_is_checked_once_and_names_the_entry() {
|
||||
let mut ids: Vec<Value> = (0..200_000u64).map(Value::from).collect();
|
||||
assert_eq!(one(Type::Ids).validate(&json!({ "x": ids })).unwrap(), None);
|
||||
ids.push(json!("no"));
|
||||
let e = one(Type::Ids).validate(&json!({ "x": ids })).unwrap_err();
|
||||
assert_eq!(e.path, "x[200000]");
|
||||
assert!(e.to_string().chars().count() < 120, "{e}");
|
||||
}
|
||||
|
||||
/// A list nested far past anything the schema can take: dropping or writing it out recurses per
|
||||
/// level, which is why the test leaks it rather than dropping it.
|
||||
fn nested_lists(levels: usize) -> Value {
|
||||
(0..levels).fold(json!(1), |inner, _| Value::Array(vec![inner]))
|
||||
}
|
||||
|
||||
/// A refused value is shown from its start only, however deep or long it is, so an error costs
|
||||
/// the same for a hostile value as for a small one.
|
||||
#[test]
|
||||
fn a_refused_value_is_read_only_as_far_as_it_is_shown() {
|
||||
let deep = nested_lists(500_000);
|
||||
let params = json!({});
|
||||
let mut map = params.as_object().unwrap().clone();
|
||||
map.insert("x".into(), deep);
|
||||
let params = Value::Object(map);
|
||||
let e = one(Type::String).validate(¶ms).unwrap_err();
|
||||
assert!(e.to_string().starts_with("`x` must be a string, not [[[[") && e.to_string().ends_with('…'), "{e}");
|
||||
assert!(e.to_string().chars().count() <= 100, "{e}");
|
||||
std::mem::forget(params);
|
||||
|
||||
let huge = "x".repeat(5_000_000);
|
||||
let e = one(Type::Boolean).validate(&json!({ "x": huge })).unwrap_err();
|
||||
assert!(e.to_string().chars().count() <= 100, "{}", e.to_string().len());
|
||||
assert!(e.to_string().ends_with('…'));
|
||||
|
||||
let e = one(Type::Boolean).validate(&json!({"x": {"a": 1, "b": [1, 2]}})).unwrap_err();
|
||||
assert_eq!(e.to_string(), "`x` must be true or false, not {\"a\":1,\"b\":[1,2]}");
|
||||
let e = ParamSchema::new(&[], "").validate(&nested_lists(3)).unwrap_err();
|
||||
assert_eq!(e.to_string(), "the params must be an object, not [[[1]]]");
|
||||
}
|
||||
|
||||
/// A list entry made an integer, and a default filled in, don't copy a value that is still to be
|
||||
/// checked: copying recurses per level.
|
||||
#[test]
|
||||
fn a_list_with_a_change_is_not_copied_before_all_of_it_is_checked() {
|
||||
let params = Value::Object([("x".to_string(), Value::Array(vec![json!(2.0), nested_lists(500_000)]))].into_iter().collect());
|
||||
assert_eq!(one(Type::Ids).validate(¶ms).unwrap_err().path, "x[1]");
|
||||
std::mem::forget(params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_with_a_default_is_not_copied_before_all_of_it_is_checked() {
|
||||
const FILLED_FIRST: &[Param] = &[Param::new("a", Type::NUMBER).defaults_to("0"), Param::new("b", Type::String)];
|
||||
let params = Value::Object([("b".to_string(), nested_lists(500_000))].into_iter().collect());
|
||||
assert_eq!(ParamSchema::new(FILLED_FIRST, "").validate(¶ms).unwrap_err().path, "b");
|
||||
std::mem::forget(params);
|
||||
}
|
||||
|
||||
/// A value the schema doesn't look inside is measured before a default copies the object it is in.
|
||||
const WITH_DEFAULT: &[Param] = &[Param::new("a", Type::NUMBER).defaults_to("0"), Param::new("b", Type::Color).optional()];
|
||||
const LENIENT_AFTER_DEFAULT: &[Param] = &[Param::new("a", Type::NUMBER).defaults_to("0"), Param::new("b", Type::Boolean).optional().lenient()];
|
||||
|
||||
fn holding(key: &str, v: Value) -> Value {
|
||||
Value::Object([(key.to_string(), v)].into_iter().collect())
|
||||
}
|
||||
|
||||
/// The lists of fixed shape count toward the nesting limit as the other lists do.
|
||||
#[test]
|
||||
fn id_lists_points_and_color_lists_count_toward_the_nesting_limit() {
|
||||
let check = |depth, ty: &Type, v: &Value| check_value(&At::Root, depth, ty, v, false, false);
|
||||
for (ty, v) in [(Type::Ids, json!([1])), (Type::Point, json!([1, 2])), (Type::Color, json!([1, 0, 0]))] {
|
||||
assert!(check(MAX_DEPTH - 1, &ty, &v).is_ok(), "{ty:?}");
|
||||
assert_eq!(check(MAX_DEPTH, &ty, &v).unwrap_err().problem, Problem::TooDeep, "{ty:?}");
|
||||
}
|
||||
assert!(check(MAX_DEPTH, &Type::Color, &json!("#ff0000")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_retired_key_past_the_nesting_limit_is_refused() {
|
||||
let params = holding("older", nested_lists(500_000));
|
||||
let e = ParamSchema::new(WITH_DEFAULT, "").validate_saved(¶ms).unwrap_err();
|
||||
assert_eq!(e.path, "older");
|
||||
assert!(matches!(e.problem, Problem::Unknown { .. }));
|
||||
std::mem::forget(params);
|
||||
let kept = ParamSchema::new(WITH_DEFAULT, "").validate_saved(&holding("older", nested_lists(MAX_DEPTH))).unwrap().unwrap();
|
||||
assert_eq!(kept["a"], json!(0));
|
||||
assert_eq!(kept["older"], nested_lists(MAX_DEPTH));
|
||||
assert!(ParamSchema::new(WITH_DEFAULT, "").validate_saved(&holding("older", nested_lists(MAX_DEPTH + 1))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_color_object_past_the_nesting_limit_is_refused() {
|
||||
let params = holding("b", holding("k", nested_lists(500_000)));
|
||||
let e = ParamSchema::new(WITH_DEFAULT, "").validate(¶ms).unwrap_err();
|
||||
assert_eq!(e.path, "b");
|
||||
assert!(matches!(e.problem, Problem::WrongType { .. }));
|
||||
std::mem::forget(params);
|
||||
assert!(ParamSchema::new(WITH_DEFAULT, "").validate(&json!({"b": {"r": 1, "g": 0, "b": 0}})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lenient_value_past_the_nesting_limit_is_refused() {
|
||||
let params = holding("b", nested_lists(500_000));
|
||||
let e = ParamSchema::new(LENIENT_AFTER_DEFAULT, "").validate(¶ms).unwrap_err();
|
||||
assert_eq!(e.path, "b");
|
||||
assert!(matches!(e.problem, Problem::WrongType { .. }));
|
||||
std::mem::forget(params);
|
||||
assert_eq!(ParamSchema::new(LENIENT_AFTER_DEFAULT, "").validate(&json!({"b": [1]})).unwrap().unwrap()["b"], json!([1]));
|
||||
}
|
||||
|
||||
/// A key is cut in the error, and an unknown key is refused whatever it holds.
|
||||
#[test]
|
||||
fn a_hostile_key_is_cut_and_refused() {
|
||||
let key = "k".repeat(1_000_000);
|
||||
let e = one(Type::Boolean).validate(&json!({ key: true, "x": true })).unwrap_err();
|
||||
assert_eq!(e.path.chars().count(), KEY_CHARS + 1);
|
||||
assert!(e.path.ends_with('…') && e.to_string().chars().count() < 200, "{}", e.path.len());
|
||||
for key in ["", "a.b", "a[0]", "`", "\u{0}", "名前"] {
|
||||
let e = one(Type::Boolean).validate(&json!({ key: true, "x": true })).unwrap_err();
|
||||
assert_eq!(e.path, key);
|
||||
assert!(matches!(e.problem, Problem::Unknown { .. }));
|
||||
}
|
||||
assert_eq!(shown_key("short"), "short");
|
||||
}
|
||||
|
||||
/// A schema that refers to itself, which only a mistake can write: checking values stops at the
|
||||
/// nesting limit, and the writers finish.
|
||||
static ENDLESS: Type = Type::Array(&ENDLESS);
|
||||
|
||||
#[test]
|
||||
fn a_schema_that_refers_to_itself_stops_at_the_nesting_limit() {
|
||||
let s = one(ENDLESS);
|
||||
let e = s.validate(&json!({ "x": nested_lists(MAX_DEPTH + 2) })).unwrap_err();
|
||||
assert_eq!(e.problem, Problem::TooDeep);
|
||||
assert!(e.path.starts_with("x[0][0]"));
|
||||
assert!(s.definition_problem().is_some_and(|p| p.contains("nests deeper")));
|
||||
assert!(s.params_text().contains('…'));
|
||||
assert!(s.to_json()["properties"]["x"].is_object());
|
||||
assert_ne!(hash([("a", &s)]), 0);
|
||||
}
|
||||
|
||||
const BAD_DEFAULT: &[Param] = &[Param::new("x", Type::NUMBER).defaults_to("\"zero\"")];
|
||||
const NOT_JSON: &[Param] = &[Param::new("x", Type::NUMBER).defaults_to("{")];
|
||||
const TWICE: &[Param] = &[Param::new("x", Type::NUMBER), Param::new("x", Type::String)];
|
||||
const EMPTY_NAME: &[Param] = &[Param::new("", Type::NUMBER)];
|
||||
const PATH_NAME: &[Param] = &[Param::new("a.b", Type::NUMBER)];
|
||||
const NO_NAMES: &[Param] = &[Param::new("x", Type::Enum(&[]))];
|
||||
const SAME_NAMES: &[Param] = &[Param::new("x", Type::Enum(&["a", "a"]))];
|
||||
const EMPTY_RANGE: &[Param] = &[Param::new("x", Type::Number { min: Some(Bound::Exclusive(1.0)), max: Some(Bound::Inclusive(1.0)), unit: None })];
|
||||
const BACKWARD: &[Param] = &[Param::new("x", Type::Integer { min: Some(2), max: Some(1) })];
|
||||
const NOT_FINITE: &[Param] = &[Param::new("x", Type::Number { min: None, max: Some(Bound::Inclusive(f64::INFINITY)), unit: None })];
|
||||
const REQUIRED_DEFAULT: &[Param] = &[Param { required: true, omitted: Omitted::Value("1"), ..Param::new("x", Type::NUMBER) }];
|
||||
const DEEP_FAULT: &[Param] = &[Param::new("edits", Type::Array(&Type::Object(TWICE)))];
|
||||
|
||||
#[test]
|
||||
fn a_schema_that_is_wrong_in_itself_is_reported() {
|
||||
for (params, why) in [
|
||||
(BAD_DEFAULT, "doesn't pass its type"),
|
||||
(NOT_JSON, "isn't JSON"),
|
||||
(TWICE, "listed twice"),
|
||||
(EMPTY_NAME, "empty or holds"),
|
||||
(PATH_NAME, "empty or holds"),
|
||||
(NO_NAMES, "no names"),
|
||||
(SAME_NAMES, "repeats"),
|
||||
(EMPTY_RANGE, "no value can be"),
|
||||
(BACKWARD, "no value can be"),
|
||||
(NOT_FINITE, "isn't finite"),
|
||||
(REQUIRED_DEFAULT, "required"),
|
||||
(DEEP_FAULT, "`edits[].x` is listed twice"),
|
||||
] {
|
||||
let problem = ParamSchema::new(params, "").definition_problem();
|
||||
assert!(problem.as_deref().is_some_and(|p| p.contains(why)), "{params:?}: {problem:?}");
|
||||
}
|
||||
assert_eq!(ParamSchema::new(VIEW, "").definition_problem(), None);
|
||||
assert_eq!(ParamSchema::new(&[], "").definition_problem(), None);
|
||||
}
|
||||
|
||||
/// Saved steps keep keys the schema doesn't name, at any depth; the values it names are checked.
|
||||
#[test]
|
||||
fn saved_steps_pass_retired_keys_and_check_their_values() {
|
||||
let edits = one(Type::Array(&Type::Object(EDIT)));
|
||||
let retired = json!({"x": [{"name": "A", "old": 1}], "older": true});
|
||||
assert!(edits.validate(&retired).is_err());
|
||||
let filled = edits.validate_saved(&retired).unwrap().unwrap();
|
||||
assert_eq!(filled["x"][0]["delete"], json!(false), "defaults are filled in");
|
||||
assert_eq!(filled["older"], json!(true), "the keys stay");
|
||||
assert_eq!(edits.validate_saved(&json!({"x": [{"name": 5}], "older": true})).unwrap_err().path, "x[0].name");
|
||||
assert_eq!(edits.validate_saved(&json!({"older": true})).unwrap_err().problem, Problem::Missing);
|
||||
}
|
||||
|
||||
/// Every type's JSON form follows the 2020-12 rules, and an independent validator of those rules
|
||||
/// agrees with [`ParamSchema::validate`] on each value.
|
||||
#[test]
|
||||
fn the_json_form_and_the_validator_agree_with_the_2020_12_rules() {
|
||||
const NULLABLE: &[Param] = &[Param::new("a", Type::String).optional().null_omitted(), Param::new("b", Type::Boolean).defaults_to("true")];
|
||||
let types = [
|
||||
Type::NUMBER,
|
||||
Type::Number { min: Some(Bound::Exclusive(0.0)), max: Some(Bound::Inclusive(1.0)), unit: Some("pt") },
|
||||
Type::Number { min: Some(Bound::Inclusive(-1.0)), max: Some(Bound::Exclusive(1.0)), unit: None },
|
||||
Type::Integer { min: None, max: None },
|
||||
Type::Integer { min: Some(1), max: Some(25) },
|
||||
Type::Boolean,
|
||||
Type::String,
|
||||
Type::Enum(&["a", "b"]),
|
||||
Type::Id,
|
||||
Type::Ids,
|
||||
Type::Color,
|
||||
Type::Point,
|
||||
Type::Object(EDIT),
|
||||
Type::Object(NULLABLE),
|
||||
Type::Object(&[]),
|
||||
Type::Array(&Type::NUMBER),
|
||||
Type::Array(&Type::Object(EDIT)),
|
||||
];
|
||||
let values = [
|
||||
json!(null),
|
||||
json!(true),
|
||||
json!(0),
|
||||
json!(1),
|
||||
json!(2.0),
|
||||
json!(25.5),
|
||||
json!(-1),
|
||||
json!(-1.0),
|
||||
json!(1e300),
|
||||
json!(MAX_ID),
|
||||
json!(MAX_ID + 1),
|
||||
json!(u64::MAX),
|
||||
json!(""),
|
||||
json!("a"),
|
||||
json!("c"),
|
||||
json!([]),
|
||||
json!([1, 2]),
|
||||
json!([1, 2, 3]),
|
||||
json!([1, "2"]),
|
||||
json!([[1]]),
|
||||
json!({}),
|
||||
json!({"name": "A"}),
|
||||
json!({"name": "A", "delete": false}),
|
||||
json!({"name": "A", "delete": null}),
|
||||
json!({"name": "A", "other": 1}),
|
||||
json!({"a": null, "b": false}),
|
||||
json!({"a": 5}),
|
||||
json!([{"name": "A"}]),
|
||||
json!([{"name": 1}]),
|
||||
];
|
||||
for ty in types {
|
||||
let schema = one(ty);
|
||||
let written = schema.to_json();
|
||||
conformance::check_schema(&written, "#").unwrap_or_else(|e| panic!("{ty:?}: {e}\n{written:#}"));
|
||||
assert_eq!(written["$schema"], "https://json-schema.org/draft/2020-12/schema");
|
||||
for v in &values {
|
||||
let params = json!({ "x": v });
|
||||
let local = schema.validate(¶ms).is_ok();
|
||||
assert_eq!(local, conformance::instance_valid(&written, ¶ms), "{ty:?} on {v}: {written}");
|
||||
}
|
||||
}
|
||||
for params in [VIEW, EDIT, NULLABLE] {
|
||||
let schema = ParamSchema::new(params, "").deprecated(1);
|
||||
conformance::check_schema(&schema.to_json(), "#").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// The independent checker refuses what the 2020-12 rules refuse, so its passes above mean
|
||||
/// something.
|
||||
#[test]
|
||||
fn the_2020_12_checker_refuses_invalid_schemas() {
|
||||
for bad in [
|
||||
json!(5),
|
||||
json!({"type": "float"}),
|
||||
json!({"enum": []}),
|
||||
json!({"enum": [1, 1]}),
|
||||
json!({"required": ["a", "a"]}),
|
||||
json!({"required": [1]}),
|
||||
json!({"properties": []}),
|
||||
json!({"properties": {"a": 5}}),
|
||||
json!({"items": 1}),
|
||||
json!({"prefixItems": []}),
|
||||
json!({"anyOf": []}),
|
||||
json!({"minimum": "0"}),
|
||||
json!({"minItems": -1}),
|
||||
json!({"description": 5}),
|
||||
json!({"deprecated": "yes"}),
|
||||
json!({"type": "number", "default": "zero"}),
|
||||
json!({"$schema": "not a uri"}),
|
||||
json!({"minimun": 0}),
|
||||
] {
|
||||
assert!(conformance::check_schema(&bad, "#").is_err(), "{bad}");
|
||||
}
|
||||
assert!(conformance::instance_valid(&json!({"type": "integer"}), &json!(2.0)));
|
||||
assert!(!conformance::instance_valid(&json!({"type": "integer"}), &json!(2.5)));
|
||||
assert!(!conformance::instance_valid(&json!({"prefixItems": [{"type": "number"}], "items": false}), &json!([1, 2])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn booleans_and_strings_take_only_their_own_type() {
|
||||
assert!(passes(Type::Boolean, json!(true)) && passes(Type::Boolean, json!(false)));
|
||||
assert!(!passes(Type::Boolean, json!("true")) && !passes(Type::Boolean, json!(1)) && !passes(Type::Boolean, json!(null)));
|
||||
assert!(passes(Type::String, json!("")) && passes(Type::String, json!("View 1")));
|
||||
assert!(!passes(Type::String, json!(5)) && !passes(Type::String, json!(["a"])));
|
||||
assert_eq!(one(Type::Boolean).validate(&json!({"x": "yes"})).unwrap_err().to_string(), "`x` must be true or false, not \"yes\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_enum_takes_one_of_its_names() {
|
||||
let modes = Type::Enum(&["rgb", "cmyk"]);
|
||||
assert!(passes(modes, json!("rgb")) && passes(modes, json!("cmyk")));
|
||||
assert!(!passes(modes, json!("RGB")) && !passes(modes, json!("lab")) && !passes(modes, json!(0)));
|
||||
assert_eq!(one(modes).validate(&json!({"x": "lab"})).unwrap_err().to_string(), "`x` must be one of \"rgb\", \"cmyk\", not \"lab\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_are_non_negative_integers() {
|
||||
assert!(passes(Type::Id, json!(0)) && passes(Type::Id, json!(42)));
|
||||
assert!(!passes(Type::Id, json!(-1)) && !passes(Type::Id, json!(1.5)) && !passes(Type::Id, json!("42")));
|
||||
assert!(passes(Type::Ids, json!([])) && passes(Type::Ids, json!([3, 4])));
|
||||
assert!(!passes(Type::Ids, json!(3)) && !passes(Type::Ids, json!([3, "4"])) && !passes(Type::Ids, json!([-3])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colors_and_points_have_their_shapes() {
|
||||
assert!(passes(Type::Color, json!("#ff0000")) && passes(Type::Color, json!([1, 0, 0])) && passes(Type::Color, json!({"gray": 50})));
|
||||
assert!(!passes(Type::Color, json!(5)) && !passes(Type::Color, json!([1, 0])) && !passes(Type::Color, json!([1, 0, "x"])));
|
||||
assert!(passes(Type::Point, json!([0, -2.5])));
|
||||
assert!(!passes(Type::Point, json!([1])) && !passes(Type::Point, json!([1, 2, 3])) && !passes(Type::Point, json!([1, "2"])));
|
||||
assert!(!passes(Type::Point, json!({"x": 1, "y": 2})));
|
||||
}
|
||||
|
||||
const EDIT: &[Param] = &[Param::new("name", Type::String), Param::new("delete", Type::Boolean).defaults_to("false")];
|
||||
|
||||
#[test]
|
||||
fn objects_and_arrays_name_the_path_of_the_value_inside() {
|
||||
let edits = Type::Array(&Type::Object(EDIT));
|
||||
assert!(passes(edits, json!([])) && passes(edits, json!([{"name": "A"}, {"name": "B", "delete": true}])));
|
||||
let err = |v: Value| one(edits).validate(&json!({ "x": v })).unwrap_err();
|
||||
assert_eq!(err(json!([{"name": "A"}, {"name": "B", "delete": "no"}])).path, "x[1].delete");
|
||||
assert_eq!(err(json!([{"name": "A"}, {"delete": true}])).to_string(), "missing `x[1].name`");
|
||||
let unknown = err(json!([{"name": "A", "nmae": "B"}]));
|
||||
assert_eq!((unknown.path.as_str(), unknown.problem), ("x[0].nmae", Problem::Unknown { keys: vec!["name", "delete"] }));
|
||||
assert_eq!(err(json!({"name": "A"})).to_string(), "`x` must be a list, not {\"name\":\"A\"}");
|
||||
assert_eq!(err(json!(["A"])).to_string(), "`x[0]` must be an object, not \"A\"");
|
||||
// Defaults inside are filled in too.
|
||||
let filled = one(edits).validate(&json!({"x": [{"name": "A"}]})).unwrap();
|
||||
assert_eq!(filled, Some(json!({"x": [{"name": "A", "delete": false}]})));
|
||||
}
|
||||
|
||||
const VIEW: &[Param] = &[
|
||||
Param::new("name", Type::String).default_note("\"View N\"").null_omitted(),
|
||||
Param::new("center", Type::Point),
|
||||
Param::new("rotation", Type::Number { min: None, max: None, unit: Some("deg") }).defaults_to("0"),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn keys_requirements_nulls_and_defaults() {
|
||||
let s = ParamSchema::new(VIEW, "save a view").returns("{name}");
|
||||
// Unknown keys are refused first, naming the key and the keys the command takes.
|
||||
let e = s.validate(&json!({"centre": [0, 0]})).unwrap_err();
|
||||
assert_eq!((e.path.as_str(), e.problem), ("centre", Problem::Unknown { keys: vec!["name", "center", "rotation"] }));
|
||||
assert_eq!(s.validate(&json!({})).unwrap_err(), ParamError { path: "center".into(), problem: Problem::Missing });
|
||||
// Null counts as omitted only where the schema says so.
|
||||
assert_eq!(s.validate(&json!({"center": [1, 2], "name": null, "rotation": 15})).unwrap(), None);
|
||||
assert_eq!(s.validate(&json!({"center": [1, 2], "rotation": null})).unwrap_err().path, "rotation");
|
||||
// An omitted value with a default gets it; one with a note is left to the command.
|
||||
assert_eq!(s.validate(&json!({"center": [1, 2]})).unwrap(), Some(json!({"center": [1, 2], "rotation": 0})));
|
||||
// Params that aren't an object are refused; null params are `{}`.
|
||||
assert_eq!(s.validate(&json!(5)).unwrap_err().to_string(), "the params must be an object, not 5");
|
||||
assert_eq!(s.validate(&Value::Null).unwrap_err().path, "center");
|
||||
let none = ParamSchema::new(&[], "");
|
||||
assert_eq!(none.validate(&Value::Null).unwrap(), None);
|
||||
assert_eq!(none.validate(&json!({"a": 1})).unwrap_err().to_string(), "unknown parameter `a`; the object takes no keys");
|
||||
}
|
||||
|
||||
const LENIENT: &[Param] = &[Param::new("on", Type::Boolean).optional().lenient(), Param::new("size", Type::NUMBER).optional().lenient()];
|
||||
|
||||
#[test]
|
||||
fn lenient_parameters_pass_values_of_another_type_to_the_command() {
|
||||
let s = ParamSchema::new(LENIENT, "");
|
||||
assert_eq!(s.validate(&json!({"on": "yes", "size": "12"})).unwrap(), None);
|
||||
// Unknown keys are still refused.
|
||||
assert!(s.validate(&json!({"of": true})).is_err());
|
||||
// The JSON form states the documented type, so a validator of that form refuses what passes here.
|
||||
let written = s.to_json();
|
||||
assert_eq!(written["properties"]["on"]["x-lenient"], true);
|
||||
assert!(!conformance::instance_valid(&written, &json!({"on": "yes"})));
|
||||
assert!(conformance::instance_valid(&written, &json!({"on": true})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_params_text_lists_each_parameter_with_its_type() {
|
||||
let s = ParamSchema::new(VIEW, "save a view").returns("{name}");
|
||||
assert_eq!(s.params_text(), "{name?: string (default: \"View N\"), center: [x, y], rotation?: number deg (default: 0)} save a view → {name}");
|
||||
const ALL: &[Param] = &[
|
||||
Param::new("a", Type::Integer { min: Some(1), max: Some(25) }),
|
||||
Param::new("b", Type::Enum(&["x", "y"])).optional(),
|
||||
Param::new("c", Type::Ids).optional().doc("instead of the selection"),
|
||||
Param::new("d", Type::Color).optional().deprecated(2),
|
||||
Param::new("e", Type::Array(&Type::Object(EDIT))).optional(),
|
||||
Param::new("f", Type::Number { min: Some(Bound::Exclusive(0.0)), max: Some(Bound::Inclusive(10.0)), unit: Some("pt") }).optional(),
|
||||
];
|
||||
assert_eq!(
|
||||
ParamSchema::new(ALL, "").params_text(),
|
||||
"{a: integer 1 to 25, b?: \"x\"|\"y\", c?: [id…] (instead of the selection), d?: color (deprecated), \
|
||||
e?: [{name: string, delete?: bool (default: false)}…], f?: number > 0 and <= 10 pt}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_json_form_is_an_object_schema() {
|
||||
let s = ParamSchema::new(VIEW, "save a view").returns("{name}").writes(Writes::Document).cost(2);
|
||||
let j = s.to_json();
|
||||
assert_eq!(j["$schema"], DIALECT);
|
||||
assert_eq!(j["type"], "object");
|
||||
assert_eq!(j["additionalProperties"], false);
|
||||
assert_eq!(j["required"], json!(["center"]));
|
||||
assert_eq!(j["properties"]["name"]["anyOf"], json!([{"type": "string"}, {"type": "null"}]));
|
||||
assert_eq!(j["properties"]["name"]["x-default"], "\"View N\"");
|
||||
assert_eq!(j["properties"]["rotation"], json!({"type": "number", "x-unit": "deg", "default": 0, "x-since": 1}));
|
||||
assert_eq!(j["properties"]["center"]["prefixItems"], json!([{"type": "number"}, {"type": "number"}]));
|
||||
assert_eq!((j["x-cost"].clone(), j["x-writes"].clone(), j["x-returns"].clone()), (json!(2), json!("document"), json!("{name}")));
|
||||
let range = one(Type::Number { min: Some(Bound::Exclusive(0.0)), max: Some(Bound::Inclusive(1.0)), unit: None }).to_json();
|
||||
assert_eq!(range["properties"]["x"], json!({"type": "number", "exclusiveMinimum": 0.0, "maximum": 1.0, "x-since": 1}));
|
||||
}
|
||||
|
||||
/// Null params read as `{}`, so the root of the JSON form allows null exactly when `{}` passes.
|
||||
/// A nested object does not read null as `{}`.
|
||||
#[test]
|
||||
fn the_json_form_allows_null_params_when_no_parameter_is_required() {
|
||||
const OPTIONAL: &[Param] = &[Param::new("inner", Type::Object(&[Param::new("y", Type::Boolean).optional()])).optional()];
|
||||
let optional = ParamSchema::new(OPTIONAL, "");
|
||||
let written = optional.to_json();
|
||||
conformance::check_schema(&written, "#").unwrap();
|
||||
assert_eq!(written["type"], json!(["object", "null"]));
|
||||
assert_eq!(written["properties"]["inner"]["type"], "object");
|
||||
let required = one(Type::Boolean);
|
||||
assert_eq!(required.to_json()["type"], "object");
|
||||
for schema in [optional, required] {
|
||||
let written = schema.to_json();
|
||||
for params in [json!(null), json!({}), json!(5), json!([]), json!("x"), json!({"inner": null}), json!({"inner": {}})] {
|
||||
assert_eq!(schema.validate(¶ms).is_ok(), conformance::instance_valid(&written, ¶ms), "{params} against {written}");
|
||||
}
|
||||
}
|
||||
assert!(optional.validate(&json!(null)).is_ok());
|
||||
assert!(required.validate(&json!(null)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hash_follows_every_schema_but_not_their_order() {
|
||||
let a = ParamSchema::new(VIEW, "save a view");
|
||||
let b = ParamSchema::new(LENIENT, "");
|
||||
let h = hash([("view.a", &a), ("view.b", &b)]);
|
||||
assert_eq!(h, hash([("view.b", &b), ("view.a", &a)]));
|
||||
assert_ne!(h, hash([("view.a", &a)]));
|
||||
assert_ne!(h, hash([("view.a", &a), ("view.c", &b)]));
|
||||
let changed = ParamSchema::new(VIEW, "save a view").since(2);
|
||||
assert_ne!(h, hash([("view.a", &changed), ("view.b", &b)]));
|
||||
}
|
||||
@@ -60,6 +60,38 @@ impl ImageBlob {
|
||||
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
|
||||
Some(Self::png(png))
|
||||
}
|
||||
|
||||
/// The image as a grayscale PNG: `f` gives each pixel's gray level from its (straight) RGB, and
|
||||
/// the PNG has an alpha channel only when some pixel isn't opaque. `f` runs once per distinct
|
||||
/// color. `None` when the image can't be decoded, or when its file stores grayscale already
|
||||
/// and no visible pixel changed.
|
||||
pub fn map_gray(&self, mut f: impl FnMut([u8; 3]) -> u8) -> Option<ImageBlob> {
|
||||
let img = rgba_within(&self.bytes, MAX_RASTER_ALLOC)?;
|
||||
let (width, height) = img.dimensions();
|
||||
let mut memo: HashMap<[u8; 3], u8> = HashMap::new();
|
||||
let mut changed = self.gray_source().is_none();
|
||||
let mut opaque = true;
|
||||
let mut gray_alpha = Vec::with_capacity(img.len() / 2);
|
||||
for px in img.pixels() {
|
||||
let rgb = [px.0[0], px.0[1], px.0[2]];
|
||||
let level = *memo.entry(rgb).or_insert_with(|| f(rgb));
|
||||
changed |= px.0[3] > 0 && [level; 3] != rgb;
|
||||
opaque &= px.0[3] == 255;
|
||||
gray_alpha.extend_from_slice(&[level, px.0[3]]);
|
||||
}
|
||||
if !changed {
|
||||
return None;
|
||||
}
|
||||
let mut png = Vec::new();
|
||||
let mut out = Cursor::new(&mut png);
|
||||
if opaque {
|
||||
let levels = gray_alpha.iter().step_by(2).copied().collect();
|
||||
image::GrayImage::from_raw(width, height, levels)?.write_to(&mut out, image::ImageFormat::Png).ok()?;
|
||||
} else {
|
||||
image::GrayAlphaImage::from_raw(width, height, gray_alpha)?.write_to(&mut out, image::ImageFormat::Png).ok()?;
|
||||
}
|
||||
Some(Self::png(png))
|
||||
}
|
||||
}
|
||||
|
||||
/// How a grayscale image's file stores its pixels ([`ImageBlob::gray_source`]).
|
||||
|
||||
@@ -40,6 +40,8 @@ vectorcraft-metafile = { workspace = true }
|
||||
vectorcraft-plugins = { workspace = true }
|
||||
vectorcraft-affinity = { workspace = true }
|
||||
vectorcraft-scriptcheck = { workspace = true }
|
||||
# The commands' parameter schemas (schemas.rs).
|
||||
vectorcraft-apischema = { workspace = true }
|
||||
image = { workspace = true }
|
||||
jxl-oxide = { workspace = true }
|
||||
jxl-color = { workspace = true }
|
||||
|
||||
@@ -130,6 +130,9 @@ pub struct CommandSpec {
|
||||
pub run: Run,
|
||||
/// Record in the journal (false for queries and selection-only helpers).
|
||||
pub journal: bool,
|
||||
/// The parameters' schema: [`Session::execute`] checks the params against it before the
|
||||
/// command runs, and `params` is the text it writes ([`crate::schemas`]).
|
||||
pub schema: Option<&'static vectorcraft_apischema::ParamSchema>,
|
||||
}
|
||||
|
||||
/// Serializable command metadata.
|
||||
@@ -143,6 +146,9 @@ pub struct CommandInfo {
|
||||
pub enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disabled_reason: Option<String>,
|
||||
/// The parameters' schema as a JSON Schema 2020-12 object schema, for a command that has one.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<Value>,
|
||||
}
|
||||
|
||||
impl CommandSpec {
|
||||
@@ -156,6 +162,7 @@ impl CommandSpec {
|
||||
params: self.params,
|
||||
enabled: e.is_ok(),
|
||||
disabled_reason: e.err(),
|
||||
schema: self.schema.map(vectorcraft_apischema::ParamSchema::to_json),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -238,10 +245,17 @@ pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
macro_rules! cmd {
|
||||
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true }
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, schema: None }
|
||||
};
|
||||
(query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false }
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, schema: None }
|
||||
};
|
||||
// With a parameter schema: `schema: &SCHEMA` after the run function.
|
||||
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr, schema: $schema:expr) => {
|
||||
$crate::cmd::CommandSpec { schema: Some($schema), ..$crate::cmd::cmd!($id, $label, [$($m),*], $sc, $params, $en, $run) }
|
||||
};
|
||||
(query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr, schema: $schema:expr) => {
|
||||
$crate::cmd::CommandSpec { schema: Some($schema), ..$crate::cmd::cmd!(query $id, $label, [$($m),*], $sc, $params, $en, $run) }
|
||||
};
|
||||
}
|
||||
pub(crate) use cmd;
|
||||
@@ -370,6 +384,14 @@ pub(crate) fn run_spec(s: &mut Session, spec: &CommandSpec, p: &Value) -> Result
|
||||
if plugin::is_command_id(spec.id) { plugin::run_command(s, spec.id, p) } else { (spec.run)(s, p) }
|
||||
}
|
||||
|
||||
/// [`run_spec`] for a command run outside [`Session::execute`]: with params checked against the
|
||||
/// command's schema, and the defaults of omitted values filled in, as `execute` does
|
||||
/// ([`crate::schemas::check`]).
|
||||
pub(crate) fn run_checked(s: &mut Session, spec: &CommandSpec, p: &Value) -> Result<Value> {
|
||||
let checked = crate::schemas::check(spec, p, s.replaying())?;
|
||||
run_spec(s, spec, checked.as_ref().unwrap_or(p))
|
||||
}
|
||||
|
||||
// ---------- param helpers ----------
|
||||
|
||||
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
|
||||
@@ -510,7 +510,7 @@ pub(super) fn expand_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(super) fn brush_freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "brush").map(str::to_string).filter(|n| s.doc().is_ok_and(|st| brush::find(&st.doc, n).is_some()));
|
||||
let spec = find_command("path.freehand").ok_or_else(|| EngineError::UnknownCommand("path.freehand".into()))?;
|
||||
let r = (spec.run)(s, p)?;
|
||||
let r = crate::cmd::run_checked(s, spec, p)?;
|
||||
let (Some(name), Some(id)) = (name, id_param(&r, "id")) else { return Ok(r) };
|
||||
s.edit("Paintbrush", |d, _| {
|
||||
if let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut()) {
|
||||
|
||||
@@ -48,9 +48,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Convert to Grayscale",
|
||||
["Edit", "Edit Colors"],
|
||||
None,
|
||||
"{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed, notChanged, warning?}",
|
||||
"{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert; an embedded image is stored with one gray channel) → {changed, notChanged, warning?}",
|
||||
has_selection,
|
||||
|s, p| recolor(s, p, "edit.colors.toGrayscale", "Convert to Grayscale", &to_gray)
|
||||
|s, p| recolor_gray(s, p, "edit.colors.toGrayscale", "Convert to Grayscale", &to_gray)
|
||||
),
|
||||
cmd!(
|
||||
"edit.colors.toRGB",
|
||||
@@ -187,6 +187,8 @@ pub(crate) struct Recolor<'a> {
|
||||
/// Maps solid colours and gradient stops with their link and tint in place of `f`.
|
||||
links: Option<&'a LinkMap<'a>>,
|
||||
scope: Scope,
|
||||
/// Recolored images are stored with one gray channel ([`ImageBlob::map_gray`]).
|
||||
gray_images: bool,
|
||||
/// Image key → the key of its recoloured copy (`None`: unchanged).
|
||||
images: HashMap<String, Option<String>>,
|
||||
/// Pattern name → the name of its recoloured copy (`None`: unchanged, or being recoloured).
|
||||
@@ -198,7 +200,17 @@ pub(crate) struct Recolor<'a> {
|
||||
|
||||
impl<'a> Recolor<'a> {
|
||||
pub(crate) fn new(scope: Scope, f: &'a dyn Fn(Color) -> Color) -> Self {
|
||||
Self { f, links: None, scope, images: HashMap::new(), patterns: HashMap::new(), new_images: vec![], new_patterns: vec![], changed: 0 }
|
||||
Self {
|
||||
f,
|
||||
links: None,
|
||||
scope,
|
||||
gray_images: false,
|
||||
images: HashMap::new(),
|
||||
patterns: HashMap::new(),
|
||||
new_images: vec![],
|
||||
new_patterns: vec![],
|
||||
changed: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A pass that maps only solid colours and gradient stops, with their swatch link and tint
|
||||
@@ -215,7 +227,7 @@ impl<'a> Recolor<'a> {
|
||||
|
||||
/// A pass that maps colors as this one does, with nothing recolored yet.
|
||||
fn again(&self) -> Self {
|
||||
Self { links: self.links, ..Self::new(self.scope, self.f) }
|
||||
Self { links: self.links, gray_images: self.gray_images, ..Self::new(self.scope, self.f) }
|
||||
}
|
||||
|
||||
/// The entries of `objects` (ids, each with a flag) whose object this pass would change, other
|
||||
@@ -313,10 +325,15 @@ impl<'a> Recolor<'a> {
|
||||
return done.clone();
|
||||
}
|
||||
let f = self.f;
|
||||
let new = d.images.get(key)?.map_rgb(|[r, g, b]| {
|
||||
let [r, g, b, _] = f(Color::rgb8(r, g, b)).to_rgba8(1.0);
|
||||
[r, g, b]
|
||||
});
|
||||
let blob = d.images.get(key)?;
|
||||
let new = if self.gray_images {
|
||||
blob.map_gray(|[r, g, b]| f(Color::rgb8(r, g, b)).to_rgba8(1.0)[0])
|
||||
} else {
|
||||
blob.map_rgb(|[r, g, b]| {
|
||||
let [r, g, b, _] = f(Color::rgb8(r, g, b)).to_rgba8(1.0);
|
||||
[r, g, b]
|
||||
})
|
||||
};
|
||||
let new = new.map(|blob| {
|
||||
let k = blob.content_key();
|
||||
self.new_images.push((k.clone(), blob));
|
||||
@@ -400,6 +417,11 @@ pub(crate) fn recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dy
|
||||
run_pass(s, p, cmd, label, Recolor::new(Scope::of(p), f))
|
||||
}
|
||||
|
||||
/// [`recolor`] where `f` gives gray colors, so embedded images are stored with one gray channel.
|
||||
fn recolor_gray(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
|
||||
run_pass(s, p, cmd, label, Recolor { gray_images: true, ..Recolor::new(Scope::of(p), f) })
|
||||
}
|
||||
|
||||
/// [`recolor`] with solid colours and gradient stops mapped through `links`, with their swatch
|
||||
/// link and tint.
|
||||
pub(crate) fn recolor_links(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color, links: &LinkMap) -> Result<Value> {
|
||||
@@ -575,10 +597,11 @@ fn adjust_balance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
other => return Err(bad(C, format!("unknown mode `{other}` (rgb|cmyk|gray|global)"))),
|
||||
};
|
||||
let convert = bool_or(p, "convert", false);
|
||||
recolor(s, p, C, "Adjust Colors", &move |c| {
|
||||
let f = move |c| {
|
||||
let out = adjust(c);
|
||||
if convert { out } else { keep_model(c, out) }
|
||||
})
|
||||
};
|
||||
if convert && mode == "gray" { recolor_gray(s, p, C, "Adjust Colors", &f) } else { recolor(s, p, C, "Adjust Colors", &f) }
|
||||
}
|
||||
|
||||
enum BlendOrder {
|
||||
|
||||
@@ -126,13 +126,20 @@ fn selected_part(n: &Node, keep: &HashSet<NodeId>) -> Option<Node> {
|
||||
/// holds a symbol instance also stays when what the canvas paints for it reaches `rect`
|
||||
/// ([`vectorcraft_render::painted_bounds`]: the effects in the symbols' art and its own effects). A
|
||||
/// clipping layer keeps every object, its clipping path among them. An artboard's file then holds
|
||||
/// its own art, not a whole document of artboards.
|
||||
pub(super) fn art_over(doc: &Document, rect: vectorcraft_geom::Rect) -> Document {
|
||||
fn kept(n: &Arc<Node>, doc: &Document, symbols: &SymbolBoxes, rect: vectorcraft_geom::Rect) -> Option<Arc<Node>> {
|
||||
/// its own art, not a whole document of artboards. `renderer` measures the 3D art, and keeps it for
|
||||
/// the next artboard of the same document.
|
||||
pub(super) fn art_over(doc: &Document, rect: vectorcraft_geom::Rect, renderer: &mut vectorcraft_render::Renderer) -> Document {
|
||||
fn kept(
|
||||
n: &Arc<Node>,
|
||||
doc: &Document,
|
||||
symbols: &SymbolBoxes,
|
||||
rect: vectorcraft_geom::Rect,
|
||||
renderer: &mut vectorcraft_render::Renderer,
|
||||
) -> Option<Arc<Node>> {
|
||||
if matches!(n.kind, NodeKind::Layer { clip: false, .. }) {
|
||||
let mut layer = Node::clone(n);
|
||||
let children = layer.children_mut()?;
|
||||
*children = children.iter().filter_map(|c| kept(c, doc, symbols, rect)).collect();
|
||||
*children = children.iter().filter_map(|c| kept(c, doc, symbols, rect, renderer)).collect();
|
||||
return Some(Arc::new(layer));
|
||||
}
|
||||
// A group that doesn't clip keeps only its members over the artboard, so art gathered in one
|
||||
@@ -141,19 +148,18 @@ pub(super) fn art_over(doc: &Document, rect: vectorcraft_geom::Rect) -> Document
|
||||
if matches!(n.kind, NodeKind::Group { clip: false, .. }) && n.appearance.effects.is_empty() {
|
||||
let mut group = Node::clone(n);
|
||||
let children = group.children_mut()?;
|
||||
*children = children.iter().filter_map(|c| kept(c, doc, symbols, rect)).collect();
|
||||
*children = children.iter().filter_map(|c| kept(c, doc, symbols, rect, renderer)).collect();
|
||||
return (!children.is_empty()).then(|| Arc::new(group));
|
||||
}
|
||||
let over = |b: vectorcraft_geom::Rect| b.x0 <= rect.x1 && rect.x0 <= b.x1 && b.y0 <= rect.y1 && rect.y0 <= b.y1;
|
||||
let mut holds_instance = false;
|
||||
n.walk(&mut |c| holds_instance |= matches!(c.kind, NodeKind::SymbolInstance { .. }));
|
||||
let drawn = || vectorcraft_render::effect_bounds(symbols, n).is_some_and(over);
|
||||
let painted = || holds_instance && vectorcraft_render::painted_bounds(doc, n).is_some_and(over);
|
||||
(n.visual_bounds_with(symbols).is_none_or(over) || drawn() || painted()).then(|| n.clone())
|
||||
(n.visual_bounds_with(symbols).is_none_or(over) || renderer.effect_bounds(symbols, n).is_some_and(over) || painted()).then(|| n.clone())
|
||||
}
|
||||
let symbols = doc.symbol_boxes().with_brush_art();
|
||||
let mut d = doc.clone();
|
||||
d.layers = doc.layers.iter().filter_map(|l| kept(l, doc, &symbols, rect)).collect();
|
||||
d.layers = doc.layers.iter().filter_map(|l| kept(l, doc, &symbols, rect, renderer)).collect();
|
||||
d
|
||||
}
|
||||
|
||||
|
||||
@@ -180,9 +180,11 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
|
||||
let flat = crate::cmd::rasterfx::flatten_pixel_effects_for(doc, opts.profile != vectorcraft_svg::Profile::Tiny12);
|
||||
let doc = flat.as_ref().unwrap_or(doc);
|
||||
let mut enc = Encoded { warnings, ..Encoded::default() };
|
||||
// One renderer measures the art over every artboard: each 3D solid is built once.
|
||||
let mut renderer = vectorcraft_render::Renderer::new();
|
||||
for artboard in boards {
|
||||
// A chosen artboard's file holds the art over it (#550) and is named after it (#3544).
|
||||
let over = artboard.filter(|_| chosen).and_then(|b| doc.artboards.get(b)).map(|a| super::export::art_over(doc, a.rect));
|
||||
let over = artboard.filter(|_| chosen).and_then(|b| doc.artboards.get(b)).map(|a| super::export::art_over(doc, a.rect, &mut renderer));
|
||||
let file = ExportOptions { artboard, artboard_title: chosen, ..opts.clone() };
|
||||
let mut out = vectorcraft_svg::export_full(over.as_ref().unwrap_or(doc), &file, native.as_deref());
|
||||
let bytes = out.take_bytes();
|
||||
|
||||
@@ -1167,9 +1167,11 @@ fn artboard_fit_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
super::panelcmds::refuse_locked(d, &indices)?;
|
||||
}
|
||||
let mut fits = vec![];
|
||||
// One renderer measures every artboard: each 3D solid is built once.
|
||||
let mut renderer = vectorcraft_render::Renderer::new();
|
||||
for i in unlocked {
|
||||
let was = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
if let Some(b) = vectorcraft_render::encode::art_bounds_on(d, was) {
|
||||
if let Some(b) = renderer.art_bounds_on(d, was) {
|
||||
// At least 1 pt a side, as `rect_from` makes Artboard Options' sizes.
|
||||
fits.push((i, was, Rect::new(b.x0, b.y0, b.x0 + b.width().max(1.0), b.y0 + b.height().max(1.0))));
|
||||
}
|
||||
|
||||
@@ -203,10 +203,11 @@ pub(crate) fn whole_or(p: &Value, key: &str, default: u64, cmd: &str) -> Result<
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a command's implementation without the enablement check or journaling.
|
||||
/// Run a command's implementation without the enablement check or journaling. A command with a
|
||||
/// parameter schema runs with its params checked ([`super::run_checked`]).
|
||||
pub(crate) fn run_raw(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
|
||||
let c = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||||
super::run_spec(s, c, p)
|
||||
super::run_checked(s, c, p)
|
||||
}
|
||||
|
||||
/// Merge the undo entries pushed since the history had `from` entries into one labelled step.
|
||||
|
||||
@@ -18,13 +18,24 @@
|
||||
//! `effect.setParams` answer a key the effect doesn't take, or a value of another type than its
|
||||
//! default, with a parameter error ([`check_effect_params`]).
|
||||
//!
|
||||
//! A command with a parameter schema (`CommandSpec::schema`) is also checked against the schema
|
||||
//! before it runs ([`crate::schemas::check`]): the schema refuses a value of the wrong type or out
|
||||
//! of range, a missing required parameter and an unknown key at any depth, and fills in declared
|
||||
//! defaults, so the command runs with the filled-in params. The check here still runs first for
|
||||
//! such a command, so a misspelled top-level key gets the same error from either, and a
|
||||
//! `command.batch` or an `action.batch` checks each step's schema before the first step runs. The
|
||||
//! keys of commands without a schema, and the options of exports and effects, are checked only as
|
||||
//! this module describes.
|
||||
//!
|
||||
//! Saved steps play through [`Session::replay`] instead: a journal or an action recorded by an older
|
||||
//! version, with a key a command no longer reads or an effect parameter it no longer takes, still
|
||||
//! plays. Even there, an effect's `color` that the effect can't read and a Levels or Curves
|
||||
//! plays. A key a schema doesn't name passes there too, and the values of the parameters it names
|
||||
//! are still checked. Even there, an effect's `color` that the effect can't read and a Levels or Curves
|
||||
//! `channel` that the effect doesn't have are refused by `effect.apply` and `effect.setParams`
|
||||
//! themselves ([`check_effect_color`], [`check_effect_channel`]).
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_apischema::shown_key;
|
||||
|
||||
use super::{bad, command_specs, find_command};
|
||||
use crate::Result;
|
||||
@@ -239,9 +250,11 @@ pub fn check_keys(id: &str, doc: &'static str, params: &Value) -> Result<()> {
|
||||
/// The parameter error of command `id` for `key`, which isn't one of `keys`: with the nearest of
|
||||
/// them, or all of them.
|
||||
pub(crate) fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError {
|
||||
let near = nearest(key, keys);
|
||||
let key = shown_key(key);
|
||||
bad(
|
||||
id,
|
||||
match nearest(key, keys) {
|
||||
match near {
|
||||
Some(near) => format!("unknown parameter `{key}`; did you mean `{near}`?"),
|
||||
None if keys.is_empty() => format!("unknown parameter `{key}`; the command takes no parameters"),
|
||||
None => format!("unknown parameter `{key}`; the command takes: {}", keys.join(", ")),
|
||||
@@ -522,6 +535,7 @@ fn check_option_tree(id: &str, within: &str, given: &serde_json::Map<String, Val
|
||||
Some(near) => format!("did you mean `{within}{near}`?"),
|
||||
None => format!("`{}` takes: {}", within.trim_end_matches('.'), keys.join(", ")),
|
||||
};
|
||||
let key = shown_key(key);
|
||||
return Err(bad(id, format!("unknown parameter `{within}{key}`; {why}")));
|
||||
}
|
||||
(Some(Value::Object(inner)), Value::Object(value)) => check_option_tree(id, &format!("{within}{key}."), value, inner)?,
|
||||
@@ -538,9 +552,11 @@ fn check_option_tree(id: &str, within: &str, given: &serde_json::Map<String, Val
|
||||
fn check_option_keys(id: &str, within: &str, given: &serde_json::Map<String, Value>, keys: &[&str], why_not: &str, trees: &Value) -> Result<()> {
|
||||
for (key, value) in given {
|
||||
if !keys.contains(&key.as_str()) {
|
||||
let near = nearest(key, keys);
|
||||
let key = shown_key(key);
|
||||
return Err(bad(
|
||||
id,
|
||||
match nearest(key, keys) {
|
||||
match near {
|
||||
Some(near) => format!("unknown parameter `{within}{key}`; did you mean `{within}{near}`?"),
|
||||
None => format!("unknown parameter `{within}{key}`; {why_not}"),
|
||||
},
|
||||
@@ -647,6 +663,7 @@ fn check_screen_rows(id: &str, within: &str, p: &serde_json::Map<String, Value>)
|
||||
Some(near) => format!("did you mean `{within}settings.{near}`?"),
|
||||
None => format!("`{within}settings` takes: {}", formats.join(", ")),
|
||||
};
|
||||
let format = shown_key(format);
|
||||
return Err(bad(id, format!("unknown parameter `{within}settings.{format}`; {why}")));
|
||||
}
|
||||
if let Value::Object(options) = options {
|
||||
@@ -758,10 +775,13 @@ fn check_params_in(id: &str, params: &Value, in_batch: bool) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
check_keys(id, spec.params, params)?;
|
||||
if in_batch {
|
||||
crate::schemas::check(spec, params, false)?;
|
||||
}
|
||||
if matches!(id, "command.batch" | "action.batch") && !in_batch {
|
||||
for step in params.get("commands").and_then(Value::as_array).into_iter().flatten() {
|
||||
if let (Some(step_id), Some(step_params)) = (step.get("command").and_then(Value::as_str), step.get("params")) {
|
||||
check_params_in(step_id, step_params, true)?;
|
||||
if let Some(step_id) = step.get("command").and_then(Value::as_str) {
|
||||
check_params_in(step_id, step.get("params").unwrap_or(&Value::Null), true)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -778,8 +798,8 @@ pub(crate) fn nearest_command(id: &str) -> Option<&'static str> {
|
||||
/// The error for `id`, which names no command: with the nearest command id, when one is near.
|
||||
pub(crate) fn unknown_command(id: &str) -> crate::EngineError {
|
||||
match nearest_command(id) {
|
||||
Some(near) => crate::EngineError::UnknownCommandNear(id.to_string(), near.to_string()),
|
||||
None => crate::EngineError::UnknownCommand(id.to_string()),
|
||||
Some(near) => crate::EngineError::UnknownCommandNear(shown_key(id), near.to_string()),
|
||||
None => crate::EngineError::UnknownCommand(shown_key(id)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -433,6 +433,7 @@ fn spec_of(p: &Plugin, c: &Command) -> &'static CommandSpec {
|
||||
enabled: has_doc,
|
||||
run: unrouted,
|
||||
journal: true,
|
||||
schema: None,
|
||||
}));
|
||||
made.push(spec);
|
||||
spec
|
||||
|
||||
@@ -48,7 +48,7 @@ impl Kind {
|
||||
|
||||
/// One command spec (the `cmd!` macro wants literals; these ids are built with `concat!`).
|
||||
const fn spec(id: &'static str, label: &'static str, menu: &'static [&'static str], params: &'static str, run: Run, journal: bool) -> CommandSpec {
|
||||
CommandSpec { id, label, menu, shortcut: None, params, enabled: has_doc, run, journal }
|
||||
CommandSpec { id, label, menu, shortcut: None, params, enabled: has_doc, run, journal, schema: None }
|
||||
}
|
||||
|
||||
macro_rules! style_cmds {
|
||||
|
||||
@@ -2,33 +2,106 @@
|
||||
//! toggles (Show Transparency Grid); the Character panel's Snap to Glyph options.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_apischema::{Bound, Param, ParamSchema, Type, Writes};
|
||||
use vectorcraft_doc::SavedView;
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, f64_req, finite_numbers, has_doc, str_param, switch_or};
|
||||
|
||||
// The parameter schemas. Each command's params text is the text its schema writes
|
||||
// (`ParamSchema::params_text`), which a test in `crate::schemas` checks.
|
||||
|
||||
static CAPS_LOCK: ParamSchema = ParamSchema::new(
|
||||
&[Param::new("on", Type::Boolean).optional()],
|
||||
"set or read the Caps Lock state the desktop app reports; while it is on, the drawing tools show the precise crosshair, and Use Precise Cursors keeps its saved value",
|
||||
)
|
||||
.returns("{on}")
|
||||
.writes(Writes::Session);
|
||||
|
||||
static NEW_VIEW: ParamSchema = ParamSchema::new(
|
||||
&[
|
||||
Param::new("name", Type::String).default_note("the first free \"View N\"").null_omitted(),
|
||||
Param::new("center", Type::Point),
|
||||
Param::new("zoom", Type::Number { min: Some(Bound::Exclusive(0.0)), max: None, unit: None }).doc("1 = 100%"),
|
||||
Param::new("rotation", Type::Number { min: None, max: None, unit: Some("deg") }).defaults_to("0"),
|
||||
],
|
||||
"save a view in the document (the app fills in the current one; at most 25, names up to 255 characters, each name once)",
|
||||
)
|
||||
.returns("{name, index}");
|
||||
|
||||
/// One rename or delete of Edit Views….
|
||||
const VIEW_EDIT: &[Param] = &[
|
||||
Param::new("name", Type::String),
|
||||
Param::new("newName", Type::String).optional().null_omitted(),
|
||||
Param::new("delete", Type::Boolean).optional().null_omitted(),
|
||||
];
|
||||
|
||||
static EDIT_VIEWS: ParamSchema = ParamSchema::new(
|
||||
&[
|
||||
Param::new("name", Type::String).optional().null_omitted(),
|
||||
Param::new("newName", Type::String).optional().null_omitted().doc("rename"),
|
||||
Param::new("delete", Type::Boolean).optional().null_omitted(),
|
||||
Param::new("edits", Type::Array(&Type::Object(VIEW_EDIT))).optional().null_omitted().doc("instead of name"),
|
||||
],
|
||||
"rename or delete saved views, all in one undo step; names are those before the edit and must stay unique; no params → {views}",
|
||||
);
|
||||
|
||||
static LIST_VIEWS: ParamSchema = ParamSchema::new(&[], "").returns("{views: [{name, center, zoom, rotation}]}").writes(Writes::Nothing);
|
||||
|
||||
static TRANSPARENCY_GRID: ParamSchema = ParamSchema::new(
|
||||
&[Param::new("on", Type::Boolean).default_note("toggle").null_omitted()],
|
||||
"show the transparency grid behind the active document's artboards (each document has its own setting)",
|
||||
)
|
||||
.returns("{on}")
|
||||
.writes(Writes::Session);
|
||||
|
||||
static GLYPH_SNAP: ParamSchema = ParamSchema::new(
|
||||
&[
|
||||
Param::new("baseline", Type::Boolean).optional(),
|
||||
Param::new("xHeight", Type::Boolean).optional(),
|
||||
Param::new("bounds", Type::Boolean).optional(),
|
||||
Param::new("proximity", Type::Boolean).optional(),
|
||||
Param::new("angular", Type::Boolean).optional(),
|
||||
Param::new("anchors", Type::Boolean).optional(),
|
||||
],
|
||||
"the Character panel's Snap to Glyph options, kept in the preferences (the glyphSnap group, all on by default): which guides View › Snap to Glyph gives: the baseline and x-height of a single line of type, Glyph Bounds (each glyph's edges), Proximity Guides (both edges of each character's width), Angular Guides (a glyph's slanted strokes, and every guide of rotated type) and the glyphs' anchor points; {} reads them",
|
||||
)
|
||||
.returns("the options")
|
||||
.writes(Writes::Preferences);
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "view.capsLock", "Caps Lock Cursor State", [], None, "{on?: bool} set or read the Caps Lock state the desktop app reports; while it is on, the drawing tools show the precise crosshair, and Use Precise Cursors keeps its saved value → {on}", always, caps_lock),
|
||||
cmd!(
|
||||
query "view.capsLock",
|
||||
"Caps Lock Cursor State",
|
||||
[],
|
||||
None,
|
||||
"{on?: bool} set or read the Caps Lock state the desktop app reports; while it is on, the drawing tools show the precise crosshair, and Use Precise Cursors keeps its saved value → {on}",
|
||||
always,
|
||||
caps_lock,
|
||||
schema: &CAPS_LOCK
|
||||
),
|
||||
cmd!(
|
||||
"view.saved.new",
|
||||
"New View…",
|
||||
["View"],
|
||||
None,
|
||||
"{name?, center: [x, y], zoom: (1 = 100%), rotation?: deg} save a view in the document (the app fills in the current one; default \"View N\"; at most 25, names up to 255 characters, each name once) → {name, index}",
|
||||
"{name?: string (default: the first free \"View N\"), center: [x, y], zoom: number > 0 (1 = 100%), rotation?: number deg (default: 0)} save a view in the document (the app fills in the current one; at most 25, names up to 255 characters, each name once) → {name, index}",
|
||||
has_doc,
|
||||
new_view
|
||||
new_view,
|
||||
schema: &NEW_VIEW
|
||||
),
|
||||
cmd!(
|
||||
"view.saved.edit",
|
||||
"Edit Views…",
|
||||
["View"],
|
||||
None,
|
||||
"{name, newName?: rename, delete?: bool} | {edits: [{name, newName?, delete?}…]} rename or delete saved views, all in one undo step; names are those before the edit and must stay unique; no params → {views}",
|
||||
"{name?: string, newName?: string (rename), delete?: bool, edits?: [{name: string, newName?: string, delete?: bool}…] (instead of name)} rename or delete saved views, all in one undo step; names are those before the edit and must stay unique; no params → {views}",
|
||||
has_views,
|
||||
edit_views
|
||||
edit_views,
|
||||
schema: &EDIT_VIEWS
|
||||
),
|
||||
cmd!(query "view.saved.list", "Saved Views", [], None, "{} → {views: [{name, center, zoom, rotation}]}", has_doc, list_views),
|
||||
cmd!(query "view.saved.list", "Saved Views", [], None, "{} → {views: [{name, center, zoom, rotation}]}", has_doc, list_views, schema: &LIST_VIEWS),
|
||||
cmd!(
|
||||
query "view.transparencyGrid",
|
||||
"Show Transparency Grid",
|
||||
@@ -36,16 +109,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Some("Cmd+Shift+D"),
|
||||
"{on?: bool (default: toggle)} show the transparency grid behind the active document's artboards (each document has its own setting) → {on}",
|
||||
has_doc,
|
||||
transparency_grid
|
||||
transparency_grid,
|
||||
schema: &TRANSPARENCY_GRID
|
||||
),
|
||||
cmd!(
|
||||
query "view.snapToGlyph.options",
|
||||
"Snap to Glyph Options",
|
||||
[],
|
||||
None,
|
||||
"{baseline?: bool, xHeight?: bool, bounds?: bool, proximity?: bool, angular?: bool, anchors?: bool} the Character panel's Snap to Glyph options, kept in the preferences (the glyphSnap group, all on by default): which guides View › Snap to Glyph gives — the baseline and x-height of a single line of type, Glyph Bounds (each glyph's edges), Proximity Guides (both edges of each character's width), Angular Guides (a glyph's slanted strokes, and every guide of rotated type) and the glyphs' anchor points; {} reads them → the options",
|
||||
"{baseline?: bool, xHeight?: bool, bounds?: bool, proximity?: bool, angular?: bool, anchors?: bool} the Character panel's Snap to Glyph options, kept in the preferences (the glyphSnap group, all on by default): which guides View › Snap to Glyph gives: the baseline and x-height of a single line of type, Glyph Bounds (each glyph's edges), Proximity Guides (both edges of each character's width), Angular Guides (a glyph's slanted strokes, and every guide of rotated type) and the glyphs' anchor points; {} reads them → the options",
|
||||
always,
|
||||
glyph_snap_options
|
||||
glyph_snap_options,
|
||||
schema: &GLYPH_SNAP
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -280,6 +355,92 @@ mod tests {
|
||||
assert_eq!(s.execute("view.saved.new", &json!({"name": long, "center": [0, 0], "zoom": 1})).unwrap()["name"], "x".repeat(255));
|
||||
}
|
||||
|
||||
/// Each command refuses a value its schema doesn't take with an error naming the parameter, and
|
||||
/// leaves the document, its views, its undo history and the settings as they were.
|
||||
#[test]
|
||||
fn each_view_command_refuses_a_bad_value_naming_the_parameter() {
|
||||
let mut s = session_with_views(&["A"]);
|
||||
for (id, params, named) in [
|
||||
("view.capsLock", json!({"on": "yes"}), "`on`"),
|
||||
("view.capsLock", json!({"of": true}), "`of`"),
|
||||
("view.saved.new", json!({"center": [0, 0], "zoom": 0}), "`zoom`"),
|
||||
("view.saved.new", json!({"center": [0, "0"], "zoom": 1}), "`center`"),
|
||||
("view.saved.new", json!({"center": [0, 0], "zoom": 1, "rotation": "15"}), "`rotation`"),
|
||||
("view.saved.new", json!({"name": 5, "center": [0, 0], "zoom": 1}), "`name`"),
|
||||
("view.saved.new", json!({"zoom": 1}), "`center`"),
|
||||
("view.saved.edit", json!({"name": "A", "delete": "yes"}), "`delete`"),
|
||||
("view.saved.edit", json!({"name": "A", "newName": 7}), "`newName`"),
|
||||
("view.saved.edit", json!({"edits": [{"name": "A", "delete": 1}]}), "`edits[0].delete`"),
|
||||
("view.saved.edit", json!({"edits": [{"newName": "B"}]}), "`edits[0].name`"),
|
||||
("view.saved.edit", json!({"edits": "A"}), "`edits`"),
|
||||
("view.saved.list", json!({"all": true}), "`all`"),
|
||||
("view.transparencyGrid", json!({"on": 1}), "`on`"),
|
||||
("view.snapToGlyph.options", json!({"bounds": "off"}), "`bounds`"),
|
||||
] {
|
||||
let st = s.doc().unwrap();
|
||||
let doc = (st.doc.views.clone(), st.history.undo.len(), st.is_dirty());
|
||||
let (grid, caps, snap) = (st.transparency_grid, s.caps_lock, s.prefs.glyph_snap);
|
||||
let e = s.execute(id, ¶ms).unwrap_err().to_string();
|
||||
assert!(e.contains(id) && e.contains(named), "{id} {params}: {e}");
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((&st.doc.views, st.history.undo.len(), st.is_dirty()), (&doc.0, doc.1, doc.2), "{id} {params}");
|
||||
assert_eq!((st.transparency_grid, s.caps_lock, s.prefs.glyph_snap), (grid, caps, snap), "{id} {params}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A command run by another command's implementation has its params checked against its schema
|
||||
/// too, with nothing changed when they are refused.
|
||||
#[test]
|
||||
fn a_raw_run_is_checked_against_the_schema() {
|
||||
let mut s = session_with_views(&[]);
|
||||
let e = super::super::menucmds::run_raw(&mut s, "view.saved.new", &json!({"center": [0, 0], "zoom": -1})).unwrap_err().to_string();
|
||||
assert!(e.contains("view.saved.new") && e.contains("`zoom` must be a number > 0, not -1"), "{e}");
|
||||
assert!(s.doc().unwrap().doc.views.is_empty());
|
||||
assert!(s.doc().unwrap().history.undo.is_empty());
|
||||
super::super::menucmds::run_raw(&mut s, "view.saved.new", &json!({"center": [0, 0], "zoom": 1})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.views.len(), 1);
|
||||
}
|
||||
|
||||
/// An edit list with nothing in it and an edit that leaves a view as it is add no undo step;
|
||||
/// names with path characters are only names; a key with one is refused and named.
|
||||
#[test]
|
||||
fn edit_views_edges() {
|
||||
let mut s = session_with_views(&["a.b[0]", "plain"]);
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
assert_eq!(s.execute("view.saved.edit", &json!({"edits": []})).unwrap()["views"].as_array().map(Vec::len), Some(2));
|
||||
s.execute("view.saved.edit", &json!({"name": "plain", "newName": "plain"})).unwrap();
|
||||
s.execute("view.saved.edit", &json!({"edits": [{"name": "plain", "newName": "plain"}]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||||
s.execute("view.saved.edit", &json!({"name": "a.b[0]", "newName": "c[1].d"})).unwrap();
|
||||
assert_eq!(names(&mut s), ["c[1].d", "plain"]);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||
let e = s.execute("view.saved.edit", &json!({"a.b": 1})).unwrap_err().to_string();
|
||||
assert!(e.contains("`a.b`"), "{e}");
|
||||
let e = s.execute("view.saved.edit", &json!({"edits": [{"name": "plain", "a.b": 1}]})).unwrap_err().to_string();
|
||||
assert!(e.contains("`edits[0].a.b`; it takes: name, newName, delete"), "{e}");
|
||||
// Repeated and unknown names keep the command's own errors.
|
||||
let e = s.execute("view.saved.edit", &json!({"edits": [{"name": "plain", "newName": "c[1].d"}]})).unwrap_err().to_string();
|
||||
assert!(e.contains("exists"), "{e}");
|
||||
let e = s.execute("view.saved.edit", &json!({"name": "", "delete": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("no view named"), "{e}");
|
||||
}
|
||||
|
||||
/// A refused call during a drag leaves the drag running and adds no undo step; canceling the
|
||||
/// drag then leaves the history as it was.
|
||||
#[test]
|
||||
fn a_refused_call_leaves_an_interaction_alone() {
|
||||
let mut s = session_with_views(&["A"]);
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
s.begin_interaction("Move").unwrap();
|
||||
assert!(s.execute("view.saved.new", &json!({"name": 5, "center": [0, 0], "zoom": 1})).is_err());
|
||||
assert!(s.doc().unwrap().interaction.is_some());
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||||
s.cancel_interaction().unwrap();
|
||||
assert!(s.doc().unwrap().interaction.is_none());
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||||
assert_eq!(names(&mut s), ["A"]);
|
||||
}
|
||||
|
||||
/// Edit Views… is off until the document has a view.
|
||||
#[test]
|
||||
fn edit_views_needs_a_saved_view() {
|
||||
|
||||
@@ -368,7 +368,7 @@ fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
q["ids"] = ids.clone();
|
||||
}
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||||
let result = (spec.run)(s, &q)?;
|
||||
let result = super::run_checked(s, spec, &q)?;
|
||||
if clear_stroke {
|
||||
s.paint.stroke = Paint::None;
|
||||
}
|
||||
|
||||
@@ -112,6 +112,8 @@ pub struct DocState {
|
||||
pub opened_by: Option<Arc<file_access::AutomationRoots>>,
|
||||
/// What [`DocState::any_empty_artboards`] last measured. Not saved.
|
||||
empty_artboards: std::cell::RefCell<EmptyArtboards>,
|
||||
/// What [`DocState::any_art_to_fit`] last measured. Not saved.
|
||||
art_to_fit: std::cell::RefCell<Option<ArtToFit>>,
|
||||
}
|
||||
|
||||
/// Whether a document has an artboard for Delete Empty Artboards to delete, with the document that
|
||||
@@ -124,10 +126,25 @@ struct EmptyArtboards {
|
||||
any: bool,
|
||||
}
|
||||
|
||||
/// Whether an unlocked artboard of those asked about has art that Fit to Artwork Bounds fits it
|
||||
/// to, with what was measured: the document (held as [`EmptyArtboards::doc`] holds it), the
|
||||
/// artboards, and the generations of what the measure reads outside the document. One is kept for
|
||||
/// each open document, and its weak reference keeps the measured document's allocation (not its
|
||||
/// contents) until the next measure or until the document closes.
|
||||
#[derive(Clone, Debug)]
|
||||
struct ArtToFit {
|
||||
doc: std::sync::Weak<Document>,
|
||||
indices: Vec<usize>,
|
||||
outside: (u64, u64),
|
||||
any: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Tests: how many times [`DocState::any_empty_artboards`] measured a document on this thread.
|
||||
pub(crate) static EMPTY_ARTBOARD_MEASURES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
/// Tests: how many times [`DocState::any_art_to_fit`] measured a document on this thread.
|
||||
pub(crate) static ART_TO_FIT_MEASURES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
@@ -309,6 +326,7 @@ impl DocState {
|
||||
last_perspective: None,
|
||||
opened_by: file_access::current(),
|
||||
empty_artboards: Default::default(),
|
||||
art_to_fit: Default::default(),
|
||||
}
|
||||
}
|
||||
/// `measure`'s answer for the document now. `measure` runs once for each version of the document,
|
||||
@@ -323,6 +341,38 @@ impl DocState {
|
||||
}
|
||||
last.any
|
||||
}
|
||||
/// Whether an unlocked artboard of `indices` has art that Fit to Artwork Bounds fits it to. A
|
||||
/// menu asks on every frame it is open, and measuring builds the solids of 3D effects, so the
|
||||
/// answer is kept for this version of the document and those artboards, and a change of the
|
||||
/// selection or the view keeps it. It is measured again when what the measure reads outside the
|
||||
/// document changes: the installed plug-ins, whose effects draw art, and the installed fonts,
|
||||
/// which set the bounds of all type.
|
||||
pub fn any_art_to_fit(&self, indices: &[usize]) -> bool {
|
||||
self.any_art_to_fit_with(indices, (vectorcraft_plugins::registry::revision(), vectorcraft_text::FontDb::global().generation()))
|
||||
}
|
||||
/// [`Self::any_art_to_fit`], with `outside` the generations of the plug-ins and the fonts.
|
||||
pub(crate) fn any_art_to_fit_with(&self, indices: &[usize], outside: (u64, u64)) -> bool {
|
||||
// Measuring runs renderer and plug-in code; should it ever ask again while measuring, it
|
||||
// is measured without being kept rather than borrowed twice.
|
||||
let Ok(mut last) = self.art_to_fit.try_borrow_mut() else {
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
return indices.iter().filter_map(|i| self.doc.artboards.get(*i)).any(|a| !a.locked && r.art_bounds_on(&self.doc, a.rect).is_some());
|
||||
};
|
||||
if let Some(k) = last.as_ref()
|
||||
&& std::ptr::eq(k.doc.as_ptr(), Arc::as_ptr(&self.doc))
|
||||
&& k.indices == indices
|
||||
&& k.outside == outside
|
||||
{
|
||||
return k.any;
|
||||
}
|
||||
#[cfg(test)]
|
||||
ART_TO_FIT_MEASURES.with(|c| c.set(c.get() + 1));
|
||||
// One renderer measures every artboard: each object once.
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let any = indices.iter().filter_map(|i| self.doc.artboards.get(*i)).any(|a| !a.locked && r.art_bounds_on(&self.doc, a.rect).is_some());
|
||||
*last = Some(ArtToFit { doc: Arc::downgrade(&self.doc), indices: indices.to_vec(), outside, any });
|
||||
any
|
||||
}
|
||||
/// Unsaved changes: the document differs from the saved one (selection changes and changes that
|
||||
/// are not edits to save don't count, [`DocState::same_content`]).
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod guard;
|
||||
pub mod inspect;
|
||||
pub mod journal;
|
||||
pub mod link_watch;
|
||||
pub mod schemas;
|
||||
pub mod steps;
|
||||
pub mod store;
|
||||
mod tooling;
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
//! The commands' parameter schemas ([`vectorcraft_apischema::ParamSchema`]) and the API level they
|
||||
//! belong to.
|
||||
//!
|
||||
//! A command with a schema (`CommandSpec::schema`) has its params checked against it by
|
||||
//! [`crate::Session::execute`] before it runs ([`check`]), and by the steps that run a command's
|
||||
//! implementation directly ([`crate::cmd::run_checked`]): a value of the wrong type, out of range
|
||||
//! or not in its list, a missing required parameter or an unknown key is refused with a parameter
|
||||
//! error naming the parameter, and the command does not run, so the document and its undo history
|
||||
//! stay as they were. An omitted value with a default takes it. The steps of a `command.batch` or
|
||||
//! an `action.batch` are checked before the first one runs. Saved steps
|
||||
//! ([`crate::Session::replay`]) keep their keys a schema doesn't name, and their values are checked.
|
||||
//! The command's `params` text is the text its schema writes, and `engine.commands` lists the schema
|
||||
//! of each command that has one.
|
||||
//!
|
||||
//! [`API_VERSION`] goes up by one whenever a schema of a built-in command changes, and
|
||||
//! [`SCHEMA_HASHES`] gains a row with the new level and [`schema_hash`]: a test fails while the
|
||||
//! newest row does not match the schemas. Commands that plug-ins add have no schema.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_apischema::{ParamError, Problem};
|
||||
|
||||
use crate::cmd::params::unknown;
|
||||
use crate::cmd::{CommandSpec, bad, command_specs};
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
/// The API level of the commands' parameters.
|
||||
pub const API_VERSION: u32 = 1;
|
||||
|
||||
/// [`schema_hash`] at each API level, oldest first; the last row is [`API_VERSION`]'s.
|
||||
pub const SCHEMA_HASHES: &[(u32, u64)] = &[(1, 0x9cdb_1087_7074_855c)];
|
||||
|
||||
/// The hash of every built-in command's schema ([`vectorcraft_apischema::hash`]).
|
||||
pub fn schema_hash() -> u64 {
|
||||
vectorcraft_apischema::hash(command_specs().iter().filter_map(|c| Some((c.id, c.schema?))))
|
||||
}
|
||||
|
||||
/// Each built-in schema that is wrong in itself, with why ([`vectorcraft_apischema::ParamSchema::definition_problem`]).
|
||||
/// Worked out once, the first time a command with a schema runs.
|
||||
fn definition_problems() -> &'static [(&'static str, String)] {
|
||||
static PROBLEMS: OnceLock<Vec<(&'static str, String)>> = OnceLock::new();
|
||||
PROBLEMS.get_or_init(|| command_specs().iter().filter_map(|c| Some((c.id, c.schema?.definition_problem()?))).collect())
|
||||
}
|
||||
|
||||
/// `params` checked against the schema of `spec`: a parameter error under the command's id for the
|
||||
/// first problem, or the params with the defaults of omitted values filled in (`None` when the
|
||||
/// command has no schema or nothing was filled in, so `params` is used as it is). A command with no
|
||||
/// schema costs one branch. `saved`: the params of a saved step, whose keys the schema doesn't name
|
||||
/// pass. A schema that is wrong in itself is an internal error, and its command does not run.
|
||||
pub(crate) fn check(spec: &CommandSpec, params: &Value, saved: bool) -> Result<Option<Value>> {
|
||||
let Some(schema) = spec.schema else { return Ok(None) };
|
||||
if let Some((_, why)) = definition_problems().iter().find(|(id, _)| *id == spec.id) {
|
||||
return Err(EngineError::Internal { cmd: spec.id.into(), msg: why.clone() });
|
||||
}
|
||||
let checked = if saved { schema.validate_saved(params) } else { schema.validate(params) };
|
||||
checked.map_err(|e| param_error(spec.id, &e))
|
||||
}
|
||||
|
||||
/// The parameter error of command `id` for `e`, in the words the message catalogs translate. An
|
||||
/// unknown top-level key is reported as the commands without a schema report it, with the nearest
|
||||
/// key.
|
||||
fn param_error(id: &str, e: &ParamError) -> EngineError {
|
||||
let path = &e.path;
|
||||
match &e.problem {
|
||||
Problem::NotAnObject { got } => bad(id, format!("the params must be an object, not {got}")),
|
||||
Problem::Unknown { keys } if !path.contains(['.', '[']) => unknown(id, path, keys),
|
||||
Problem::Unknown { keys } if keys.is_empty() => bad(id, format!("unknown parameter `{path}`; the object takes no keys")),
|
||||
Problem::Unknown { keys } => bad(id, format!("unknown parameter `{path}`; it takes: {keys}", keys = keys.join(", "))),
|
||||
Problem::Missing => bad(id, format!("missing `{path}`")),
|
||||
Problem::WrongType { expected, got } => bad(id, format!("`{path}` must be {expected}, not {got}")),
|
||||
Problem::TooDeep => EngineError::Internal { cmd: id.into(), msg: e.to_string() },
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../../apischema/src/conformance.rs"]
|
||||
mod conformance;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use proptest::prelude::*;
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_apischema::{Bound, Omitted, Param, ParamSchema, Type};
|
||||
|
||||
use super::conformance::{check_schema, instance_valid};
|
||||
use super::*;
|
||||
use crate::Session;
|
||||
|
||||
fn schemas() -> Vec<(&'static str, &'static ParamSchema)> {
|
||||
command_specs().iter().filter_map(|c| Some((c.id, c.schema?))).collect()
|
||||
}
|
||||
|
||||
/// The params text of a command with a schema is the text the schema writes.
|
||||
#[test]
|
||||
fn params_text_is_the_text_the_schema_writes() {
|
||||
assert!(!schemas().is_empty());
|
||||
for c in command_specs() {
|
||||
if let Some(schema) = c.schema {
|
||||
assert_eq!(c.params, schema.params_text(), "{}", c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A schema changed without a new API level (and a new row of hashes) fails here: add the row
|
||||
/// `(API_VERSION + 1, <the hash below>)` and raise [`API_VERSION`].
|
||||
#[test]
|
||||
fn schema_hash_matches_the_api_version() {
|
||||
let last = SCHEMA_HASHES.last().copied();
|
||||
assert_eq!(last.map(|(level, _)| level), Some(API_VERSION), "the last row of SCHEMA_HASHES is not API_VERSION's");
|
||||
assert_eq!(last.map(|(_, hash)| hash), Some(schema_hash()), "the schemas changed: {:#x}", schema_hash());
|
||||
assert!(SCHEMA_HASHES.windows(2).all(|w| w[0].0 < w[1].0), "the API levels go up");
|
||||
}
|
||||
|
||||
/// Every schema is sound in itself ([`ParamSchema::definition_problem`]), so no command is
|
||||
/// refused for its schema, and no parameter or command is newer than the API level.
|
||||
#[test]
|
||||
fn schemas_are_sound_and_levels_are_known() {
|
||||
fn walk(id: &str, params: &[Param]) {
|
||||
for p in params {
|
||||
assert!(p.since >= 1 && p.since <= API_VERSION, "{id}.{}", p.name);
|
||||
if let Type::Object(inner) | Type::Array(&Type::Object(inner)) = p.ty {
|
||||
walk(id, inner);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(definition_problems(), &[]);
|
||||
for (id, schema) in schemas() {
|
||||
assert!(schema.since >= 1 && schema.since <= API_VERSION, "{id}");
|
||||
walk(id, schema.params);
|
||||
}
|
||||
}
|
||||
|
||||
/// Each command's defaults are of its parameter's type (an omitted value takes them), with
|
||||
/// which ones are filled in.
|
||||
#[test]
|
||||
fn declared_defaults_are_filled_in() {
|
||||
let new_view = command_specs().iter().find(|c| c.id == "view.saved.new").and_then(|c| c.schema).unwrap();
|
||||
let filled = new_view.validate(&json!({"center": [1, 2], "zoom": 1})).unwrap().unwrap();
|
||||
assert_eq!(filled["rotation"], json!(0));
|
||||
assert!(filled.get("name").is_none(), "the command works out the name");
|
||||
assert!(new_view.param("rotation").is_some_and(|p| matches!(p.omitted, Omitted::Value("0"))));
|
||||
}
|
||||
|
||||
/// A refusal from the schema names the parameter, and the command does not run.
|
||||
#[test]
|
||||
fn a_refused_value_names_the_parameter_under_the_command() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let e = s.execute("view.saved.new", &json!({"center": [0, 0], "zoom": -1})).unwrap_err().to_string();
|
||||
assert!(e.contains("view.saved.new") && e.contains("`zoom` must be a number > 0, not -1"), "{e}");
|
||||
let e = s.execute("view.saved.new", &json!({"center": [0, 0], "zoom": 1, "rotaton": 5})).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown parameter `rotaton`; did you mean `rotation`?"), "{e}");
|
||||
let e = s.execute("view.saved.new", &Value::Null).unwrap_err().to_string();
|
||||
assert!(e.contains("view.saved.new") && e.contains("missing `center`"), "{e}");
|
||||
assert!(s.doc().unwrap().doc.views.is_empty());
|
||||
assert!(s.doc().unwrap().history.undo.is_empty());
|
||||
}
|
||||
|
||||
/// The key check runs before the schema, so a misspelled key is the error even when a value is
|
||||
/// bad too, and the schema reports the value when the keys are right.
|
||||
#[test]
|
||||
fn a_misspelled_key_is_reported_before_a_bad_value() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let e = s.execute("view.saved.new", &json!({"center": [0, 0], "zoom": -1, "rotaton": 5})).unwrap_err().to_string();
|
||||
assert!(e.contains("did you mean `rotation`?"), "{e}");
|
||||
let e = s.execute("view.saved.new", &json!({"center": "here", "zoom": -1})).unwrap_err().to_string();
|
||||
assert!(e.contains("`center` must be [x, y] (two numbers), not \"here\""), "{e}");
|
||||
}
|
||||
|
||||
/// Saved steps keep a retired key, at the top level and inside `edits`, and a bad value is
|
||||
/// still refused, with no undo step; the journal keeps the params as given.
|
||||
#[test]
|
||||
fn saved_steps_keep_retired_keys_and_check_values() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let retired = json!({"name": "A", "center": [1, 2], "zoom": 2, "fitArt": true});
|
||||
assert!(s.execute("view.saved.new", &retired).is_err());
|
||||
s.replay("view.saved.new", &retired).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.views[0].name, "A");
|
||||
let last = s.journal.last().unwrap();
|
||||
assert_eq!(last.1, retired, "the journal has the params as given, with no default filled in");
|
||||
s.replay("view.saved.edit", &json!({"edits": [{"name": "A", "newName": "B", "was": 1}]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.views[0].name, "B");
|
||||
assert!(s.execute("view.saved.edit", &json!({"edits": [{"name": "B", "newName": "C", "was": 1}]})).is_err());
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
let e = s.replay("view.saved.new", &json!({"center": [1, 2], "zoom": 0, "fitArt": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("`zoom`"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||||
// A frontend that replays through its own path sets the flag, and gets the same rule.
|
||||
let outer = s.set_replaying(true);
|
||||
s.execute("view.saved.new", &json!({"name": "C", "center": [1, 2], "zoom": 2, "fitArt": true})).unwrap();
|
||||
s.set_replaying(outer);
|
||||
}
|
||||
|
||||
/// A batch step a schema refuses stops the batch before its first step runs.
|
||||
#[test]
|
||||
fn a_batch_is_checked_against_the_schemas_before_its_first_step() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let step = |p: Value| json!({"command": "view.saved.new", "params": p});
|
||||
for (last, named) in [
|
||||
(step(json!({"center": [0, 0], "zoom": 0})), "`zoom`"),
|
||||
(step(json!({"center": [0, 0]})), "`zoom`"),
|
||||
(json!({"command": "view.saved.new"}), "`center`"),
|
||||
] {
|
||||
let batch = json!({"commands": [step(json!({"name": "First", "center": [0, 0], "zoom": 1})), last]});
|
||||
let e = s.execute("command.batch", &batch).unwrap_err().to_string();
|
||||
assert!(e.contains("view.saved.new") && e.contains(named), "{e}");
|
||||
assert!(s.doc().unwrap().doc.views.is_empty(), "the first step did not run: {e}");
|
||||
assert!(s.doc().unwrap().history.undo.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// Values and keys far larger than any a command takes are refused with a short error and no
|
||||
/// panic. Nested values are leaked because dropping one recurses per level.
|
||||
#[test]
|
||||
fn hostile_values_are_refused_with_a_short_error() {
|
||||
fn deep(levels: usize) -> Value {
|
||||
(0..levels).fold(json!(1), |inner, _| Value::Array(vec![inner]))
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let huge = "z".repeat(2_000_000);
|
||||
for params in [
|
||||
json!({ huge.as_str(): 1, "center": [0, 0], "zoom": 1 }),
|
||||
json!({"center": [0, 0], "zoom": huge}),
|
||||
json!({"center": huge, "zoom": 1}),
|
||||
json!({"center": [0, 0], "zoom": 1, "name": [huge]}),
|
||||
json!({"center": [0, 0], "zoom": f64::MAX, "rotation": f64::INFINITY}),
|
||||
] {
|
||||
let e = s.execute("view.saved.new", ¶ms).unwrap_err().to_string();
|
||||
assert!(e.chars().count() < 400, "{} chars", e.chars().count());
|
||||
}
|
||||
let e = s.execute("view.saved.edit", &json!({"edits": [{"name": "A", huge.as_str(): 1}]})).unwrap_err().to_string();
|
||||
assert!(e.chars().count() < 400, "{} chars", e.chars().count());
|
||||
let nested = json!({"center": [0, 0]});
|
||||
let Value::Object(mut map) = nested else { panic!() };
|
||||
map.insert("zoom".into(), deep(500_000));
|
||||
let params = Value::Object(map);
|
||||
let e = s.execute("view.saved.new", ¶ms).unwrap_err().to_string();
|
||||
assert!(e.contains("`zoom`") && e.chars().count() < 400, "{e}");
|
||||
std::mem::forget(params);
|
||||
assert!(s.doc().unwrap().doc.views.is_empty());
|
||||
assert!(s.doc().unwrap().history.undo.is_empty());
|
||||
}
|
||||
|
||||
/// Each command's JSON schema follows the 2020-12 rules, and a validator of those rules, which
|
||||
/// shares no code with the writer, gives the same answer as the schema check. None of these
|
||||
/// schemas has a lenient parameter.
|
||||
#[test]
|
||||
fn the_listed_schemas_follow_2020_12_and_agree_with_the_check() {
|
||||
let cases: &[(&str, Value)] = &[
|
||||
("view.capsLock", json!({})),
|
||||
("view.capsLock", json!({"on": true})),
|
||||
("view.capsLock", json!({"on": "yes"})),
|
||||
("view.capsLock", json!({"on": null})),
|
||||
("view.capsLock", json!({"of": true})),
|
||||
("view.saved.new", json!({"center": [0, 0], "zoom": 1})),
|
||||
("view.saved.new", json!({"center": [0.5, -2], "zoom": 0.001, "rotation": -30, "name": "A"})),
|
||||
("view.saved.new", json!({"center": [0, 0], "zoom": 1, "name": null})),
|
||||
("view.saved.new", json!({"center": [0, 0], "zoom": 1, "rotation": null})),
|
||||
("view.saved.new", json!({"center": [0, 0], "zoom": 0})),
|
||||
("view.saved.new", json!({"center": [0, 0], "zoom": -1})),
|
||||
("view.saved.new", json!({"center": [0], "zoom": 1})),
|
||||
("view.saved.new", json!({"center": [0, 0, 0], "zoom": 1})),
|
||||
("view.saved.new", json!({"center": [0, "0"], "zoom": 1})),
|
||||
("view.saved.new", json!({"zoom": 1})),
|
||||
("view.saved.new", json!({"center": [0, 0]})),
|
||||
("view.saved.new", json!({"center": [0, 0], "zoom": 1, "name": 5})),
|
||||
("view.saved.edit", json!({})),
|
||||
("view.saved.edit", json!({"name": "A", "newName": "B"})),
|
||||
("view.saved.edit", json!({"name": null, "delete": null})),
|
||||
("view.saved.edit", json!({"edits": []})),
|
||||
("view.saved.edit", json!({"edits": null})),
|
||||
("view.saved.edit", json!({"edits": [{"name": "A", "delete": true}, {"name": "B", "newName": null}]})),
|
||||
("view.saved.edit", json!({"edits": [{"newName": "B"}]})),
|
||||
("view.saved.edit", json!({"edits": [{"name": "A", "delete": 1}]})),
|
||||
("view.saved.edit", json!({"edits": [{"name": "A", "x": 1}]})),
|
||||
("view.saved.edit", json!({"edits": {"name": "A"}})),
|
||||
("view.saved.list", json!({})),
|
||||
("view.saved.list", json!({"all": true})),
|
||||
("view.transparencyGrid", json!({})),
|
||||
("view.transparencyGrid", json!({"on": null})),
|
||||
("view.transparencyGrid", json!({"on": false})),
|
||||
("view.transparencyGrid", json!({"on": 1})),
|
||||
("view.snapToGlyph.options", json!({"bounds": false, "angular": true})),
|
||||
("view.snapToGlyph.options", json!({"bounds": null})),
|
||||
("view.capsLock", json!(null)),
|
||||
("view.saved.new", json!(null)),
|
||||
("view.saved.edit", json!(null)),
|
||||
("view.saved.list", json!(null)),
|
||||
("view.transparencyGrid", json!(null)),
|
||||
("view.snapToGlyph.options", json!(null)),
|
||||
("view.capsLock", json!([])),
|
||||
("view.saved.new", json!(5)),
|
||||
("view.transparencyGrid", json!("on")),
|
||||
];
|
||||
assert_eq!(schemas().len(), 6);
|
||||
for (id, schema) in schemas() {
|
||||
let written = schema.to_json();
|
||||
check_schema(&written, id).unwrap_or_else(|e| panic!("{e}\n{written:#}"));
|
||||
assert!(cases.iter().any(|(c, _)| *c == id), "{id} has no cases");
|
||||
}
|
||||
for (id, params) in cases {
|
||||
let schema = schemas().into_iter().find(|(c, _)| c == id).unwrap().1;
|
||||
assert_eq!(schema.validate(params).is_ok(), instance_valid(&schema.to_json(), params), "{id} {params}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine's own listing carries the schema of each command that has one.
|
||||
#[test]
|
||||
fn engine_commands_list_the_schemas() {
|
||||
let s = Session::new();
|
||||
let all = s.commands();
|
||||
let grid = all.iter().find(|c| c.id == "view.transparencyGrid").and_then(|c| c.schema.clone()).unwrap();
|
||||
assert_eq!(grid["$schema"], vectorcraft_apischema::DIALECT);
|
||||
assert_eq!(grid["properties"]["on"]["x-default"], "toggle");
|
||||
let listed = serde_json::to_value(all.iter().find(|c| c.id == "view.saved.new").unwrap()).unwrap();
|
||||
assert_eq!(listed["schema"]["required"], json!(["center", "zoom"]));
|
||||
let without = serde_json::to_value(all.iter().find(|c| c.id == "file.new").unwrap()).unwrap();
|
||||
assert!(without.get("schema").is_none());
|
||||
}
|
||||
|
||||
/// The listing says what each View command changes when it runs. Only the commands that make an
|
||||
/// undo step list `document`.
|
||||
#[test]
|
||||
fn each_view_command_lists_what_it_writes() {
|
||||
let listed: Vec<(&str, Value)> = schemas().into_iter().map(|(id, schema)| (id, schema.to_json()["x-writes"].clone())).collect();
|
||||
let expected = [
|
||||
("view.capsLock", "session"),
|
||||
("view.saved.new", "document"),
|
||||
("view.saved.edit", "document"),
|
||||
("view.saved.list", "nothing"),
|
||||
("view.transparencyGrid", "session"),
|
||||
("view.snapToGlyph.options", "preferences"),
|
||||
];
|
||||
for (id, writes) in expected {
|
||||
let got = listed.iter().find(|(c, _)| *c == id).map(|(_, w)| w.clone());
|
||||
assert_eq!(got, Some(json!(writes)), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Values of type `ty` that pass it.
|
||||
fn valid(ty: Type) -> BoxedStrategy<Value> {
|
||||
fn number(min: Option<Bound>, max: Option<Bound>) -> BoxedStrategy<Value> {
|
||||
let low = match min {
|
||||
Some(Bound::Inclusive(a)) => a,
|
||||
Some(Bound::Exclusive(a)) => a + 1e-6_f64.max(a.abs() * 1e-9),
|
||||
None => -1e6,
|
||||
};
|
||||
let high = match max {
|
||||
Some(Bound::Inclusive(b)) => b,
|
||||
Some(Bound::Exclusive(b)) => b - 1e-6_f64.max(b.abs() * 1e-9),
|
||||
None => low.max(0.0) + 1e6,
|
||||
};
|
||||
(low..=high).prop_map(|x| json!(x)).boxed()
|
||||
}
|
||||
match ty {
|
||||
Type::Number { min, max, .. } => number(min, max),
|
||||
Type::Integer { min, max } => (min.unwrap_or(-1_000_000)..=max.unwrap_or(1_000_000)).prop_map(|x| json!(x)).boxed(),
|
||||
Type::Boolean => any::<bool>().prop_map(Value::from).boxed(),
|
||||
Type::String => "[ -~]{0,12}".prop_map(Value::from).boxed(),
|
||||
Type::Enum(names) => prop::sample::select(names.to_vec()).prop_map(Value::from).boxed(),
|
||||
Type::Id => (0u64..10_000).prop_map(Value::from).boxed(),
|
||||
Type::Ids => prop::collection::vec(0u64..10_000, 0..4).prop_map(Value::from).boxed(),
|
||||
Type::Color => Just(json!("#336699")).boxed(),
|
||||
Type::Point => (-1e6..1e6f64, -1e6..1e6f64).prop_map(|(x, y)| json!([x, y])).boxed(),
|
||||
Type::Object(params) => valid_object(params),
|
||||
Type::Array(item) => prop::collection::vec(valid(*item), 0..3).prop_map(Value::from).boxed(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Objects that pass `params`: each required parameter, and each optional one or not.
|
||||
fn valid_object(params: &'static [Param]) -> BoxedStrategy<Value> {
|
||||
let parts: Vec<BoxedStrategy<Option<(String, Value)>>> = params
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let entry = valid(p.ty).prop_map(move |v| Some((p.name.to_string(), v)));
|
||||
if p.required { entry.boxed() } else { prop_oneof![Just(None), entry].boxed() }
|
||||
})
|
||||
.collect();
|
||||
parts.prop_map(|entries| Value::Object(entries.into_iter().flatten().collect::<Map<String, Value>>())).boxed()
|
||||
}
|
||||
|
||||
/// Commands with a schema and params for them that pass it.
|
||||
fn valid_calls() -> BoxedStrategy<(&'static str, Value)> {
|
||||
let calls: Vec<BoxedStrategy<(&'static str, Value)>> =
|
||||
schemas().into_iter().map(|(id, schema)| valid_object(schema.params).prop_map(move |p| (id, p)).boxed()).collect();
|
||||
prop::strategy::Union::new(calls).boxed()
|
||||
}
|
||||
|
||||
proptest! {
|
||||
/// Params that pass a command's schema never get a parameter error about a value's type or
|
||||
/// range from the command itself: the schema refuses whatever the command's own checks would.
|
||||
#[test]
|
||||
fn schema_valid_params_get_no_type_error_from_the_command((id, params) in valid_calls(), views in 0usize..3) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
for i in 0..views {
|
||||
s.execute("view.saved.new", &json!({"name": format!("V{i}"), "center": [0, 0], "zoom": 1})).unwrap();
|
||||
}
|
||||
match s.execute(id, ¶ms) {
|
||||
Err(EngineError::BadParams { msg, .. }) => {
|
||||
prop_assert!(!msg.contains(" must be ") && !msg.contains("missing") && !msg.contains("unknown parameter"), "{id} {params}: {msg}");
|
||||
}
|
||||
Err(EngineError::Internal { msg, .. }) => prop_assert!(false, "{id} {params} panicked: {msg}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,11 @@ impl Session {
|
||||
if !self.replaying {
|
||||
self.check_params(id, params)?;
|
||||
}
|
||||
// A command with a parameter schema runs only with params that pass it, and with the
|
||||
// defaults of omitted values filled in; the journal keeps the params as given. Saved steps
|
||||
// keep their keys the schema doesn't name, as above.
|
||||
let checked = crate::schemas::check(spec, params, self.replaying)?;
|
||||
let run_params = checked.as_ref().unwrap_or(params);
|
||||
// Objects given as `id` (where it stands for `ids` of one) or as `ids` beside it are checked
|
||||
// before anything else: a malformed one, or one naming no object, is an error under this
|
||||
// command's id, never the selection in its place. `effect.apply {id: "stylize.dropShadow"}`
|
||||
@@ -126,7 +131,7 @@ impl Session {
|
||||
if let Some(ids) = &select_first {
|
||||
s.execute("select.set", &serde_json::json!({ "ids": ids }))?;
|
||||
}
|
||||
let r = cmd::run_spec(s, spec, params);
|
||||
let r = cmd::run_spec(s, spec, run_params);
|
||||
if r.is_err()
|
||||
&& let Some((uid, selection, layer)) = selected_before
|
||||
&& let Some(st) = s.docs.iter_mut().find(|d| d.uid == uid)
|
||||
|
||||
@@ -823,6 +823,13 @@ fn shape_builder_applies_a_merge_before_what_comes_next() {
|
||||
// No files: the command fails after the merge.
|
||||
assert!(s.execute("text.addFontFiles", &json!({"files": []})).is_err());
|
||||
}),
|
||||
("a call its schema refuses", |s, _, _| {
|
||||
// The refused call adds no undo step of its own: the merge's is the last one.
|
||||
let e = s.execute("view.saved.new", &json!({"center": [0, 0], "zoom": 0})).unwrap_err().to_string();
|
||||
assert!(e.contains("`zoom`"), "{e}");
|
||||
assert!(s.doc().unwrap().doc.views.is_empty());
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Shape Builder"));
|
||||
}),
|
||||
];
|
||||
let _proof = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let dir = vectorcraft_testkit::temp_dir("shape-builder-merge-first");
|
||||
|
||||
@@ -342,7 +342,12 @@ fn pixels(s: &Session, id: NodeId) -> Vec<[u8; 4]> {
|
||||
|
||||
/// A selected embedded 2×2 image.
|
||||
fn image_node(s: &mut Session) -> NodeId {
|
||||
let bytes = png([[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255, 255, 255, 128]]);
|
||||
image_of(s, [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255, 255, 255, 128]])
|
||||
}
|
||||
|
||||
/// A selected embedded 2×2 image of the pixels `px`.
|
||||
fn image_of(s: &mut Session, px: [[u8; 4]; 4]) -> NodeId {
|
||||
let bytes = png(px);
|
||||
s.edit("Place", |d, sel| {
|
||||
d.images.insert("pic".into(), vectorcraft_doc::ImageBlob::png(bytes));
|
||||
let id = d.alloc_id();
|
||||
@@ -383,6 +388,85 @@ fn invert_recolours_an_embedded_image() {
|
||||
assert_eq!(pixels(&s, id)[..2], [[0, 0, 0, 255], [0, 255, 0, 255]]);
|
||||
}
|
||||
|
||||
/// The key of image object `id`'s blob.
|
||||
fn image_key(s: &Session, id: NodeId) -> String {
|
||||
let NodeKind::Image(im) = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("not an image") };
|
||||
im.key.clone()
|
||||
}
|
||||
|
||||
/// Convert to Grayscale stores an image with one color channel, alpha kept (#5632).
|
||||
#[test]
|
||||
fn convert_to_grayscale_stores_an_image_as_grayscale() {
|
||||
let mut s = session();
|
||||
let id = image_node(&mut s);
|
||||
let r = s.execute("edit.colors.toGrayscale", &json!({})).unwrap();
|
||||
assert_eq!(r["changed"], 1);
|
||||
let key = image_key(&s, id);
|
||||
let source = s.doc().unwrap().doc.images[&key].gray_source();
|
||||
assert!(source.is_some_and(|g| g.alpha && !g.one_bit), "a grayscale file with alpha: {source:?}");
|
||||
let gray = |hex: &str| Color::from_hex(hex).unwrap().in_model(vectorcraft_color::cms::Model::Gray).to_rgba8(1.0)[0];
|
||||
let (r_, g_, b_, w) = (gray("#ff0000"), gray("#00ff00"), gray("#0000ff"), gray("#ffffff"));
|
||||
assert_eq!(pixels(&s, id), [[r_, r_, r_, 255], [g_, g_, g_, 255], [b_, b_, b_, 255], [w, w, w, 128]]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(pixels(&s, id)[0], [255, 0, 0, 255], "one undo step restores the color image");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
// An image that is already grayscale has nothing to convert.
|
||||
let again = s.execute("edit.colors.toGrayscale", &json!({})).unwrap();
|
||||
assert_eq!(again["changed"], 0);
|
||||
assert_eq!(image_key(&s, id), key);
|
||||
}
|
||||
|
||||
/// An RGB image whose pixels are all neutral still becomes a one-channel image (#5632).
|
||||
#[test]
|
||||
fn convert_to_grayscale_stores_a_neutral_rgb_image_as_grayscale() {
|
||||
let mut s = session();
|
||||
let id = image_of(&mut s, [[255, 255, 255, 255], [0, 0, 0, 255], [255, 255, 255, 255], [0, 0, 0, 255]]);
|
||||
let r = s.execute("edit.colors.toGrayscale", &json!({})).unwrap();
|
||||
assert_eq!(r["changed"], 1);
|
||||
let source = s.doc().unwrap().doc.images[&image_key(&s, id)].gray_source();
|
||||
assert!(source.is_some_and(|g| !g.alpha), "an opaque image has no alpha channel: {source:?}");
|
||||
}
|
||||
|
||||
/// Select All leaves a hidden neutral RGB image out of Convert to Grayscale, which would change it.
|
||||
#[test]
|
||||
fn a_hidden_neutral_rgb_image_counts_as_not_converted() {
|
||||
let mut s = session();
|
||||
let hidden = image_of(&mut s, [[255, 255, 255, 255], [0, 0, 0, 255], [255, 255, 255, 255], [0, 0, 0, 255]]);
|
||||
s.execute("layer.setProps", &json!({"ids": [hidden.0], "visible": false})).unwrap();
|
||||
rect(&mut s, json!("#ff0000"));
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let r = s.execute("edit.colors.toGrayscale", &json!({})).unwrap();
|
||||
assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 1}), "{r}");
|
||||
}
|
||||
|
||||
/// Adjust Colors converting to gray stores an image with one channel; without Convert it keeps
|
||||
/// the image's RGB (#5632).
|
||||
#[test]
|
||||
fn adjust_colors_converting_to_gray_stores_an_image_as_grayscale() {
|
||||
let source = |convert: bool| {
|
||||
let mut s = session();
|
||||
let id = image_node(&mut s);
|
||||
s.execute("edit.colors.adjustBalance", &json!({"mode": "gray", "gray": 10, "convert": convert})).unwrap();
|
||||
s.doc().unwrap().doc.images[&image_key(&s, id)].gray_source()
|
||||
};
|
||||
assert!(source(true).is_some(), "converted to gray");
|
||||
assert!(source(false).is_none(), "the colors keep their model");
|
||||
}
|
||||
|
||||
/// A PDF of a CMYK document writes an image Convert to Grayscale made with one channel (#5632).
|
||||
#[test]
|
||||
fn a_converted_image_is_written_to_pdf_as_gray_in_a_cmyk_document() {
|
||||
let mut s = session();
|
||||
s.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap();
|
||||
image_of(&mut s, [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [10, 200, 30, 255]]);
|
||||
s.execute("edit.colors.toGrayscale", &json!({})).unwrap();
|
||||
let opts = vectorcraft_pdf::PdfOptions { created: Some(0), ..vectorcraft_pdf::PdfOptions::uncompressed() };
|
||||
let pdf = String::from_utf8_lossy(&vectorcraft_pdf::export(&s.doc().unwrap().doc, &opts).unwrap()).into_owned();
|
||||
let image = pdf.split("/Subtype/Image").nth(1).or_else(|| pdf.split("/Subtype /Image").nth(1)).expect("an image in the PDF");
|
||||
let image = &image[..image.find("stream").unwrap_or(image.len())];
|
||||
assert!(image.contains("/DeviceGray") && !image.contains("/DeviceRGB"), "one color channel: {image}");
|
||||
}
|
||||
|
||||
/// "Dots": a red square tile; a selected rectangle filled with it.
|
||||
fn pattern_fill(s: &mut Session) -> NodeId {
|
||||
rect(s, json!("#ff0000"));
|
||||
|
||||
@@ -1786,6 +1786,54 @@ fn delete_empty_artboards_check_measures_a_document_once() {
|
||||
assert_eq!(delete_empty_enabled_counted(&s), (true, 0), "after another change of the selection");
|
||||
}
|
||||
|
||||
/// Whether Fit to Artwork Bounds has art to fit on `indices`, as a menu asks for it, with the
|
||||
/// plug-ins' and fonts' generations `outside`, and how many times asking measured a document on
|
||||
/// this thread.
|
||||
fn art_to_fit_counted(s: &Session, indices: &[usize], outside: (u64, u64)) -> (bool, usize) {
|
||||
let measures = || crate::doc_state::ART_TO_FIT_MEASURES.with(std::cell::Cell::get);
|
||||
let before = measures();
|
||||
let any = s.doc().unwrap().any_art_to_fit_with(indices, outside);
|
||||
(any, measures() - before)
|
||||
}
|
||||
|
||||
/// A menu asks whether Fit to Artwork Bounds has art on every frame while it is open: the answer is
|
||||
/// measured once for a version of the document and the artboards asked about, kept through a change
|
||||
/// of the selection or the view, and measured again after an edit, for other artboards, and when
|
||||
/// the plug-ins or the fonts change.
|
||||
#[test]
|
||||
fn art_to_fit_is_measured_once_for_a_version_of_the_document() {
|
||||
let mut s = row_of_artboards(2);
|
||||
let both = [0, 1];
|
||||
assert_eq!(art_to_fit_counted(&s, &both, (0, 0)), (false, 1), "the first frame");
|
||||
assert_eq!(art_to_fit_counted(&s, &both, (0, 0)), (false, 0), "a later frame");
|
||||
let art = box_at(&mut s, 10.0, 20.0);
|
||||
assert_eq!(art_to_fit_counted(&s, &both, (0, 0)), (true, 1), "an edit puts art on artboard 1");
|
||||
s.execute("select.set", &json!({"ids": [art.0]})).unwrap();
|
||||
assert_eq!(art_to_fit_counted(&s, &both, (0, 0)), (true, 0), "after a change of the selection");
|
||||
s.execute("view.transparencyGrid", &json!({})).unwrap();
|
||||
assert_eq!(art_to_fit_counted(&s, &both, (0, 0)), (true, 0), "after a change of the view");
|
||||
assert_eq!(art_to_fit_counted(&s, &[1], (0, 0)), (false, 1), "other artboards");
|
||||
assert_eq!(art_to_fit_counted(&s, &[1], (1, 0)), (false, 1), "a plug-in installed or removed");
|
||||
assert_eq!(art_to_fit_counted(&s, &[1], (1, 1)), (false, 1), "fonts installed");
|
||||
assert_eq!(art_to_fit_counted(&s, &[1], (1, 1)), (false, 0));
|
||||
}
|
||||
|
||||
/// A change made to the document in place (`Arc::make_mut`, with no other holder and no change
|
||||
/// of revision) is measured again: the kept answer holds the document it measured weakly, so a
|
||||
/// changed document is a new allocation, not the old address.
|
||||
#[test]
|
||||
fn art_to_fit_follows_a_change_in_place() {
|
||||
let mut s = row_of_artboards(2);
|
||||
box_at(&mut s, 10.0, 20.0);
|
||||
assert_eq!(art_to_fit_counted(&s, &[0], (0, 0)), (true, 1), "art on artboard 1");
|
||||
let st = s.doc_mut().unwrap();
|
||||
assert_eq!(Arc::strong_count(&st.doc), 1, "the undo history holds the document before the edit");
|
||||
let (revision, empty_spot) = (st.revision, st.doc.artboards[1].rect);
|
||||
Arc::make_mut(&mut st.doc).artboards[0].rect = empty_spot;
|
||||
assert_eq!(st.revision, revision);
|
||||
assert_eq!(art_to_fit_counted(&s, &[0], (0, 0)), (false, 1), "artboard 1 moved off the art");
|
||||
}
|
||||
|
||||
/// The enabled state follows an edit, an undo, a redo, an added and a removed artboard, a locked
|
||||
/// artboard, and each change measures the document once.
|
||||
#[test]
|
||||
|
||||
+354
-20
@@ -20,6 +20,8 @@ use crate::fit::{end_tangent, fit_single, is_straight, sample, start_tangent};
|
||||
thread_local! {
|
||||
/// The contour passes made over an arrangement on this thread, each of which walks all of it.
|
||||
pub(crate) static CONTOUR_PASSES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
/// The sweeps [`normalize_bez`] made on this thread.
|
||||
static NORMALIZE_SWEEPS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Default curve-refit tolerance (document points) used to merge split curve pieces back together.
|
||||
@@ -822,6 +824,8 @@ pub fn try_normalize(path: &PathData, rule: FillRule) -> Result<PathData, PathOp
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_bez(bp: &BezPath, rule: FillRule) -> Result<Contours, PathOpsError> {
|
||||
#[cfg(test)]
|
||||
NORMALIZE_SWEEPS.with(|n| n.set(n.get() + 1));
|
||||
let eps = eps_for([bp])?;
|
||||
let mut bp = bp.clone();
|
||||
snap_horizontals(&mut [&mut bp], eps);
|
||||
@@ -983,25 +987,21 @@ pub fn try_unite_all_with(paths: &[(&PathData, FillRule)], precision: f64) -> Re
|
||||
|
||||
/// [`try_unite_all_with`] with the sweeps run on `paths` as they are.
|
||||
pub(crate) fn try_unite_all_upright(paths: &[(&PathData, FillRule)], precision: f64) -> Result<PathData, PathOpsError> {
|
||||
let mut all = BezPath::new();
|
||||
for (p, r) in paths {
|
||||
let bp = fill_bezpath(p);
|
||||
let bps: Vec<BezPath> = paths.iter().map(|(p, _)| fill_bezpath(p)).collect();
|
||||
let mut parts = Vec::new();
|
||||
for (bp, (_, r)) in bps.iter().zip(paths) {
|
||||
if bp.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
for ct in normalize_bez(&bp, *r)?.contours() {
|
||||
all.extend(ct.path.iter());
|
||||
}
|
||||
parts.push((normalize_bez(bp, *r)?, bp.segments().count()));
|
||||
}
|
||||
if all.elements().is_empty() {
|
||||
return Ok(PathData::default());
|
||||
}
|
||||
let tidy = Tidy::keeping(precision, paths.iter().map(|(p, _)| fill_bezpath(p)).collect::<Vec<_>>().iter());
|
||||
Ok(all_contours_to_path(&normalize_bez(&all, FillRule::NonZero)?, &tidy))
|
||||
unite_parts(parts, bps, precision)
|
||||
}
|
||||
|
||||
/// Union of many filled paths. Each input is first normalised on its own, then all are swept
|
||||
/// together with a single integer winding number, which keeps this fast for thousands of shapes.
|
||||
/// A single input whose own sweep split none of its segments, with no two of them within twice the
|
||||
/// sweep tolerance, is not swept a second time.
|
||||
pub fn unite_all(paths: &[(&PathData, FillRule)]) -> PathData {
|
||||
unite_all_with(paths, DEFAULT_PRECISION)
|
||||
}
|
||||
@@ -1019,25 +1019,180 @@ pub fn unite_all_with(paths: &[(&PathData, FillRule)], precision: f64) -> PathDa
|
||||
|
||||
/// [`unite_all_with`] with the sweeps run on `paths` as they are.
|
||||
pub(crate) fn unite_all_upright(paths: &[(&PathData, FillRule)], precision: f64) -> PathData {
|
||||
let mut all = BezPath::new();
|
||||
for (p, r) in paths {
|
||||
let bp = fill_bezpath(p);
|
||||
let bps: Vec<BezPath> = paths.iter().map(|(p, _)| fill_bezpath(p)).collect();
|
||||
let mut parts = Vec::new();
|
||||
for (bp, (_, r)) in bps.iter().zip(paths) {
|
||||
if bp.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(c) = normalize_bez(&bp, *r) else { continue };
|
||||
let Ok(c) = normalize_bez(bp, *r) else { continue };
|
||||
parts.push((c, bp.segments().count()));
|
||||
}
|
||||
unite_parts(parts, bps, precision).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The union of `parts`, each the contours [`normalize_bez`] made of one input with that input's
|
||||
/// segment count, refit to `precision` keeping the anchors of `inputs`.
|
||||
///
|
||||
/// When there is one part, its sweep split none of the input's segments and none of them lies
|
||||
/// within twice the sweep tolerance of another, its contours are the union and are refit as they
|
||||
/// are. Otherwise all the contours are swept again together under NonZero, which also merges the
|
||||
/// short pieces the first sweeps leave where a path crosses itself, and contours that lie within
|
||||
/// that distance of each other.
|
||||
fn unite_parts(parts: Vec<(Contours, usize)>, inputs: Vec<BezPath>, precision: f64) -> Result<PathData, PathOpsError> {
|
||||
if parts.is_empty() {
|
||||
return Ok(PathData::default());
|
||||
}
|
||||
let tidy = Tidy::keeping(precision, inputs.iter());
|
||||
if let [(c, input_segments)] = parts.as_slice()
|
||||
&& splits_nothing(c, *input_segments, &tidy)
|
||||
&& eps_for(inputs.iter()).is_ok_and(|eps| !comes_close(c, 2.0 * eps))
|
||||
{
|
||||
return Ok(all_contours_to_path(c, &tidy));
|
||||
}
|
||||
drop(inputs);
|
||||
let mut all = BezPath::new();
|
||||
for (c, _) in parts {
|
||||
for ct in c.contours() {
|
||||
all.extend(ct.path.iter());
|
||||
}
|
||||
}
|
||||
if all.elements().is_empty() {
|
||||
return PathData::default();
|
||||
return Ok(PathData::default());
|
||||
}
|
||||
let tidy = Tidy::keeping(precision, paths.iter().map(|(p, _)| fill_bezpath(p)).collect::<Vec<_>>().iter());
|
||||
match normalize_bez(&all, FillRule::NonZero) {
|
||||
Ok(c) => all_contours_to_path(&c, &tidy),
|
||||
Err(_) => PathData::default(),
|
||||
Ok(all_contours_to_path(&normalize_bez(&all, FillRule::NonZero)?, &tidy))
|
||||
}
|
||||
|
||||
/// Are the contours `c` the input's own segments: as many as `input_segments`, each starting at an
|
||||
/// anchor `tidy` keeps? A segment split where paths cross gives a piece that starts away from the
|
||||
/// anchors, and a piece dropped inside another path leaves fewer segments than the input.
|
||||
fn splits_nothing(c: &Contours, input_segments: usize, tidy: &Tidy) -> bool {
|
||||
let mut n = 0;
|
||||
for ct in c.contours() {
|
||||
for s in ct.path.segments() {
|
||||
if !tidy.is_kept(to_seg(s).c.p0) {
|
||||
return false;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
n == input_segments
|
||||
}
|
||||
|
||||
/// The pairs of segments [`comes_close`] compares for each segment. Past it the answer is yes.
|
||||
const MAX_CLOSE_PAIRS: usize = 512;
|
||||
|
||||
/// One segment of a contour for [`comes_close`]: its control points' extents along and across the
|
||||
/// side the segments are sorted on, and its place in its contour.
|
||||
struct CloseSeg {
|
||||
c: CubicBez,
|
||||
line: bool,
|
||||
lo: f64,
|
||||
hi: f64,
|
||||
across_lo: f64,
|
||||
across_hi: f64,
|
||||
contour: usize,
|
||||
index: usize,
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl CloseSeg {
|
||||
fn new(seg: Seg, contour: usize, index: usize, len: usize) -> Self {
|
||||
let pts = [seg.c.p0, seg.c.p1, seg.c.p2, seg.c.p3];
|
||||
let (lo, hi) = pts.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(l, h), p| (l.min(p.x), h.max(p.x)));
|
||||
let (across_lo, across_hi) = pts.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(l, h), p| (l.min(p.y), h.max(p.y)));
|
||||
Self { c: seg.c, line: seg.line, lo, hi, across_lo, across_hi, contour, index, len }
|
||||
}
|
||||
}
|
||||
|
||||
/// Does a segment of the contours `c` lie within `touch` of another? A sweep can join such
|
||||
/// segments or leave them apart depending on the order it meets them in, so a second sweep can
|
||||
/// answer differently from the first. Segments whose control points can't be told apart from
|
||||
/// `touch` count as close, which costs only that second sweep.
|
||||
fn comes_close(c: &Contours, touch: f64) -> bool {
|
||||
let mut segs = Vec::new();
|
||||
for (contour, ct) in c.contours().enumerate() {
|
||||
let len = ct.path.segments().count();
|
||||
for (index, s) in ct.path.segments().enumerate() {
|
||||
segs.push(CloseSeg::new(to_seg(s), contour, index, len));
|
||||
}
|
||||
}
|
||||
// Sort along the longer side of the whole, where fewer segments overlap.
|
||||
let extent = |lo: fn(&CloseSeg) -> f64, hi: fn(&CloseSeg) -> f64| {
|
||||
segs.iter().map(hi).fold(f64::NEG_INFINITY, f64::max) - segs.iter().map(lo).fold(f64::INFINITY, f64::min)
|
||||
};
|
||||
if extent(|s| s.across_lo, |s| s.across_hi) > extent(|s| s.lo, |s| s.hi) {
|
||||
for s in &mut segs {
|
||||
std::mem::swap(&mut s.lo, &mut s.across_lo);
|
||||
std::mem::swap(&mut s.hi, &mut s.across_hi);
|
||||
}
|
||||
}
|
||||
segs.sort_by(|a, b| a.lo.total_cmp(&b.lo));
|
||||
let (mut pairs, budget) = (0usize, segs.len().saturating_mul(MAX_CLOSE_PAIRS));
|
||||
for (i, a) in segs.iter().enumerate() {
|
||||
for b in segs.iter().skip(i + 1) {
|
||||
if b.lo > a.hi + touch {
|
||||
break;
|
||||
}
|
||||
pairs += 1;
|
||||
if pairs > budget {
|
||||
return true;
|
||||
}
|
||||
if a.across_lo > b.across_hi + touch || b.across_lo > a.across_hi + touch {
|
||||
continue;
|
||||
}
|
||||
let neighbors = a.contour == b.contour && ((a.index + 1) % a.len == b.index || (b.index + 1) % b.len == a.index);
|
||||
let close = if neighbors { neighbors_run_together(a, b, touch) } else { !hulls_apart(&a.c, &b.c, touch) };
|
||||
if close {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The sine of the angle below which two segments leaving a shared end count as parallel.
|
||||
const PARALLEL_SINE: f64 = 1e-3;
|
||||
|
||||
/// Do segments `a` and `b`, which share an end, run along each other within `touch`? Straight
|
||||
/// segments do when the far end of either lies within `touch` of the other. A curve does when the
|
||||
/// two leave their shared end in the same direction, or when their extents overlap by more than
|
||||
/// `touch` both ways.
|
||||
fn neighbors_run_together(a: &CloseSeg, b: &CloseSeg, touch: f64) -> bool {
|
||||
use kurbo::ParamCurveNearest;
|
||||
if a.line && b.line {
|
||||
let (shared, p, q) = if a.c.p3 == b.c.p0 { (a.c.p3, a.c.p0, b.c.p3) } else { (a.c.p0, a.c.p3, b.c.p0) };
|
||||
let near = |p: Point, q: Point| kurbo::Line::new(shared, q).nearest(p, 0.0).distance_sq.sqrt() <= touch;
|
||||
return near(q, p) || near(p, q);
|
||||
}
|
||||
let (from_a, from_b) = if a.c.p3 == b.c.p0 {
|
||||
([a.c.p3, a.c.p2, a.c.p1, a.c.p0], [b.c.p0, b.c.p1, b.c.p2, b.c.p3])
|
||||
} else {
|
||||
([a.c.p0, a.c.p1, a.c.p2, a.c.p3], [b.c.p3, b.c.p2, b.c.p1, b.c.p0])
|
||||
};
|
||||
let away = |pts: [Point; 4]| pts.iter().skip(1).map(|p| *p - pts[0]).find(|v| v.hypot2() > 0.0);
|
||||
if let (Some(u), Some(v)) = (away(from_a), away(from_b))
|
||||
&& u.dot(v) > 0.0
|
||||
&& u.cross(v).abs() <= PARALLEL_SINE * u.hypot() * v.hypot()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
a.hi.min(b.hi) - a.lo.max(b.lo) > touch && a.across_hi.min(b.across_hi) - a.across_lo.max(b.across_lo) > touch
|
||||
}
|
||||
|
||||
/// Is there a direction, along or across a side of either segment's control polygon or chord, in
|
||||
/// which the control points of `a` and `b` lie more than `touch` apart?
|
||||
fn hulls_apart(a: &CubicBez, b: &CubicBez, touch: f64) -> bool {
|
||||
let (pa, pb) = ([a.p0, a.p1, a.p2, a.p3], [b.p0, b.p1, b.p2, b.p3]);
|
||||
let apart = |d: kurbo::Vec2| {
|
||||
let span =
|
||||
|pts: &[Point; 4]| pts.iter().map(|p| p.to_vec2().dot(d)).fold((f64::INFINITY, f64::NEG_INFINITY), |(l, h), v| (l.min(v), h.max(v)));
|
||||
let ((la, ha), (lb, hb)) = (span(&pa), span(&pb));
|
||||
let gap = touch * d.hypot();
|
||||
gap > 0.0 && (lb - ha > gap || la - hb > gap)
|
||||
};
|
||||
let sides = |p: [Point; 4]| [p[3] - p[0], p[1] - p[0], p[2] - p[1], p[3] - p[2]];
|
||||
sides(pa).into_iter().chain(sides(pb)).any(|d| apart(d) || apart(kurbo::Vec2::new(-d.y, d.x)))
|
||||
}
|
||||
|
||||
/// [`area`] that reports a refused input instead of an area of zero.
|
||||
@@ -1316,6 +1471,185 @@ mod tests {
|
||||
assert!(arrangement_adds_up(&arr, [&s, &t]));
|
||||
}
|
||||
|
||||
/// The union with every input swept on its own and then all their contours swept together.
|
||||
fn unite_swept_twice(paths: &[(&PathData, FillRule)], precision: f64) -> PathData {
|
||||
let bps: Vec<BezPath> = paths.iter().map(|(p, _)| fill_bezpath(p)).collect();
|
||||
let mut all = BezPath::new();
|
||||
for (bp, (_, r)) in bps.iter().zip(paths) {
|
||||
for ct in normalize_bez(bp, *r).unwrap().contours() {
|
||||
all.extend(ct.path.iter());
|
||||
}
|
||||
}
|
||||
all_contours_to_path(&normalize_bez(&all, FillRule::NonZero).unwrap(), &Tidy::keeping(precision, bps.iter()))
|
||||
}
|
||||
|
||||
/// The unions of `paths` by [`unite_all_with`] and [`try_unite_all_with`], each with the sweeps it made.
|
||||
fn unite_counting_sweeps(paths: &[(&PathData, FillRule)]) -> [(PathData, usize); 2] {
|
||||
let counted = |f: &dyn Fn() -> PathData| {
|
||||
NORMALIZE_SWEEPS.with(|n| n.set(0));
|
||||
let out = f();
|
||||
(out, NORMALIZE_SWEEPS.with(std::cell::Cell::get))
|
||||
};
|
||||
[counted(&|| unite_all_with(paths, DEFAULT_PRECISION)), counted(&|| try_unite_all_with(paths, DEFAULT_PRECISION).unwrap())]
|
||||
}
|
||||
|
||||
/// Disjoint rings, each a circle with a circular hole, as one even-odd compound path.
|
||||
fn rings(n: usize) -> PathData {
|
||||
let mut bp = BezPath::new();
|
||||
for i in 0..n {
|
||||
let c = Point::new((i % 10) as f64 * 30.0, (i / 10) as f64 * 30.0);
|
||||
bp.extend(kurbo::Circle::new(c, 12.0).path_elements(0.1));
|
||||
bp.extend(kurbo::Circle::new(c, 5.0).path_elements(0.1));
|
||||
}
|
||||
PathData::from_bezpath(&bp)
|
||||
}
|
||||
|
||||
/// A polygon with `n` teeth of different heights along its top.
|
||||
fn zigzag(n: usize) -> PathData {
|
||||
let mut pts: Vec<Point> = (0..=n).map(|i| Point::new(i as f64 * 4.0, if i % 2 == 0 { 0.0 } else { -10.0 - (i * 37 % 11) as f64 })).collect();
|
||||
pts.push(Point::new(n as f64 * 4.0, 20.0));
|
||||
pts.push(Point::new(0.0, 20.0));
|
||||
PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true))
|
||||
}
|
||||
|
||||
/// A sweep can leave segments within its tolerance apart where a second sweep joins them.
|
||||
#[test]
|
||||
fn a_lone_input_with_segments_within_the_tolerance_is_swept_again() {
|
||||
use vectorcraft_geom::SubPath;
|
||||
let triangles = |gap: f64, lift: f64| {
|
||||
PathData::new(vec![
|
||||
SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], true),
|
||||
SubPath::polyline(&[Point::new(10.0 + gap, lift), Point::new(10.0 + gap, 10.0 + lift), Point::new(gap, 10.0 + lift)], true),
|
||||
])
|
||||
};
|
||||
let mut bp = BezPath::new();
|
||||
bp.extend(kurbo::Circle::new((0.0, 0.0), 10.0).path_elements(0.1));
|
||||
bp.extend(kurbo::Circle::new((20.000001, 0.0), 10.0).path_elements(0.1));
|
||||
let circles = PathData::from_bezpath(&bp);
|
||||
for (name, path, joined) in
|
||||
[("snapped", triangles(0.99e-6, 0.99e-6), Some(1)), ("same heights", triangles(0.9e-6, 0.0), Some(1)), ("circles", circles, None)]
|
||||
{
|
||||
let want = unite_swept_twice(&[(&path, FillRule::NonZero)], DEFAULT_PRECISION);
|
||||
if let Some(n) = joined {
|
||||
assert_eq!(want.subpaths.len(), n, "{name}");
|
||||
}
|
||||
for (got, sweeps) in unite_counting_sweeps(&[(&path, FillRule::NonZero)]) {
|
||||
assert_eq!(sweeps, 2, "{name}");
|
||||
assert_eq!(got, want, "{name}");
|
||||
}
|
||||
}
|
||||
// Farther apart than that, the sweep's own contours stand.
|
||||
let apart = triangles(1e-5, 0.0);
|
||||
let want = unite_swept_twice(&[(&apart, FillRule::NonZero)], DEFAULT_PRECISION);
|
||||
assert_eq!(want.subpaths.len(), 2);
|
||||
for (got, sweeps) in unite_counting_sweeps(&[(&apart, FillRule::NonZero)]) {
|
||||
assert_eq!(sweeps, 1);
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lone_part_beside_a_refused_input_keeps_its_union() {
|
||||
use vectorcraft_geom::SubPath;
|
||||
let polygon = |pts: &[(f64, f64)]| PathData::single(SubPath::polyline(&pts.iter().map(|p| Point::new(p.0, p.1)).collect::<Vec<_>>(), true));
|
||||
let square = polygon(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]);
|
||||
let refused = polygon(&[(f64::NAN, 0.0), (1.0, 1.0), (0.0, 1.0)]);
|
||||
let want = unite_swept_twice(&[(&square, FillRule::NonZero)], DEFAULT_PRECISION);
|
||||
assert_eq!(unite_all(&[(&square, FillRule::NonZero), (&refused, FillRule::NonZero)]), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neighboring_segments_run_together_when_one_lies_along_the_other() {
|
||||
let line = |a: (f64, f64), b: (f64, f64)| CloseSeg::new(Seg::line(a.into(), b.into()), 0, 0, 2);
|
||||
let curve = |p: [(f64, f64); 4]| CloseSeg::new(Seg { c: CubicBez::new(p[0], p[1], p[2], p[3]), line: false }, 0, 0, 2);
|
||||
let touch = 2e-6;
|
||||
// The two sides of a needle's tip, and an ordinary corner.
|
||||
assert!(neighbors_run_together(&line((10.0, 10.0), (30.0, 30.0)), &line((30.0, 30.0), (10.0, 10.000001)), touch));
|
||||
assert!(!neighbors_run_together(&line((0.0, 0.0), (10.0, 0.0)), &line((10.0, 0.0), (10.0, 10.0)), touch));
|
||||
// Curves that fold back over each other, and two quarters of a circle.
|
||||
let out = curve([(0.0, 0.0), (5.0, 8.0), (10.0, 8.0), (20.0, 0.0)]);
|
||||
assert!(neighbors_run_together(&out, &curve([(20.0, 0.0), (10.0, 6.0), (5.0, 6.0), (0.0, 1.0)]), touch));
|
||||
// A curved hairpin that lies within the tolerance along its length.
|
||||
let there = curve([(0.0, 0.0), (5.0, 1e-7), (15.0, 1e-7), (20.0, 0.0)]);
|
||||
assert!(neighbors_run_together(&there, &curve([(20.0, 0.0), (15.0, 2e-7), (5.0, 2e-7), (0.0, 1e-6)]), touch));
|
||||
let quarter = curve([(10.0, 0.0), (10.0, 5.5), (5.5, 10.0), (0.0, 10.0)]);
|
||||
assert!(!neighbors_run_together(&quarter, &curve([(0.0, 10.0), (-5.5, 10.0), (-10.0, 5.5), (-10.0, 0.0)]), touch));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lone_input_its_own_sweep_does_not_split_is_swept_once() {
|
||||
for (name, path, rule) in [("rings", rings(40), FillRule::EvenOdd), ("zigzag", zigzag(301), FillRule::NonZero)] {
|
||||
let want = unite_swept_twice(&[(&path, rule)], DEFAULT_PRECISION);
|
||||
assert!(!want.is_empty(), "{name}");
|
||||
for (got, sweeps) in unite_counting_sweeps(&[(&path, rule)]) {
|
||||
assert_eq!(sweeps, 1, "{name}");
|
||||
assert_eq!(got, want, "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inputs_that_cross_or_are_several_are_swept_again_together() {
|
||||
// Overlapping circles in one compound path: the first sweep splits them where they cross.
|
||||
let mut bp = BezPath::new();
|
||||
for i in 0..12 {
|
||||
bp.extend(kurbo::Circle::new(((i % 4) as f64 * 15.0, (i / 4) as f64 * 15.0), 10.0).path_elements(0.1));
|
||||
}
|
||||
let overlapping = PathData::from_bezpath(&bp);
|
||||
let want = unite_swept_twice(&[(&overlapping, FillRule::NonZero)], DEFAULT_PRECISION);
|
||||
for (got, sweeps) in unite_counting_sweeps(&[(&overlapping, FillRule::NonZero)]) {
|
||||
assert_eq!(sweeps, 2);
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
// Two overlapping squares in one compound path, each with an anchor where the other crosses it: the first sweep
|
||||
// splits no segment and starts every piece at an anchor, but drops the pieces inside the other square.
|
||||
let mut bp = BezPath::new();
|
||||
for pts in [
|
||||
[(0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (10.0, 10.0), (5.0, 10.0), (0.0, 10.0)],
|
||||
[(5.0, 5.0), (10.0, 5.0), (15.0, 5.0), (15.0, 15.0), (5.0, 15.0), (5.0, 10.0)],
|
||||
] {
|
||||
bp.move_to(pts[0]);
|
||||
for p in &pts[1..] {
|
||||
bp.line_to(*p);
|
||||
}
|
||||
bp.close_path();
|
||||
}
|
||||
let anchored = PathData::from_bezpath(&bp);
|
||||
let want = unite_swept_twice(&[(&anchored, FillRule::NonZero)], DEFAULT_PRECISION);
|
||||
for (got, sweeps) in unite_counting_sweeps(&[(&anchored, FillRule::NonZero)]) {
|
||||
assert_eq!(sweeps, 2);
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
// Two plain overlapping squares in one compound path: as many segments come out as go in, because the sweep drops
|
||||
// the two pieces inside the other square and adds two where the outlines cross.
|
||||
let mut bp = BezPath::new();
|
||||
for (x, y) in [(0.0, 0.0), (5.0, 5.0)] {
|
||||
bp.move_to((x, y));
|
||||
bp.line_to((x + 10.0, y));
|
||||
bp.line_to((x + 10.0, y + 10.0));
|
||||
bp.line_to((x, y + 10.0));
|
||||
bp.close_path();
|
||||
}
|
||||
let balanced = PathData::from_bezpath(&bp);
|
||||
assert_eq!(
|
||||
normalize_bez(&fill_bezpath(&balanced), FillRule::NonZero).unwrap().contours().map(|c| c.path.segments().count()).sum::<usize>(),
|
||||
8
|
||||
);
|
||||
let want = unite_swept_twice(&[(&balanced, FillRule::NonZero)], DEFAULT_PRECISION);
|
||||
for (got, sweeps) in unite_counting_sweeps(&[(&balanced, FillRule::NonZero)]) {
|
||||
assert_eq!(sweeps, 2);
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
// Two inputs that do not cross each other or themselves are still swept together.
|
||||
let (a, b) = (square(0.0, 0.0, 10.0), square(20.0, 0.0, 10.0));
|
||||
let both = [(&a, FillRule::NonZero), (&b, FillRule::NonZero)];
|
||||
let want = unite_swept_twice(&both, DEFAULT_PRECISION);
|
||||
for (got, sweeps) in unite_counting_sweeps(&both) {
|
||||
assert_eq!(sweeps, 3);
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(panic = "unwind")]
|
||||
fn a_panic_in_the_contour_pass_becomes_an_error() {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{BezPath, Document, FillRule, Node, NodeKind, Rect, SymbolBoxes, brush_fx, fx};
|
||||
|
||||
/// Bounds of everything `n` paints, as the renderer culls it: its members, strokes, brush art and
|
||||
@@ -13,12 +15,39 @@ pub fn painted_bounds(doc: &Document, n: &Node) -> Option<Rect> {
|
||||
/// out the transparent source art of a Shaper group as [`Node::visual_bounds_with`] does. Symbol
|
||||
/// instances are measured by their art in `symbols`.
|
||||
pub fn effect_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
effect_bounds_with(symbols, n, None, None)
|
||||
}
|
||||
|
||||
impl crate::Renderer {
|
||||
/// [`effect_bounds`] of `n`, its 3D art measured as this renderer draws and keeps it
|
||||
/// (`Renderer::bounds_of`): a pass that measures the same objects again, over each
|
||||
/// artboard of an export, builds each solid once. What it keeps is measured with the symbol
|
||||
/// boxes it was first measured with, and found by the objects' addresses: give each document,
|
||||
/// and each kind of `symbols`, its own renderer.
|
||||
pub fn effect_bounds(&mut self, symbols: &SymbolBoxes, n: &Arc<Node>) -> Option<Rect> {
|
||||
effect_bounds_with(symbols, n, Some(n), Some(self))
|
||||
}
|
||||
}
|
||||
|
||||
/// [`effect_bounds`] of `n` (`held`: `n` as held in its document, which `renderer` keeps what it
|
||||
/// measures by). Through `renderer`, an object is measured once for every artboard it measures:
|
||||
/// those it measures as [`effect_bounds`] does (`Renderer::bounds_of`), those whose
|
||||
/// brushed strokes it leaves room for afresh.
|
||||
pub(crate) fn effect_bounds_with(
|
||||
symbols: &SymbolBoxes,
|
||||
n: &Node,
|
||||
held: Option<&Arc<Node>>,
|
||||
mut renderer: Option<&mut crate::Renderer>,
|
||||
) -> Option<Rect> {
|
||||
match &n.kind {
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_object_fx(n) => children
|
||||
.iter()
|
||||
.skip(usize::from(n.extras().shaper.is_some()))
|
||||
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, effect_bounds(symbols, c))),
|
||||
_ => fx::cull_bounds(symbols, n),
|
||||
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, effect_bounds_with(symbols, c, Some(c), renderer.as_deref_mut()))),
|
||||
_ => match (renderer, held) {
|
||||
(Some(r), Some(a)) if brush_fx::pad(symbols, a) == 0.0 => r.bounds_of(symbols, a),
|
||||
_ => fx::cull_bounds(symbols, n),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,13 @@ impl BrushCache {
|
||||
self.library(doc, stamp);
|
||||
self.key
|
||||
}
|
||||
|
||||
/// [`Self::library_key`] checked now, between frames: `doc` may be the document of the last
|
||||
/// frame, edited in place.
|
||||
pub(crate) fn library_key_now(&mut self, doc: &Document, stamp: u64) -> u64 {
|
||||
self.checked = (u64::MAX, 0);
|
||||
self.library_key(doc, stamp)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cull bounds that allow for brush art reaching past the stroke (scatter, wide nibs), symbol
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Document, Node, NodeKind, SymbolBoxes};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, SymbolBoxes};
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
|
||||
/// What may draw differently in a document than in its version before an edit ([`changed`]).
|
||||
@@ -82,10 +82,44 @@ fn add_placed(symbols: &SymbolBoxes, n: &Node, files: &HashSet<String>, area: &m
|
||||
/// art also draws in the copies round it). Objects that an edit doesn't touch
|
||||
/// keep their identity, so this costs what changed, not the size of the document.
|
||||
pub fn changed(old: &Document, new: &Document) -> Changed {
|
||||
changed_with(old, new, None, &crate::RenderOptions::default())
|
||||
}
|
||||
|
||||
impl crate::Renderer {
|
||||
/// [`changed`], measured through this renderer (`Renderer::bounds_of`): what it drew of `old`
|
||||
/// it measured to draw it, and what it measures of `new`, a 3D effect's art included, it holds
|
||||
/// to draw that with `opts`; the objects `opts` leaves out (hidden, templates and guides the
|
||||
/// view hides, the instance a symbol is edited from) draw nothing. The view that drew `old`
|
||||
/// with this renderer and `opts` asks this. With Overprint Preview or Separations Preview the
|
||||
/// renderer draws copies of the objects (`proof::prepare`), so they are measured afresh and
|
||||
/// nothing is held.
|
||||
pub fn changed(&mut self, old: &Document, new: &Document, opts: &crate::RenderOptions) -> Changed {
|
||||
let renderer = (!crate::proof::overprints(opts)).then_some(self);
|
||||
changed_with(old, new, renderer, opts)
|
||||
}
|
||||
|
||||
/// Let go of the art made to measure objects ([`crate::Renderer::changed`]) and held for this
|
||||
/// renderer to draw them: another one draws the frame.
|
||||
pub fn let_go_of_measured(&mut self) {
|
||||
self.measured.clear();
|
||||
self.measured_bytes = 0;
|
||||
self.pass_bounds.clear();
|
||||
self.three_d_bounds.retain(|_, e| e.0.strong_count() > 0);
|
||||
self.three_d_kept = self.three_d_bounds.len();
|
||||
}
|
||||
|
||||
/// The bytes of the art made to measure objects that this renderer holds to draw them
|
||||
/// ([`crate::Renderer::changed`]).
|
||||
pub fn measured_bytes(&self) -> usize {
|
||||
self.measured_bytes
|
||||
}
|
||||
}
|
||||
|
||||
fn changed_with(old: &Document, new: &Document, renderer: Option<&mut crate::Renderer>, opts: &crate::RenderOptions) -> Changed {
|
||||
if new.pattern_edit.is_some() || !old.same_outside_layers(new) {
|
||||
return Changed::Everything;
|
||||
}
|
||||
let mut diff = Diff { old: old.symbol_boxes(), new: new.symbol_boxes(), area: None };
|
||||
let mut diff = Diff { old: old.symbol_boxes(), new: new.symbol_boxes(), area: None, renderer, opts, sources: (None, None) };
|
||||
diff.children(&old.layers, &new.layers);
|
||||
diff.area.map_or(Changed::Nothing, Changed::Area)
|
||||
}
|
||||
@@ -102,14 +136,20 @@ pub fn view_pixels(area: Rect, view: Affine, w: u32, h: u32) -> Option<[u32; 4]>
|
||||
(x1 > x0 && y1 > y0).then_some([x0 as u32, y0 as u32, x1 as u32, y1 as u32])
|
||||
}
|
||||
|
||||
/// [`changed`] in progress: the symbols' boxes of both versions, and the area so far.
|
||||
struct Diff<'a> {
|
||||
/// [`changed`] in progress: the symbols' boxes of both versions, the area so far, the renderer
|
||||
/// that measures, if any (else each object is measured afresh), the options of the frames it
|
||||
/// measures for, and the hidden source image, in each version, of the Image Trace object whose
|
||||
/// members it compares.
|
||||
struct Diff<'a, 'r> {
|
||||
old: SymbolBoxes<'a>,
|
||||
new: SymbolBoxes<'a>,
|
||||
area: Option<Rect>,
|
||||
renderer: Option<&'r mut crate::Renderer>,
|
||||
opts: &'r crate::RenderOptions,
|
||||
sources: (Option<NodeId>, Option<NodeId>),
|
||||
}
|
||||
|
||||
impl Diff<'_> {
|
||||
impl Diff<'_, '_> {
|
||||
/// Members in the same order compare one by one. Members added, removed or restacked count
|
||||
/// before and after, except those that kept their place unchanged.
|
||||
fn children(&mut self, old: &[Arc<Node>], new: &[Arc<Node>]) {
|
||||
@@ -123,8 +163,8 @@ impl Diff<'_> {
|
||||
match (old.get(i), new.get(i)) {
|
||||
(Some(a), Some(b)) if Arc::ptr_eq(a, b) => {}
|
||||
(a, b) => {
|
||||
self.add_old(a.map(|a| &**a));
|
||||
self.add_new(b.map(|b| &**b));
|
||||
self.add_old(a);
|
||||
self.add_new(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,20 +175,25 @@ impl Diff<'_> {
|
||||
/// only had anchors moved changed around them ([`moved_anchors`]). Any other object that
|
||||
/// changed counts whole, before and after.
|
||||
fn node(&mut self, old: &Arc<Node>, new: &Arc<Node>) {
|
||||
if Arc::ptr_eq(old, new) {
|
||||
if Arc::ptr_eq(old, new) || self.unseen(Side::Old, old) && self.unseen(Side::New, new) {
|
||||
return;
|
||||
}
|
||||
if let (Some(a), Some(b)) = (members(old), members(new))
|
||||
&& same_but_members(old, new)
|
||||
{
|
||||
// An Image Trace object draws its hidden source image, its first member, in some views.
|
||||
// Each version's own: an edit can put another member first (undoing Send to Back).
|
||||
let first = |n: &Node, m: &[Arc<Node>]| n.extras().trace.is_some().then(|| m.first().map(|s| s.id)).flatten();
|
||||
let outer = std::mem::replace(&mut self.sources, (first(old, a), first(new, b)));
|
||||
self.children(a, b);
|
||||
self.sources = outer;
|
||||
} else if let Some((area, moved, anchors)) = moved_anchors(old, new) {
|
||||
if moved <= FEW_MOVED && anchors >= MANY_ANCHORS {
|
||||
self.add(area);
|
||||
} else {
|
||||
// The object's box before and after holds every pixel that changed too, and so
|
||||
// does where the two overlap.
|
||||
let whole = vectorcraft_geom::union_opt(crate::brush_fx::cull_bounds(&self.old, old), crate::brush_fx::cull_bounds(&self.new, new));
|
||||
let whole = vectorcraft_geom::union_opt(self.bounds(Side::Old, old), self.bounds(Side::New, new));
|
||||
self.add(area.zip(whole).map(|(a, w)| a.intersect(w)));
|
||||
}
|
||||
} else {
|
||||
@@ -157,12 +202,42 @@ impl Diff<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn add_old(&mut self, n: Option<&Node>) {
|
||||
self.add(n.and_then(|n| crate::brush_fx::cull_bounds(&self.old, n)));
|
||||
/// Does `n` of the `side` version draw nothing, as the renderer leaves it out
|
||||
/// (`node_checks::left_out`)? The source image of an Image Trace object draws in some views.
|
||||
/// The members of a group or layer left out are never compared ([`Self::node`]).
|
||||
fn unseen(&self, side: Side, n: &Node) -> bool {
|
||||
let (doc, source) = match side {
|
||||
Side::Old => (self.old.document(), self.sources.0),
|
||||
Side::New => (self.new.document(), self.sources.1),
|
||||
};
|
||||
source != Some(n.id) && crate::node_checks::left_out(self.opts, doc, n)
|
||||
}
|
||||
|
||||
fn add_new(&mut self, n: Option<&Node>) {
|
||||
self.add(n.and_then(|n| crate::brush_fx::cull_bounds(&self.new, n)));
|
||||
/// The cull bounds of `n` of the `side` version, through the renderer when there is one;
|
||||
/// `None` when it draws nothing ([`Self::unseen`]), as nothing is measured to draw it.
|
||||
fn bounds(&mut self, side: Side, n: &Arc<Node>) -> Option<Rect> {
|
||||
if self.unseen(side, n) {
|
||||
return None;
|
||||
}
|
||||
let Diff { old, new, renderer, .. } = self;
|
||||
let symbols = match side {
|
||||
Side::Old => old,
|
||||
Side::New => new,
|
||||
};
|
||||
match renderer {
|
||||
Some(r) => r.bounds_of(symbols, n),
|
||||
None => crate::brush_fx::cull_bounds(symbols, n),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_old(&mut self, n: Option<&Arc<Node>>) {
|
||||
let b = n.and_then(|n| self.bounds(Side::Old, n));
|
||||
self.add(b);
|
||||
}
|
||||
|
||||
fn add_new(&mut self, n: Option<&Arc<Node>>) {
|
||||
let b = n.and_then(|n| self.bounds(Side::New, n));
|
||||
self.add(b);
|
||||
}
|
||||
|
||||
fn add(&mut self, r: Option<Rect>) {
|
||||
@@ -170,6 +245,13 @@ impl Diff<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which version of the document [`Diff`] measures an object of.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Side {
|
||||
Old,
|
||||
New,
|
||||
}
|
||||
|
||||
/// The members of a group or layer whose members draw each on their own: no clipping mask, and no
|
||||
/// effects of its own.
|
||||
fn members(n: &Node) -> Option<&[Arc<Node>]> {
|
||||
|
||||
@@ -205,18 +205,32 @@ pub fn art_bounds(doc: &Document) -> Option<Rect> {
|
||||
/// object of a layer or sublayer whose drawn bounds overlap `rect`, with all of its bounds; a group
|
||||
/// or a clipping layer counts as one object. `None` when no object overlaps `rect`.
|
||||
pub fn art_bounds_on(doc: &Document, rect: Rect) -> Option<Rect> {
|
||||
art_bounds_on_with(doc, rect, None)
|
||||
}
|
||||
|
||||
impl crate::Renderer {
|
||||
/// [`art_bounds_on`], its 3D art measured as this renderer draws and keeps it
|
||||
/// (`Renderer::bounds_of`): a pass over each artboard builds each solid once. What it
|
||||
/// keeps is measured with the document's symbol boxes as it first measured them, and found by
|
||||
/// the objects' addresses: give each document its own renderer.
|
||||
pub fn art_bounds_on(&mut self, doc: &Document, rect: Rect) -> Option<Rect> {
|
||||
art_bounds_on_with(doc, rect, Some(self))
|
||||
}
|
||||
}
|
||||
|
||||
fn art_bounds_on_with(doc: &Document, rect: Rect, mut renderer: Option<&mut crate::Renderer>) -> Option<Rect> {
|
||||
// Art flush with an edge from outside can overlap it by a rounding error (#1295).
|
||||
let rect = rect.inflate(-1e-6, -1e-6);
|
||||
fn on(symbols: &SymbolBoxes, n: &Node, rect: Rect) -> Option<Rect> {
|
||||
fn on(symbols: &SymbolBoxes, n: &Arc<Node>, rect: Rect, mut renderer: Option<&mut crate::Renderer>) -> Option<Rect> {
|
||||
match &n.kind {
|
||||
NodeKind::Layer { template: false, clip: false, children, .. } if n.visible => {
|
||||
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, on(symbols, c, rect)))
|
||||
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, on(symbols, c, rect, renderer.as_deref_mut())))
|
||||
}
|
||||
_ => drawn_bounds(symbols, n).filter(|b| b.x0 < rect.x1 && b.x1 > rect.x0 && b.y0 < rect.y1 && b.y1 > rect.y0),
|
||||
_ => drawn_bounds_with(symbols, n, Some(n), renderer).filter(|b| b.x0 < rect.x1 && b.x1 > rect.x0 && b.y0 < rect.y1 && b.y1 > rect.y0),
|
||||
}
|
||||
}
|
||||
let symbols = doc.symbol_boxes();
|
||||
doc.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, on(&symbols, l, rect)))
|
||||
doc.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, on(&symbols, l, rect, renderer.as_deref_mut())))
|
||||
}
|
||||
|
||||
/// Bounds of what an export draws of object `n` ([`art_bounds`]): `None` when `n` is hidden, a
|
||||
@@ -224,6 +238,12 @@ pub fn art_bounds_on(doc: &Document, rect: Rect) -> Option<Rect> {
|
||||
/// except in a group or layer with effects of its own that keep all its members (`without_undrawn`
|
||||
/// lists them). Symbol instances are measured by their art in `symbols`.
|
||||
pub fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
drawn_bounds_with(symbols, n, None, None)
|
||||
}
|
||||
|
||||
/// [`drawn_bounds`] of `n` (`held`: `n` as held in its document, which `renderer` keeps what it
|
||||
/// measures by).
|
||||
fn drawn_bounds_with(symbols: &SymbolBoxes, n: &Node, held: Option<&Arc<Node>>, mut renderer: Option<&mut crate::Renderer>) -> Option<Rect> {
|
||||
if !n.visible {
|
||||
return None;
|
||||
}
|
||||
@@ -232,8 +252,23 @@ pub fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_object_fx(n) => children
|
||||
.iter()
|
||||
.skip(usize::from(n.extras().shaper.is_some()))
|
||||
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, drawn_bounds(symbols, c))),
|
||||
_ => crate::effect_bounds(symbols, without_undrawn(n).as_ref().unwrap_or(n)),
|
||||
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, drawn_bounds_with(symbols, c, Some(c), renderer.as_deref_mut()))),
|
||||
_ => match (without_undrawn(n), held, renderer) {
|
||||
// A new object, measured once a pass by the one it was made from.
|
||||
(Some(kept), Some(a), Some(r)) => {
|
||||
let key = Arc::as_ptr(a) as usize;
|
||||
if let Some((node, b)) = r.pass_bounds.get(&key)
|
||||
&& Arc::ptr_eq(node, a)
|
||||
{
|
||||
return *b;
|
||||
}
|
||||
let b = crate::bounds::effect_bounds_with(symbols, &kept, None, Some(r));
|
||||
r.pass_bounds.insert(key, (a.clone(), b));
|
||||
b
|
||||
}
|
||||
(Some(kept), _, renderer) => crate::bounds::effect_bounds_with(symbols, &kept, None, renderer),
|
||||
(None, held, renderer) => crate::bounds::effect_bounds_with(symbols, n, held, renderer),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -174,6 +174,12 @@ pub struct FrameStats {
|
||||
/// ([`Renderer::draw_type_edges`]). Type drawn from the pixels an earlier frame kept is not
|
||||
/// counted.
|
||||
pub type_edges: usize,
|
||||
/// The art of 3D effects (Revolve, Extrude & Bevel, Inflate, Rotate) this renderer made for the
|
||||
/// objects it draws or measures itself (`Renderer::bounds_of`) since its last frame
|
||||
/// began: none it found kept, and none made by measures without a renderer (the free bounds
|
||||
/// functions), which this renderer doesn't see. A view that measured what changed through the
|
||||
/// renderer ([`crate::Renderer::changed`]) made the art then, and draws it with none made.
|
||||
pub three_d_built: usize,
|
||||
pub micros: u64,
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ mod renderer;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use bounds::visual_bounds;
|
||||
pub(crate) use bounds::{cull_bounds, drawn_outline, drawn_outlines, painted_reach, path_cull_bounds};
|
||||
pub(crate) use bounds::{cull_bounds, drawn_outline, drawn_outlines, painted_reach, path_cull_bounds, three_d_art_bounds, with_own_raster_reach};
|
||||
#[cfg(test)]
|
||||
pub(crate) use entries::Drawn;
|
||||
pub(crate) use entries::{DocInputs, FxGeom, Inputs, Reads, ShadowEntry};
|
||||
|
||||
@@ -57,12 +57,6 @@ pub(super) fn item_effects(item: &AppearanceItem) -> &[Effect] {
|
||||
/// Visual bounds including geometry effects, stroke outsets and shadows/glows (symbol instances
|
||||
/// measured by their art from `symbols`).
|
||||
pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
if let Some(art) = effects::revolve_art(n).or_else(|| effects::solid_art(n)) {
|
||||
return cull_bounds(symbols, &art).map(|b| {
|
||||
let o = outset(symbols, &n.appearance.effects, b);
|
||||
b.inflate(o, o)
|
||||
});
|
||||
}
|
||||
if let Some(art) = painted_art(symbols, n) {
|
||||
return art.visual_bounds_with(symbols).map(|r| with_raster_reach(symbols, n, r));
|
||||
}
|
||||
@@ -109,6 +103,10 @@ fn paints(item: &AppearanceItem) -> bool {
|
||||
/// [`Node::visual_bounds_with`] measures them, without the reach of `n`'s own raster effects
|
||||
/// (symbol instances measured by their art from `symbols`): what Rasterize makes its image over.
|
||||
pub(crate) fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
// A 3D effect's art holds its fills' and strokes' raster effects, measured with it.
|
||||
if let Some(b) = three_d_bounds(symbols, n) {
|
||||
return b;
|
||||
}
|
||||
let b = art_bounds(symbols, n)?;
|
||||
let items = n.appearance.items.iter().filter(|i| paints(i) && !effects::raster_effects(item_effects(i)).is_empty());
|
||||
Some(items.map(|i| spread(symbols, item_effects(i), item_bounds(symbols.document(), n, i).unwrap_or(b))).fold(b, |acc, r| acc.union(r)))
|
||||
@@ -168,13 +166,6 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
members.iter().filter(|c| c.visible).fold(None, |acc, c| vectorcraft_geom::union_opt(acc, painted_reach(symbols, c)))
|
||||
};
|
||||
match &n.kind {
|
||||
// A group's or layer's 3D effect: the art of its members' solids.
|
||||
NodeKind::Layer { .. } | NodeKind::Group { clip: false, .. }
|
||||
if effects::has_solid(n)
|
||||
&& let Some(art) = effects::solid_art(n) =>
|
||||
{
|
||||
drawn_bounds(symbols, &art)
|
||||
}
|
||||
_ if effects::has_container_appearance(n) => {
|
||||
let art = effects::evaluate_container(Some(symbols.document()), n);
|
||||
let art = art.as_ref().unwrap_or(n);
|
||||
@@ -182,10 +173,6 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
}
|
||||
NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. } => union(n),
|
||||
_ if has_fx(n) => {
|
||||
// A 3D effect's art.
|
||||
if let Some(art) = effects::revolve_art(n).or_else(|| effects::solid_art(n)) {
|
||||
return drawn_bounds(symbols, &art);
|
||||
}
|
||||
if let Some(art) = painted_art(symbols, n) {
|
||||
return art.visual_bounds_with(symbols);
|
||||
}
|
||||
@@ -221,7 +208,7 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
Some(r) => r.visual_bounds_with(symbols),
|
||||
None => None,
|
||||
}
|
||||
.or_else(|| n.visual_bounds_with(symbols))?;
|
||||
.or_else(|| own_box(symbols, n))?;
|
||||
let a = match &reshaped {
|
||||
Some(r) => reshaped_art_effects_reach(symbols, n, r),
|
||||
None => art_effects_reach(symbols, n),
|
||||
@@ -385,6 +372,13 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio
|
||||
let reach = effects::addons::halftone::reach(n);
|
||||
inner(&effects::addons::halftone::split(n).0).map(|b| b.inflate(reach, reach))
|
||||
}
|
||||
// A 3D effect's art, as the renderer draws it, and the raster effects' reach: the solid of
|
||||
// type or of a group's members reaches past the letters or members the arms below measure.
|
||||
_ if (effects::has_revolve(n) || effects::has_solid(n))
|
||||
&& let Some(b) = three_d_bounds(symbols, n) =>
|
||||
{
|
||||
b.map(|b| with_own_raster_reach(symbols, n, b))
|
||||
}
|
||||
// The evaluated art (its fills, strokes and geometry effects) and the raster effects' reach.
|
||||
_ if effects::has_container_appearance(n) => {
|
||||
let art = effects::evaluate_container(Some(symbols.document()), n);
|
||||
@@ -394,8 +388,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio
|
||||
} else {
|
||||
art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c)))
|
||||
}?;
|
||||
let o = outset(symbols, &n.appearance.effects, b);
|
||||
Some(b.inflate(o, o))
|
||||
Some(with_own_raster_reach(symbols, n, b))
|
||||
}
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
|
||||
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c)))
|
||||
@@ -418,15 +411,14 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio
|
||||
Some(r) => r.visual_bounds_with(symbols),
|
||||
None => None,
|
||||
}
|
||||
.or_else(|| n.visual_bounds_with(symbols))?;
|
||||
.or_else(|| own_box(symbols, n))?;
|
||||
let a = match &reshaped {
|
||||
Some(r) if after_reshape => reshaped_art_effects_reach(symbols, n, r),
|
||||
_ => art_effects_reach(symbols, n),
|
||||
};
|
||||
let a = a.max(live_content_reach(symbols, n));
|
||||
let b = b.inflate(a, a);
|
||||
let o = outset(symbols, &n.appearance.effects, b);
|
||||
Some(b.inflate(o, o))
|
||||
Some(with_own_raster_reach(symbols, n, b))
|
||||
}
|
||||
NodeKind::SymbolInstance { .. } => {
|
||||
let b = n.visual_bounds_with(symbols)?;
|
||||
@@ -445,6 +437,61 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio
|
||||
}
|
||||
}
|
||||
|
||||
/// The bounds of the art a 3D effect of `n` draws (Revolve, Extrude & Bevel, Inflate, Rotate),
|
||||
/// built now, without `n`'s own raster effects ([`three_d_art_bounds`]): `None` when it draws no
|
||||
/// such art, `Some(None)` when that art paints nothing. The renderer measures the art it keeps
|
||||
/// instead ([`crate::Renderer::bounds_of`]).
|
||||
fn three_d_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Option<Rect>> {
|
||||
if !effects::has_revolve(n) && !effects::has_solid(n) {
|
||||
return None;
|
||||
}
|
||||
let art = effects::revolve_art(n).or_else(|| effects::solid_art(n))?;
|
||||
Some(three_d_art_bounds(symbols, &art))
|
||||
}
|
||||
|
||||
/// The bounds of `art`, the art a 3D effect draws for an object, as the renderer draws it: with
|
||||
/// its geometry effects, without the raster effects the object paints over it (which
|
||||
/// [`with_own_raster_reach`] adds), and each object in it with the raster effects of its own fills
|
||||
/// and strokes and the art of its brushed strokes (a stroke without a 3D effect of its own keeps
|
||||
/// its brush): the art itself, which Rasterize and exports take, not the room culling leaves.
|
||||
pub(crate) fn three_d_art_bounds(symbols: &SymbolBoxes, art: &Node) -> Option<Rect> {
|
||||
fn pieces(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
match &n.kind {
|
||||
NodeKind::Group { children, clip: false } if !has_object_fx(n) => {
|
||||
children.iter().filter(|c| c.visible).fold(None, |acc, c| vectorcraft_geom::union_opt(acc, pieces(symbols, c)))
|
||||
}
|
||||
// The art its brushed strokes draw, not the room culling leaves for it.
|
||||
_ => {
|
||||
let brushed = vectorcraft_brush::has_brush(n)
|
||||
.then(|| vectorcraft_brush::expand(symbols.document(), n))
|
||||
.flatten()
|
||||
.and_then(|art| art.visual_bounds_with(symbols));
|
||||
vectorcraft_geom::union_opt(cull_bounds(symbols, n), brushed)
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut art = art.clone();
|
||||
art.appearance.effects.retain(|e| effects::is_geometry(&e.id));
|
||||
let art = effects::evaluate_container(Some(symbols.document()), &art).unwrap_or(art);
|
||||
pieces(symbols, &art)
|
||||
}
|
||||
|
||||
/// `b`, the bounds of what `n` draws, grown by how far `n`'s own raster effects paint past it.
|
||||
pub(crate) fn with_own_raster_reach(symbols: &SymbolBoxes, n: &Node, b: Rect) -> Rect {
|
||||
let o = outset(symbols, &n.appearance.effects, b);
|
||||
b.inflate(o, o)
|
||||
}
|
||||
|
||||
/// `n`'s own visual box, type's grown to hold its glyphs ([`glyphs_box`]): what it draws when its
|
||||
/// object effects leave it as it is (Revolve builds no art from type).
|
||||
fn own_box(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
let b = n.visual_bounds_with(symbols)?;
|
||||
Some(match &n.kind {
|
||||
NodeKind::Text(t) => vectorcraft_geom::union_opt(Some(b), glyphs_box(t, n.appearance.outset())).unwrap_or(b),
|
||||
_ => b,
|
||||
})
|
||||
}
|
||||
|
||||
/// The box of type `t`'s glyphs as laid out (its layout bounds, else laid out now), grown by its
|
||||
/// characters' strokes, miter spikes included, and by `outset`: on a path they stand above it and
|
||||
/// run past its ends, in an area they can hang past its frame, where its own box
|
||||
|
||||
@@ -6,8 +6,8 @@ use super::raster::{
|
||||
shade_filter, shadow_filter, silhouette,
|
||||
};
|
||||
use super::{
|
||||
Affine, Arc, BezPath, FillRule, Frame, GeomContext, Ink, Node, NodeKind, RasterFx, Rect, RenderContext, Renderer, Shape, Vec2, blend_mode,
|
||||
effects, fill_rule, peniko,
|
||||
Affine, Arc, BezPath, FillRule, Frame, GeomContext, Ink, Node, NodeKind, RasterFx, Rect, RenderContext, Renderer, Shape, SymbolBoxes, Vec2,
|
||||
blend_mode, effects, fill_rule, peniko,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -437,7 +437,13 @@ impl Renderer {
|
||||
let region = if clipped { self.clip_of(&art) } else { None };
|
||||
// A symbol's art can change while the instance doesn't: no cached shadows.
|
||||
let cache = (cache && !matches!(a.kind, NodeKind::SymbolInstance { .. })).then_some(0);
|
||||
let bounds = rasterize_bounds(f.symbols, a, &rfx);
|
||||
// A 3D effect's art is in hand: Rasterize takes its bounds, measured once while it is
|
||||
// kept, without building it again.
|
||||
let rasterizes = rfx.iter().any(|x| matches!(x, RasterFx::Rasterize(_)));
|
||||
let bounds = match rasterizes.then(|| self.three_d_art_bounds(f.symbols, &art)).flatten() {
|
||||
Some(b) => b,
|
||||
None => rasterize_bounds(f.symbols, a, &rfx),
|
||||
};
|
||||
let reach = bounds.map_or(reach, |b| reach.union(b));
|
||||
let content = Content { node: a, owner: Some(a), cache, reach, outline: region.as_deref().map(|r| (&r.0, r.1)), bounds, kept: None };
|
||||
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.draw_fx_art(c, fr, a, &art));
|
||||
@@ -487,6 +493,91 @@ impl Renderer {
|
||||
{
|
||||
return art;
|
||||
}
|
||||
// Made to measure it ([`Self::measured_fx_art`]): taken out, and kept in `fx_arts` from now
|
||||
// on if made from what it reads now, else made again.
|
||||
let measured = self.measured.remove(&key).filter(|m| Arc::ptr_eq(&m.0, a)).map(|m| {
|
||||
self.measured_bytes = self.measured_bytes.saturating_sub(crate::lru::node_bytes(&m.1));
|
||||
(m.1, m.2)
|
||||
});
|
||||
let art = match measured {
|
||||
Some((art, was)) => {
|
||||
let now = match &found {
|
||||
Some(f) => Some(f.0),
|
||||
None => self.outside_key(doc, a),
|
||||
};
|
||||
if was == now { art } else { self.make_fx_art(doc, a) }
|
||||
}
|
||||
None => self.make_fx_art(doc, a),
|
||||
};
|
||||
if cache {
|
||||
let bytes = crate::lru::node_bytes(&art);
|
||||
self.fx_arts.insert(key, (a.clone(), art.clone(), found), bytes, self.stamp);
|
||||
}
|
||||
art
|
||||
}
|
||||
|
||||
/// The art [`Self::fx_art`] draws for `a`, to measure it: the art kept for it, else made now and
|
||||
/// held until the frame ends ([`Renderer::measured`]), for [`Self::fx_art`] to keep if `a` is
|
||||
/// drawn. What is measured but never drawn (culled, off the view) is let go, and isn't kept in
|
||||
/// place of what is drawn.
|
||||
pub(crate) fn measured_fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc<Node>) -> Arc<Node> {
|
||||
let key = Arc::as_ptr(a) as usize;
|
||||
if let Some((node, art, found)) = self.fx_arts.get(&key, self.stamp)
|
||||
&& Arc::ptr_eq(node, a)
|
||||
{
|
||||
let (art, was) = (art.clone(), found.as_ref().map(|f| f.0));
|
||||
if was.is_none_or(|was| self.live_found(doc, a).0 == was) {
|
||||
return art;
|
||||
}
|
||||
}
|
||||
let now = self.outside_key(doc, a);
|
||||
if let Some((node, art, was)) = self.measured.get(&key)
|
||||
&& Arc::ptr_eq(node, a)
|
||||
&& *was == now
|
||||
{
|
||||
return art.clone();
|
||||
}
|
||||
let art = self.make_fx_art(doc, a);
|
||||
// Art held for an object before (made from what it read then) gives way.
|
||||
if let Some(old) = self.measured.remove(&key) {
|
||||
self.measured_bytes = self.measured_bytes.saturating_sub(crate::lru::node_bytes(&old.1));
|
||||
}
|
||||
let bytes = crate::lru::node_bytes(&art);
|
||||
// Within a budget: past it, what is measured is made again to draw it.
|
||||
if self.measured_bytes + bytes <= self.measured_budget {
|
||||
self.measured_bytes += bytes;
|
||||
self.measured.insert(key, (a.clone(), art.clone(), now));
|
||||
}
|
||||
art
|
||||
}
|
||||
|
||||
/// The bounds of `art`, art [`Self::fx_art`] made for an object, when it is a 3D effect's art
|
||||
/// ([`super::three_d_art_bounds`]), measured once while it is kept; `None` for any other art.
|
||||
pub(crate) fn three_d_art_bounds(&mut self, symbols: &SymbolBoxes, art: &Arc<Node>) -> Option<Option<Rect>> {
|
||||
let key = Arc::as_ptr(art) as usize;
|
||||
// Raster effects in the art spread by the resolution; brushed strokes in it draw from the
|
||||
// brushes.
|
||||
let at = (super::raster::doc_raster_ppi(symbols.document()).to_bits(), self.brushes.library_key(symbols.document(), self.stamp));
|
||||
let (kept, measured) =
|
||||
self.three_d_bounds.get(&key).filter(|e| std::ptr::eq(e.0.as_ptr(), Arc::as_ptr(art))).map(|e| (true, e.1.filter(|m| m.0 == at)))?;
|
||||
if let (true, Some((_, b))) = (kept, measured) {
|
||||
return Some(b);
|
||||
}
|
||||
let b = super::three_d_art_bounds(symbols, art);
|
||||
self.three_d_bounds.insert(key, (Arc::downgrade(art), Some((at, b))));
|
||||
Some(b)
|
||||
}
|
||||
|
||||
/// The key of what `a`'s art reads from outside it ([`Self::live_found`]), `None` when it reads
|
||||
/// nothing.
|
||||
fn outside_key(&mut self, doc: &vectorcraft_doc::Document, a: &Arc<Node>) -> Option<u64> {
|
||||
(evaluates_live(a) || reads_outside(a)).then(|| self.live_found(doc, a).0)
|
||||
}
|
||||
|
||||
/// The art [`Self::fx_art`] draws for `a`, made now.
|
||||
fn make_fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc<Node>) -> Arc<Node> {
|
||||
// Whether the art is a 3D effect's ([`Self::three_d_art_bounds`]).
|
||||
let mut three_d = false;
|
||||
let symbol = match &a.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => {
|
||||
doc.symbols.iter().find(|s| s.name == *symbol).map(|s| crate::brush_fx::instance_art(&s.art, a))
|
||||
@@ -496,8 +587,12 @@ impl Renderer {
|
||||
let container = is_container(a);
|
||||
let art = match effects::crop_marks_art(a)
|
||||
.or_else(|| crate::addons::halftone::art(doc, a))
|
||||
.or_else(|| effects::revolve_art(a))
|
||||
.or_else(|| effects::solid_art(a))
|
||||
.or_else(|| {
|
||||
let art = effects::revolve_art(a).or_else(|| effects::solid_art(a));
|
||||
self.stats.three_d_built += usize::from(art.is_some());
|
||||
three_d |= art.is_some();
|
||||
art
|
||||
})
|
||||
.or_else(|| effects::reshape(Some(doc), a, symbol.as_ref()))
|
||||
{
|
||||
Some(r) => r,
|
||||
@@ -505,7 +600,16 @@ impl Renderer {
|
||||
None if matches!(a.kind, NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::Path { .. } | NodeKind::Compound { .. }) => (**a).clone(),
|
||||
None => effects::outline_art(Some(doc), a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])),
|
||||
};
|
||||
let art = if effects::has_revolve(a) || effects::has_solid(a) { effects::evaluate_container(Some(doc), &art).unwrap_or(art) } else { art };
|
||||
let has_three_d = effects::has_revolve(a) || effects::has_solid(a);
|
||||
let mut art = if has_three_d { effects::evaluate_container(Some(doc), &art).unwrap_or(art) } else { art };
|
||||
// A 3D effect on a fill or stroke that built nothing (Revolve after Roughen, a solid too
|
||||
// complex) draws the item as without it: left in the art, it would draw the art as an
|
||||
// object with effects again, and that art again, without end.
|
||||
if has_three_d && !three_d && !container {
|
||||
for item in &mut art.appearance.items {
|
||||
item.effects_mut().retain(|e| !effects::is_three_d(&e.id));
|
||||
}
|
||||
}
|
||||
// Vector Halftone samples the object with its colour adjustments: its dots keep their inks.
|
||||
let outer = if effects::addons::halftone::has_halftone(a) { None } else { effects::color_map(&a.appearance.effects) };
|
||||
let mut art = self.adjusted_art(doc, art, outer.as_ref());
|
||||
@@ -520,9 +624,18 @@ impl Renderer {
|
||||
// knockout setting says).
|
||||
art.knockout = if container { a.knockout } else { vectorcraft_doc::Knockout::Off };
|
||||
let art = Arc::new(art);
|
||||
if cache {
|
||||
let bytes = crate::lru::node_bytes(&art);
|
||||
self.fx_arts.insert(key, (a.clone(), art.clone(), found), bytes, self.stamp);
|
||||
if three_d {
|
||||
// A renderer that only measures (an export, Fit to Artwork Bounds) ends no frame: what
|
||||
// it no longer holds is let go here too.
|
||||
if self.three_d_bounds.len() >= (2 * self.three_d_kept).max(1024) {
|
||||
self.three_d_bounds.retain(|_, e| e.0.strong_count() > 0);
|
||||
self.three_d_kept = self.three_d_bounds.len();
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.three_d_sweeps += 1;
|
||||
}
|
||||
}
|
||||
self.three_d_bounds.insert(Arc::as_ptr(&art) as usize, (Arc::downgrade(&art), None));
|
||||
}
|
||||
art
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Effect, Node};
|
||||
@@ -543,6 +545,606 @@ fn visual_bounds_include_shadow() {
|
||||
assert!(b.x1 >= 10.0 + 7.0 + 7.5 - 1e-9);
|
||||
}
|
||||
|
||||
/// A shadow after a 3D effect reaches as far past the solid as it says, once: the solid's art keeps
|
||||
/// the raster effects that follow the 3D effect, and the object paints them over that art.
|
||||
#[test]
|
||||
fn a_shadow_after_a_3d_effect_reaches_past_the_solid_once() {
|
||||
let r = Rect::new(40.0, 40.0, 60.0, 60.0);
|
||||
let extrude = ("threeD.extrude", json!({"depth": 20.0, "position": "isometricTop"}));
|
||||
let shadow = ("stylize.dropShadow", json!({"x": 20, "y": 0, "blur": 0}));
|
||||
let measure = |d: &Document| super::cull_bounds(&d.symbol_boxes(), &d.layers[0].children().unwrap()[0]).unwrap();
|
||||
let solid = measure(&doc_with(r, Color::BLACK, vec![extrude.clone()]));
|
||||
let shadowed = measure(&doc_with(r, Color::BLACK, vec![extrude, shadow]));
|
||||
assert_eq!(shadowed.union(solid), shadowed);
|
||||
let grown = [solid.x0 - shadowed.x0, solid.y0 - shadowed.y0, shadowed.x1 - solid.x1, shadowed.y1 - solid.y1];
|
||||
assert!(grown.iter().all(|g| *g <= 20.0 + 1e-6) && grown.iter().any(|g| *g >= 20.0 - 1e-6), "{solid:?} {shadowed:?}");
|
||||
}
|
||||
|
||||
/// Revolve builds no art from type: the type draws its glyphs, and its bounds hold them.
|
||||
#[test]
|
||||
fn revolved_type_is_measured_by_the_glyphs_it_draws() {
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_doc::text::{CharStyle, TextObject};
|
||||
let _fonts = fonts_steady();
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let style = CharStyle { size: 30.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(vectorcraft_geom::Point::new(20.0, 60.0), "KW", style))));
|
||||
n.appearance.effects = vec![Effect { id: "threeD.revolve".into(), params: json!({}), visible: true }];
|
||||
let l = d.layers[0].id;
|
||||
let id = d.insert(Some(l), 0, n).unwrap();
|
||||
let (img, b) = with_fonts_unchanged(|| (render(&d), super::cull_bounds(&d.symbol_boxes(), d.node(id).unwrap()).unwrap()));
|
||||
let inked: Vec<(u32, u32)> = (0..100).flat_map(|y| (0..100).map(move |x| (x, y))).filter(|&(x, y)| img.pixel(x, y)[0] < 250).collect();
|
||||
assert!(!inked.is_empty());
|
||||
assert!(inked.iter().all(|&(x, y)| b.inflate(1.0, 1.0).contains(vectorcraft_geom::Point::new(f64::from(x), f64::from(y)))), "{b:?}");
|
||||
}
|
||||
|
||||
/// An export crops to the solid a 3D effect draws on type, not to the letters, and so does
|
||||
/// Object › Rasterize.
|
||||
#[test]
|
||||
fn an_export_and_rasterize_hold_the_solid_of_3d_type() {
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_doc::text::{CharStyle, TextObject};
|
||||
let _fonts = fonts_steady();
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let style = CharStyle { size: 30.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(vectorcraft_geom::Point::new(20.0, 60.0), "K", style))));
|
||||
let letters = with_fonts_unchanged(|| crate::encode::art_bounds(&doc_of(&mut d.clone(), n.clone())).unwrap());
|
||||
n.appearance.effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 60.0, "position": "isometricTop"}), visible: true }];
|
||||
let solid_doc = doc_of(&mut d, n.clone());
|
||||
let (solid, rasterized) = with_fonts_unchanged(|| {
|
||||
let n = &solid_doc.layers[0].children().unwrap()[0];
|
||||
(crate::encode::art_bounds(&solid_doc).unwrap(), crate::drawn_bounds(&solid_doc, n).unwrap())
|
||||
});
|
||||
assert!(solid.width() > letters.width() + 15.0, "{letters:?} {solid:?}");
|
||||
assert!(rasterized.width() > letters.width() + 15.0, "{letters:?} {rasterized:?}");
|
||||
}
|
||||
|
||||
fn doc_of(d: &mut Document, n: Node) -> Document {
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
d.clone()
|
||||
}
|
||||
|
||||
/// A shadow on a fill after its own 3D effect is drawn on the fill's solid and reaches past it
|
||||
/// once: Object › Rasterize takes the solid and the shadow, no more.
|
||||
#[test]
|
||||
fn a_fills_shadow_after_its_3d_effect_reaches_past_its_solid_once() {
|
||||
let mut d = doc_with(Rect::new(40.0, 40.0, 60.0, 60.0), Color::BLACK, vec![]);
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
let fill = |shadow: bool| {
|
||||
let mut fx = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 20.0, "position": "isometricTop"}), visible: true }];
|
||||
if shadow {
|
||||
fx.push(Effect { id: "stylize.dropShadow".into(), params: json!({"x": 20, "y": 0, "blur": 0}), visible: true });
|
||||
}
|
||||
fx
|
||||
};
|
||||
d.node_mut(id).unwrap().appearance.fill_mut().unwrap().effects = fill(false);
|
||||
let solid = crate::drawn_bounds(&d, d.node(id).unwrap()).unwrap();
|
||||
d.node_mut(id).unwrap().appearance.fill_mut().unwrap().effects = fill(true);
|
||||
let shadowed = crate::drawn_bounds(&d, d.node(id).unwrap()).unwrap();
|
||||
let grown = [solid.x0 - shadowed.x0, solid.y0 - shadowed.y0, shadowed.x1 - solid.x1, shadowed.y1 - solid.y1];
|
||||
assert!(grown.iter().all(|g| *g <= 20.0 + 1e-6) && grown.iter().any(|g| *g >= 20.0 - 1e-6), "{solid:?} {shadowed:?}");
|
||||
}
|
||||
|
||||
/// A fill's own 3D effect leaves the brushed stroke drawn with its brush: Object › Rasterize, exports
|
||||
/// and culling take the brush's art in, and Rasterize and exports no more than the solid and that
|
||||
/// art.
|
||||
#[test]
|
||||
fn a_brushed_stroke_beside_a_fills_3d_effect_is_measured_with_its_brush() {
|
||||
let mut d = doc_with(Rect::new(40.0, 40.0, 60.0, 60.0), Color::BLACK, vec![]);
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
{
|
||||
let n = d.node_mut(id).unwrap();
|
||||
n.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 1.0)));
|
||||
if let Some(vectorcraft_doc::AppearanceItem::Stroke(s)) = n.appearance.items.last_mut() {
|
||||
s.brush = Some("10 pt. Oval".into());
|
||||
}
|
||||
n.appearance.fill_mut().unwrap().effects =
|
||||
vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 20.0, "position": "isometricTop"}), visible: true }];
|
||||
}
|
||||
let n = d.node(id).unwrap();
|
||||
let brush = vectorcraft_brush::expand(&d, n).and_then(|art| art.visual_bounds()).unwrap();
|
||||
let symbols = d.symbol_boxes();
|
||||
let rasterized = crate::drawn_bounds(&d, n).unwrap();
|
||||
let exported = crate::effect_bounds(&symbols, n).unwrap();
|
||||
for (what, b) in [("Rasterize", rasterized), ("exports", exported), ("culling", super::cull_bounds(&symbols, n).unwrap())] {
|
||||
assert_eq!(b.union(brush), b, "{what}: {b:?} holds the brush's art {brush:?}");
|
||||
}
|
||||
// The solid alone, without the stroke: what else may be measured.
|
||||
let mut plain = d.clone();
|
||||
plain.node_mut(id).unwrap().appearance.items.retain(|i| !matches!(i, vectorcraft_doc::AppearanceItem::Stroke(_)));
|
||||
let solid = crate::drawn_bounds(&plain, plain.node(id).unwrap()).unwrap();
|
||||
let most = solid.union(brush).inflate(1.0, 1.0);
|
||||
for (what, b) in [("Rasterize", rasterized), ("exports", exported)] {
|
||||
assert_eq!(most.union(b), most, "{what}: {b:?} no more than the solid and the brush's art {most:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The renderer measures a 3D object with Crop Marks as the free measure does: by the marks and
|
||||
/// the object, which paints its own shadow inside them, counted once.
|
||||
#[test]
|
||||
fn the_renderer_measures_3d_art_with_crop_marks_as_drawn() {
|
||||
let fx = vec![
|
||||
("threeD.extrude", json!({"depth": 30.0, "position": "isometricTop"})),
|
||||
("stylize.dropShadow", json!({"x": 20, "y": 0, "blur": 0})),
|
||||
(crate::effects::CROP_MARKS, json!({})),
|
||||
];
|
||||
let d = doc_with(Rect::new(40.0, 40.0, 60.0, 60.0), Color::BLACK, fx);
|
||||
let n = d.layers[0].children().unwrap()[0].clone();
|
||||
let symbols = d.symbol_boxes();
|
||||
let free = super::cull_bounds(&symbols, &n).unwrap();
|
||||
assert_eq!(Renderer::new().bounds_of(&symbols, &n), Some(free));
|
||||
}
|
||||
|
||||
/// Vector Halftone draws dots in place of a 3D effect's solid: the renderer doesn't measure the
|
||||
/// object by art it holds to draw it with, and measures it as the free measure does.
|
||||
#[test]
|
||||
fn the_renderer_measures_3d_art_with_vector_halftone_as_drawn() {
|
||||
let fx = vec![("threeD.extrude", json!({"depth": 30.0, "position": "isometricTop"})), (crate::effects::addons::halftone::HALFTONE, json!({}))];
|
||||
let d = doc_with(Rect::new(40.0, 40.0, 60.0, 60.0), Color::BLACK, fx);
|
||||
let n = d.layers[0].children().unwrap()[0].clone();
|
||||
let symbols = d.symbol_boxes();
|
||||
let free = super::cull_bounds(&symbols, &n).unwrap();
|
||||
let mut r = Renderer::new();
|
||||
// A consistency check: measured from the dots' art, the bounds come out the same.
|
||||
assert_eq!(r.bounds_of(&symbols, &n), Some(free));
|
||||
assert_eq!(r.measured_bytes(), 0, "no art held");
|
||||
}
|
||||
|
||||
/// A renderer measures the art over each artboard as [`crate::encode::art_bounds_on`] does, and
|
||||
/// builds each 3D solid once for every artboard it measures.
|
||||
#[test]
|
||||
fn a_renderer_measures_the_art_on_each_artboard_building_each_solid_once() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let l = d.layers[0].id;
|
||||
let boards = 30;
|
||||
for i in 0..boards {
|
||||
let x = f64::from(i) * 100.0;
|
||||
let mut n = Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::rectangle(Rect::new(x + 30.0, 30.0, x + 60.0, 60.0)),
|
||||
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
);
|
||||
n.appearance.effects =
|
||||
vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 40.0, "position": "isometricTop"}), visible: true }];
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
}
|
||||
let mut r = Renderer::new();
|
||||
for i in 0..boards {
|
||||
let rect = Rect::new(f64::from(i) * 100.0, 0.0, f64::from(i) * 100.0 + 100.0, 100.0);
|
||||
assert_eq!(r.art_bounds_on(&d, rect), crate::encode::art_bounds_on(&d, rect), "artboard {i}");
|
||||
}
|
||||
assert_eq!(r.stats.three_d_built, boards as usize, "each solid built once");
|
||||
}
|
||||
|
||||
/// A 3D object measured but not drawn (off the view) doesn't keep its art past the frame: what is
|
||||
/// kept is what is drawn.
|
||||
#[test]
|
||||
fn only_drawn_3d_art_is_kept() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let l = d.layers[0].id;
|
||||
let mut ids = vec![];
|
||||
for x in [30.0, 530.0] {
|
||||
let mut n = Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::rectangle(Rect::new(x, 30.0, x + 30.0, 60.0)),
|
||||
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
);
|
||||
n.appearance.effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 20.0}), visible: true }];
|
||||
ids.push(d.insert(Some(l), 0, n).unwrap());
|
||||
}
|
||||
let mut r = Renderer::new();
|
||||
r.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
|
||||
let kept = |r: &Renderer, id| {
|
||||
let n = d.layers[0].children().unwrap().iter().find(|c| c.id == id).unwrap();
|
||||
r.fx_arts.contains_key(&(Arc::as_ptr(n) as usize))
|
||||
};
|
||||
assert!(kept(&r, ids[0]), "drawn, kept");
|
||||
assert!(!kept(&r, ids[1]), "off the view, let go");
|
||||
assert!(r.measured.is_empty());
|
||||
}
|
||||
|
||||
/// Art made to measure objects is held within a budget: past it, objects are measured and their
|
||||
/// art is made again if they are drawn.
|
||||
#[test]
|
||||
fn measured_art_is_held_within_its_budget() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let l = d.layers[0].id;
|
||||
for i in 0..20 {
|
||||
let x = 500.0 + f64::from(i) * 40.0;
|
||||
let mut n = Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::rectangle(Rect::new(x, 30.0, x + 30.0, 60.0)),
|
||||
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
);
|
||||
n.appearance.effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 20.0}), visible: true }];
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
}
|
||||
let symbols = d.symbol_boxes();
|
||||
let mut r = Renderer::new();
|
||||
let one = {
|
||||
let n = &d.layers[0].children().unwrap()[0];
|
||||
r.bounds_of(&symbols, n);
|
||||
r.measured_bytes
|
||||
};
|
||||
r.let_go_of_measured();
|
||||
r.measured_budget = one * 3;
|
||||
for n in d.layers[0].children().unwrap() {
|
||||
r.bounds_of(&symbols, n);
|
||||
}
|
||||
assert!(r.measured_bytes <= one * 3 && r.measured.len() <= 3, "{} bytes in {} entries", r.measured_bytes, r.measured.len());
|
||||
// Every object is measured right, those past the budget too, and draws as a new renderer
|
||||
// draws it, its art made again.
|
||||
for n in d.layers[0].children().unwrap() {
|
||||
assert_eq!(r.bounds_of(&symbols, n), super::cull_bounds(&symbols, n), "{:?}", n.id);
|
||||
}
|
||||
let (view, opts) = (Affine::translate((-500.0, 0.0)), RenderOptions::default());
|
||||
let drawn = r.render(&d, 800, 100, view, &opts).pixels;
|
||||
assert!(drawn == Renderer::new().render(&d, 800, 100, view, &opts).pixels);
|
||||
}
|
||||
|
||||
/// An edit to 3D objects the frame doesn't draw (hidden, in a hidden group, drawn hidden) builds
|
||||
/// nothing to measure them, and changes nothing.
|
||||
#[test]
|
||||
fn an_edit_out_of_sight_builds_nothing() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let l = d.layers[0].id;
|
||||
let extruded = |d: &mut Document, x: f64| {
|
||||
let mut n = Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::rectangle(Rect::new(x, 30.0, x + 20.0, 50.0)),
|
||||
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
);
|
||||
n.appearance.effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 20.0}), visible: true }];
|
||||
n
|
||||
};
|
||||
let mut hidden = extruded(&mut d, 10.0);
|
||||
hidden.visible = false;
|
||||
let member = extruded(&mut d, 40.0);
|
||||
let mut group = Node::group(d.alloc_id(), vec![Arc::new(member)]);
|
||||
group.visible = false;
|
||||
let drawn_hidden = extruded(&mut d, 70.0);
|
||||
let ids = [d.insert(Some(l), 0, hidden).unwrap(), group.children().unwrap()[0].id, d.insert(Some(l), 0, drawn_hidden).unwrap()];
|
||||
d.insert(Some(l), 0, group).unwrap();
|
||||
let opts = RenderOptions { hidden: vec![ids[2]], ..Default::default() };
|
||||
let mut r = Renderer::new();
|
||||
r.render(&d, 100, 100, Affine::IDENTITY, &opts);
|
||||
let mut e = d.clone();
|
||||
for id in ids {
|
||||
e.node_mut(id).unwrap().appearance.effects[0].params = json!({"depth": 5.0});
|
||||
}
|
||||
let built = r.stats.three_d_built;
|
||||
assert_eq!(r.changed(&d, &e, &opts), crate::Changed::Nothing);
|
||||
assert_eq!((r.stats.three_d_built - built, r.measured.len()), (0, 0));
|
||||
}
|
||||
|
||||
/// The bounds of 3D art kept for a pass that only measures stay as many as the art still held.
|
||||
#[test]
|
||||
fn a_measuring_pass_keeps_the_bounds_of_held_art_only() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let l = d.layers[0].id;
|
||||
for i in 0..3000 {
|
||||
let x = f64::from(i) * 30.0;
|
||||
let mut n = Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::rectangle(Rect::new(x, 30.0, x + 20.0, 50.0)),
|
||||
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
);
|
||||
n.appearance.effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 2.0}), visible: true }];
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
}
|
||||
let symbols = d.symbol_boxes();
|
||||
let mut r = Renderer::new();
|
||||
// Nothing measured is held: the art is let go once measured.
|
||||
r.measured_budget = 0;
|
||||
for n in d.layers[0].children().unwrap() {
|
||||
r.bounds_of(&symbols, n);
|
||||
}
|
||||
assert_eq!(r.stats.three_d_built, 3000);
|
||||
assert!(r.three_d_bounds.len() <= 1024, "{} kept", r.three_d_bounds.len());
|
||||
}
|
||||
|
||||
/// While its 3D art is held, a pass that only measures sweeps the bounds kept for 3D art each time
|
||||
/// they double, not on every new art past a size.
|
||||
#[test]
|
||||
fn a_measuring_pass_sweeps_the_bounds_of_held_art_as_they_double() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let l = d.layers[0].id;
|
||||
for i in 0..3000 {
|
||||
let x = f64::from(i) * 30.0;
|
||||
let mut n = Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::rectangle(Rect::new(x, 30.0, x + 20.0, 50.0)),
|
||||
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
);
|
||||
n.appearance.effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 2.0}), visible: true }];
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
}
|
||||
let mut r = Renderer::new();
|
||||
// Held here, as the art kept to draw is.
|
||||
let held: Vec<_> = d.layers[0].children().unwrap().iter().map(|n| r.measured_fx_art(&d, n)).collect();
|
||||
assert_eq!((held.len(), r.three_d_bounds.len()), (3000, 3000));
|
||||
assert!(r.three_d_sweeps <= 2, "{} sweeps", r.three_d_sweeps);
|
||||
}
|
||||
|
||||
/// A renderer checks the brush library between frames only once it has measured 3D art, and again
|
||||
/// only after measuring 3D art since the library last changed.
|
||||
#[test]
|
||||
fn the_brush_library_is_checked_only_after_3d_art_is_measured() {
|
||||
let mut d = doc_with(Rect::new(30.0, 30.0, 60.0, 60.0), Color::BLACK, vec![]);
|
||||
let mut r = Renderer::new();
|
||||
r.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
|
||||
assert_eq!(r.brush_library, None);
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
d.node_mut(id).unwrap().appearance.effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 20.0}), visible: true }];
|
||||
r.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
|
||||
assert!(r.brush_library.is_some());
|
||||
// A library changed: the bounds measured with the old one go, and so does its key.
|
||||
let mut lib = vectorcraft_brush::library(&d);
|
||||
lib.retain(|b| b.name != "3 pt. Round");
|
||||
vectorcraft_brush::store(&mut d, &lib);
|
||||
r.check_symbols(&d);
|
||||
assert_eq!(r.brush_library, None);
|
||||
}
|
||||
|
||||
/// An object whose 3D effect builds nothing (Revolve on a group) is measured by the renderer as the
|
||||
/// free measure does it: as the renderer draws it, by its members.
|
||||
#[test]
|
||||
fn a_3d_effect_that_builds_nothing_is_measured_alike() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let mut hidden = Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::rectangle(Rect::new(60.0, 10.0, 90.0, 20.0)),
|
||||
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
);
|
||||
hidden.visible = false;
|
||||
let shown = Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)),
|
||||
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
);
|
||||
let mut g = Node::group(d.alloc_id(), vec![Arc::new(hidden), Arc::new(shown)]);
|
||||
g.appearance.effects = vec![Effect { id: "threeD.revolve".into(), params: json!({}), visible: true }];
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, g).unwrap();
|
||||
let n = d.layers[0].children().unwrap()[0].clone();
|
||||
let symbols = d.symbol_boxes();
|
||||
assert_eq!(Renderer::new().effect_bounds(&symbols, &n), crate::effect_bounds(&symbols, &n));
|
||||
assert_eq!(Renderer::new().bounds_of(&symbols, &n), crate::brush_fx::cull_bounds(&symbols, &n));
|
||||
}
|
||||
|
||||
/// A group with effects of its own that holds an extruded path is measured once for every artboard
|
||||
/// of a pass: its bounds are kept after the first.
|
||||
#[test]
|
||||
fn a_group_holding_3d_art_is_measured_once_a_pass() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let mut inner = Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::rectangle(Rect::new(30.0, 30.0, 60.0, 60.0)),
|
||||
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
);
|
||||
inner.appearance.effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 20.0}), visible: true }];
|
||||
let mut g = Node::group(d.alloc_id(), vec![Arc::new(inner)]);
|
||||
g.appearance.effects = vec![Effect { id: "stylize.dropShadow".into(), params: json!({"x": 4, "y": 4, "blur": 2}), visible: true }];
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, g).unwrap();
|
||||
let g = d.layers[0].children().unwrap()[0].clone();
|
||||
let mut r = Renderer::new();
|
||||
let first = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
assert_eq!(r.art_bounds_on(&d, first), crate::encode::art_bounds_on(&d, first));
|
||||
let key = Arc::as_ptr(&g) as usize;
|
||||
assert!(r.geom.contains_key(&key), "kept for the next artboard");
|
||||
// The group's bounds, planted over the next artboards: they read the kept bounds, measuring
|
||||
// nothing again.
|
||||
let planted = Rect::new(100.0, 0.0, 300.0, 100.0);
|
||||
r.geom.get_mut(&key).unwrap().bounds = Some(planted);
|
||||
for i in 1..3 {
|
||||
let rect = Rect::new(f64::from(i) * 100.0, 0.0, f64::from(i) * 100.0 + 100.0, 100.0);
|
||||
assert_eq!(r.art_bounds_on(&d, rect), Some(planted), "artboard {i}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A pass that only measures (Fit to Artwork Bounds, an export) holds no 3D art past its budget:
|
||||
/// what it measured and didn't keep is let go.
|
||||
#[test]
|
||||
fn a_measuring_pass_holds_no_art_past_its_budget() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let l = d.layers[0].id;
|
||||
for i in 0..20 {
|
||||
let x = f64::from(i) * 40.0;
|
||||
let mut n = Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::rectangle(Rect::new(x, 30.0, x + 30.0, 60.0)),
|
||||
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
);
|
||||
n.appearance.effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 20.0}), visible: true }];
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
}
|
||||
let mut r = Renderer::new();
|
||||
r.measured_budget = 0;
|
||||
r.art_bounds_on(&d, Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
assert!(r.measured.is_empty());
|
||||
assert!(r.three_d_bounds.values().all(|e| e.0.strong_count() == 0), "no art held");
|
||||
}
|
||||
|
||||
/// Drawing an object takes the art made to measure it out of the budget: a frame draws each 3D
|
||||
/// object with that art, making none again, while what it measured fits the budget.
|
||||
#[test]
|
||||
fn drawing_takes_measured_art_out_of_the_budget() {
|
||||
let mut d = Document::new(800.0, 100.0);
|
||||
let l = d.layers[0].id;
|
||||
for i in 0..20 {
|
||||
let x = f64::from(i) * 40.0;
|
||||
let mut n = Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::rectangle(Rect::new(x, 30.0, x + 30.0, 60.0)),
|
||||
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
);
|
||||
n.appearance.effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 20.0}), visible: true }];
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
}
|
||||
let n = d.layers[0].children().unwrap()[0].clone();
|
||||
let mut r = Renderer::new();
|
||||
r.bounds_of(&d.symbol_boxes(), &n);
|
||||
assert!(r.measured_bytes > 0);
|
||||
r.fx_art(&d, &n, true);
|
||||
assert_eq!((r.measured_bytes, r.measured.len()), (0, 0), "taken out when drawn");
|
||||
let mut r = Renderer::new();
|
||||
r.render(&d, 800, 100, Affine::IDENTITY, &RenderOptions::default());
|
||||
assert_eq!(r.stats.three_d_built, 20, "each made once");
|
||||
}
|
||||
|
||||
/// An object with a brushed stroke whose 3D effect builds nothing is measured by a pass as without
|
||||
/// it: not with the room culling leaves for its brush.
|
||||
#[test]
|
||||
fn a_pass_measures_brushed_art_as_exports_do() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let mut art = Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)),
|
||||
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
);
|
||||
art.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 1.0)));
|
||||
if let Some(vectorcraft_doc::AppearanceItem::Stroke(s)) = art.appearance.items.last_mut() {
|
||||
s.brush = Some("10 pt. Oval".into());
|
||||
}
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Brushed".into(), art: Arc::new(art) });
|
||||
let mut n = Node::new(d.alloc_id(), vectorcraft_doc::NodeKind::SymbolInstance { symbol: "Brushed".into(), xf: Affine::translate((50.0, 50.0)) });
|
||||
n.appearance.effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 20.0}), visible: true }];
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
let n = d.layers[0].children().unwrap()[0].clone();
|
||||
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
assert_eq!(Renderer::new().art_bounds_on(&d, rect), crate::encode::art_bounds_on(&d, rect));
|
||||
let symbols = d.symbol_boxes().with_brush_art();
|
||||
assert_eq!(Renderer::new().effect_bounds(&symbols, &n), crate::effect_bounds(&symbols, &n));
|
||||
}
|
||||
|
||||
/// A 3D object whose brushed stroke's brush gets wider is drawn where the brush now reaches, by a
|
||||
/// renderer that measured it before: culling leaves room for brush art.
|
||||
#[test]
|
||||
fn a_3d_objects_wider_brush_is_drawn_where_it_reaches() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let mut ap = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::BLACK), 1.0);
|
||||
ap.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
|
||||
let mut n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(104.0, 40.0, 124.0, 60.0)), ap);
|
||||
n.appearance.fill_mut().unwrap().effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 2.0}), visible: true }];
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let mut r = Renderer::new();
|
||||
r.render(&d, 100, 100, Affine::IDENTITY, &opts);
|
||||
let mut lib = vectorcraft_brush::library(&d);
|
||||
let round = lib.iter_mut().find(|b| b.name == "3 pt. Round").unwrap();
|
||||
if let vectorcraft_brush::BrushKind::Calligraphic(c) = &mut round.kind {
|
||||
c.size = 30.0;
|
||||
}
|
||||
vectorcraft_brush::store(&mut d, &lib);
|
||||
let drawn = r.render(&d, 100, 100, Affine::IDENTITY, &opts).pixels;
|
||||
let fresh = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts).pixels;
|
||||
assert!(fresh.iter().any(|c| *c < 128), "the wider brush reaches into the view");
|
||||
assert!(drawn == fresh, "as a new renderer draws it");
|
||||
}
|
||||
|
||||
/// A 20 pt square at `x` with a fill-level 3D effect, a brushed stroke and Rasterize, in a 100 pt
|
||||
/// view: drawn by a renderer that drew it before its brush got wider (`size` pt), and by a new
|
||||
/// renderer.
|
||||
fn drawn_after_a_brush_widens(x: f64, size: f64) -> (Vec<u8>, Vec<u8>) {
|
||||
let brush = "3 pt. Round";
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let mut ap = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::BLACK), 1.0);
|
||||
ap.stroke_mut().unwrap().brush = Some(brush.into());
|
||||
let mut n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 40.0, x + 20.0, 60.0)), ap);
|
||||
n.appearance.fill_mut().unwrap().effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 2.0}), visible: true }];
|
||||
n.appearance.effects = vec![Effect { id: "rasterize".into(), params: json!({"useDocumentResolution": false, "resolution": 72}), visible: true }];
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let mut r = Renderer::new();
|
||||
r.render(&d, 100, 100, Affine::IDENTITY, &opts);
|
||||
let mut lib = vectorcraft_brush::library(&d);
|
||||
let b = lib.iter_mut().find(|b| b.name == brush).unwrap();
|
||||
if let vectorcraft_brush::BrushKind::Calligraphic(c) = &mut b.kind {
|
||||
c.size = size;
|
||||
}
|
||||
vectorcraft_brush::store(&mut d, &lib);
|
||||
let drawn = r.render(&d, 100, 100, Affine::IDENTITY, &opts).pixels;
|
||||
(drawn, Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts).pixels)
|
||||
}
|
||||
|
||||
/// Rasterize on a brushed 3D object outside the view draws its art with the brush as it is now, by
|
||||
/// a renderer that drew it with the brush before: the object is measured again when the brush
|
||||
/// library changes, with the brush's art reaching past the room culling leaves for it, and
|
||||
/// rasterized again.
|
||||
#[test]
|
||||
fn a_rasterized_3d_objects_brush_widened_is_drawn() {
|
||||
let (drawn, fresh) = drawn_after_a_brush_widens(140.0, 120.0);
|
||||
assert!(fresh.iter().any(|c| *c < 128), "the wider brush reaches into the view");
|
||||
assert!(drawn == fresh, "as a new renderer draws it");
|
||||
}
|
||||
|
||||
/// A rasterized brushed 3D object in the view is rasterized again when its brush changes.
|
||||
#[test]
|
||||
fn a_rasterized_objects_brush_widened_in_view_is_drawn_again() {
|
||||
let (drawn, fresh) = drawn_after_a_brush_widens(40.0, 12.0);
|
||||
let (_, before) = drawn_after_a_brush_widens(40.0, 3.0);
|
||||
assert!(fresh != before, "the wider brush draws otherwise");
|
||||
assert!(drawn == fresh, "as a new renderer draws it");
|
||||
}
|
||||
|
||||
/// A group with effects of its own that holds a hidden member is measured without it once a pass,
|
||||
/// as exports measure it.
|
||||
#[test]
|
||||
fn a_group_with_a_hidden_member_is_measured_once_a_pass() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let mut solid = Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::rectangle(Rect::new(30.0, 30.0, 60.0, 60.0)),
|
||||
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
);
|
||||
solid.appearance.effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 20.0}), visible: true }];
|
||||
let mut hidden = Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::rectangle(Rect::new(80.0, 80.0, 95.0, 95.0)),
|
||||
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
);
|
||||
hidden.visible = false;
|
||||
let mut g = Node::group(d.alloc_id(), vec![Arc::new(solid), Arc::new(hidden)]);
|
||||
g.appearance.effects = vec![Effect { id: "stylize.dropShadow".into(), params: json!({"x": 4, "y": 4, "blur": 2}), visible: true }];
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, g).unwrap();
|
||||
let g = d.layers[0].children().unwrap()[0].clone();
|
||||
let mut r = Renderer::new();
|
||||
let first = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
assert_eq!(r.art_bounds_on(&d, first), crate::encode::art_bounds_on(&d, first));
|
||||
let key = Arc::as_ptr(&g) as usize;
|
||||
assert!(r.pass_bounds.contains_key(&key), "kept for the next artboard");
|
||||
// The group's bounds, planted over the next artboards: they read the kept bounds, measuring
|
||||
// nothing again.
|
||||
let planted = Rect::new(100.0, 0.0, 300.0, 100.0);
|
||||
r.pass_bounds.get_mut(&key).unwrap().1 = Some(planted);
|
||||
for i in 1..3 {
|
||||
let rect = Rect::new(f64::from(i) * 100.0, 0.0, f64::from(i) * 100.0 + 100.0, 100.0);
|
||||
assert_eq!(r.art_bounds_on(&d, rect), Some(planted), "artboard {i}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Art made again for an object, after what it read from outside it changed, takes the place of
|
||||
/// the art held for it, and of its bytes.
|
||||
#[test]
|
||||
fn art_measured_again_takes_the_place_of_the_held() {
|
||||
let d = doc_with(Rect::new(30.0, 30.0, 60.0, 60.0), Color::BLACK, vec![("threeD.extrude", json!({"depth": 20.0}))]);
|
||||
let n = d.layers[0].children().unwrap()[0].clone();
|
||||
let key = Arc::as_ptr(&n) as usize;
|
||||
let mut r = Renderer::new();
|
||||
let art = r.measured_fx_art(&d, &n);
|
||||
let bytes = r.measured_bytes;
|
||||
// As if it had read something else then.
|
||||
r.measured.insert(key, (n.clone(), art, Some(7)));
|
||||
r.measured_fx_art(&d, &n);
|
||||
assert_eq!((r.measured_bytes, r.measured.len()), (bytes, 1));
|
||||
}
|
||||
|
||||
/// Multithreaded rendering filters each effect offscreen; it must match the single-threaded
|
||||
/// reference (blend modes against the real backdrop, objects partly off-screen, all effects).
|
||||
#[test]
|
||||
@@ -1145,3 +1747,70 @@ fn an_image_inner_glow_follows_its_visible_pixels() {
|
||||
assert!(lum(img.pixel(60, 50)) < lum(img.pixel(51, 50)), "threads {threads}: fading inward");
|
||||
}
|
||||
}
|
||||
|
||||
/// A 3D effect on a path's fill or stroke that builds nothing (Revolve after Roughen) draws the
|
||||
/// item as without it: the art made for the object is drawn without it, not as an object with
|
||||
/// effects again, and that art again, without end. A stroked object draws exactly as without the
|
||||
/// 3D effect, with a raster effect of its own (Drop Shadow, Gaussian Blur) too. An object with
|
||||
/// only a fill and a raster effect draws from that art, where without the 3D effect it draws as a
|
||||
/// path, whose raster effects are clipped to the path's own geometry: there Roughen's spikes are
|
||||
/// cut off, so the two differ in a few pixels at the edge and match inside the path. That clipping
|
||||
/// is an older bug on main of drawing without the 3D effect, not the design. Either
|
||||
/// way the raster effect reaches past what the object draws without it.
|
||||
#[test]
|
||||
fn a_3d_effect_that_builds_nothing_draws_the_item_as_without_it() {
|
||||
let rough =
|
||||
Effect { id: "distort.roughen".into(), params: json!({"size": 10, "relative": false, "detail": 10, "points": "smooth"}), visible: true };
|
||||
let revolve = Effect { id: "threeD.revolve".into(), params: json!({}), visible: true };
|
||||
let shadow = Effect { id: "stylize.dropShadow".into(), params: json!({}), visible: true };
|
||||
let blur = Effect { id: "blur.gaussian".into(), params: json!({"radius": 2}), visible: true };
|
||||
let cases = [
|
||||
(vec![], true, true),
|
||||
(vec![], false, true),
|
||||
(vec![shadow.clone()], true, true),
|
||||
(vec![blur.clone()], false, true),
|
||||
(vec![], true, false),
|
||||
(vec![shadow], true, false),
|
||||
(vec![blur], true, false),
|
||||
];
|
||||
for (object, on_fill, stroked) in cases {
|
||||
let draw = |object: &[Effect], three: bool| {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let stroke = if stroked { Paint::solid(Color::rgb(0.0, 0.0, 1.0)) } else { Paint::None };
|
||||
let ap = Appearance::basic(Paint::solid(Color::BLACK), stroke, 2.0);
|
||||
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(30.0, 30.0, 60.0, 60.0)), ap);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.insert(Some(l), 0, n).unwrap();
|
||||
let n = d.node_mut(id).unwrap();
|
||||
n.appearance.effects = object.to_vec();
|
||||
let item = if on_fill {
|
||||
n.appearance.fill_mut().unwrap().effects = vec![];
|
||||
&mut n.appearance.items[0]
|
||||
} else {
|
||||
&mut n.appearance.items[1]
|
||||
};
|
||||
*item.effects_mut() = [Some(rough.clone()), three.then(|| revolve.clone())].into_iter().flatten().collect();
|
||||
assert!(crate::effects::revolve_art(d.node(id).unwrap()).is_none(), "builds nothing");
|
||||
Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()).pixels
|
||||
};
|
||||
let what = format!("{object:?} on the fill {on_fill}, stroked {stroked}");
|
||||
let (with, without) = (draw(&object, true), draw(&object, false));
|
||||
if stroked || object.is_empty() {
|
||||
assert!(with == without, "{what}: as without it");
|
||||
} else {
|
||||
// Under 4% of the pixels, all at the edge, where Roughen's spikes are: inside the path
|
||||
// they match.
|
||||
let differ: Vec<(usize, usize)> =
|
||||
(0..100 * 100).filter(|i| with[i * 4..i * 4 + 4] != without[i * 4..i * 4 + 4]).map(|i| (i % 100, i / 100)).collect();
|
||||
assert!(differ.len() * 25 < 100 * 100, "{what}: {} pixels differ", differ.len());
|
||||
let inside = differ.iter().find(|(x, y)| (33..57).contains(x) && (33..57).contains(y));
|
||||
assert!(inside.is_none(), "{what}: differs inside the path at {inside:?}");
|
||||
}
|
||||
if !object.is_empty() {
|
||||
// Ink where the object without its raster effect draws none.
|
||||
let plain = draw(&[], true);
|
||||
let past = (0..100 * 100).filter(|i| plain[i * 4 + 3] == 0 && with[i * 4 + 3] > 0).count();
|
||||
assert!(past > 0, "{what}: drawn past the object");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,14 @@ pub use pattern::render_pattern_swatch;
|
||||
pub use vectorcraft_effects as effects;
|
||||
pub use vello_cpu;
|
||||
|
||||
/// Art made to measure an object ([`Renderer::measured`]): the object, its art, and the key of
|
||||
/// what the art read from outside the object (`None`: nothing).
|
||||
type MeasuredArt = (Arc<Node>, Arc<Node>, Option<u64>);
|
||||
|
||||
/// A 3D effect's art ([`Renderer::three_d_bounds`]), held weakly, and its bounds once measured at a
|
||||
/// raster effects resolution (its bits) with a brush library (its key).
|
||||
type ThreeDArt = (std::sync::Weak<Node>, Option<((u64, u64), Option<Rect>)>);
|
||||
|
||||
/// Reusable renderer (keeps the render context, geometry and glyph caches between frames; decoded
|
||||
/// images are shared by all renderers, see [`set_image_cache_budget`]).
|
||||
pub struct Renderer {
|
||||
@@ -76,6 +84,11 @@ pub struct Renderer {
|
||||
/// The plug-in revision ([`effects::plugin_revision`]) that `geom`, `fx_geom` and `fx_arts` were
|
||||
/// last checked against (see [`Self::check_plugins`]).
|
||||
plugin_revision: u64,
|
||||
/// The key of the brush library ([`brush_fx::BrushCache::library_key`]) when `geom`'s bounds of
|
||||
/// 3D art were measured with it (see [`Self::check_symbols`]): set by the first such measure
|
||||
/// and kept, whether or not those bounds still are, until the library changes. `None` until
|
||||
/// then, and nothing is checked.
|
||||
brush_library: Option<u64>,
|
||||
/// Is a live-effect plug-in that paints its pieces installed (found when `plugin_revision`
|
||||
/// changes)?
|
||||
painting_plugins: bool,
|
||||
@@ -148,6 +161,33 @@ pub struct Renderer {
|
||||
shape_of: usize,
|
||||
/// The art drawn for objects with effects that apply through it (see `fx::has_object_fx`).
|
||||
fx_arts: FrameCache<usize, FxArtEntry>,
|
||||
/// The art of objects with object effects made to measure them ([`Renderer::bounds_of`]) and
|
||||
/// not drawn yet, by the object's address, with the object and the key of what it read from
|
||||
/// outside the object when it was made (`None`: nothing): drawing one keeps it in `fx_arts`
|
||||
/// while that is unchanged; the frame's end lets go of the rest.
|
||||
measured: PtrMap<usize, MeasuredArt>,
|
||||
/// The bytes of the art in `measured` ([`lru::node_bytes`]), within `measured_budget`
|
||||
/// ([`lru::MEASURED_BUDGET`]).
|
||||
measured_bytes: usize,
|
||||
measured_budget: usize,
|
||||
/// The export bounds of the objects measured without their hidden members
|
||||
/// (`encode::drawn_bounds`), by the object they were made from: kept for a pass over artboards,
|
||||
/// let go when a frame ends.
|
||||
pub(crate) pass_bounds: PtrMap<usize, (Arc<Node>, Option<Rect>)>,
|
||||
/// The 3D effects' art among the art `fx_art` made, by its address, with the art (weakly: what
|
||||
/// keeps the art is `fx_arts` and `measured`) and its bounds once measured. An entry is added
|
||||
/// for every 3D art made, measured or not. Entries whose art is gone are swept when a frame
|
||||
/// ends (`let_go_of_measured`) and, for a renderer that only measures, when the map reaches
|
||||
/// twice the size it kept at the last sweep (at least 1024): it holds at most max(twice the
|
||||
/// entries kept at the last sweep, 1024). Until swept, an entry's weak pointer keeps the art's
|
||||
/// allocation (not its contents) alive.
|
||||
three_d_bounds: PtrMap<usize, ThreeDArt>,
|
||||
/// How many entries `three_d_bounds` kept when it last let go of the art no longer held: it
|
||||
/// does so again when it holds twice as many.
|
||||
three_d_kept: usize,
|
||||
/// How many times `three_d_bounds` was swept for art no longer held between frames.
|
||||
#[cfg(test)]
|
||||
three_d_sweeps: usize,
|
||||
/// Symbol art as instances and inline graphics draw it (see [`Self::placed_art`]).
|
||||
placed_arts: FrameCache<PlacedKey, PlacedEntry>,
|
||||
/// The bitmap clock of [`placed_document`] when this frame first drew a placed document from
|
||||
|
||||
@@ -33,6 +33,10 @@ pub(crate) const CHAR_STROKE_BUDGET: usize = 128 << 20;
|
||||
pub(crate) const CLIP_BUDGET: usize = 32 << 20;
|
||||
/// The art drawn for objects with effects that apply through it (`Renderer::fx_art`).
|
||||
pub(crate) const FX_ART_BUDGET: usize = 64 << 20;
|
||||
/// Art made to measure objects and held until the frame ends, for the objects drawn to keep
|
||||
/// (`Renderer::measured`): as much as the art kept for the objects drawn (`FX_ART_BUDGET`), which
|
||||
/// a frame measures before it draws them. Past it, an object is made again to draw it.
|
||||
pub(crate) const MEASURED_BUDGET: usize = FX_ART_BUDGET;
|
||||
/// Symbol art placed for instances and inline graphics (`Renderer::placed_art`).
|
||||
pub(crate) const PLACED_ART_BUDGET: usize = 64 << 20;
|
||||
/// Brush art per brushed stroke (`Renderer::draw_brush`).
|
||||
|
||||
@@ -107,6 +107,33 @@ pub(crate) fn holds_instance(n: &Node) -> bool {
|
||||
holds
|
||||
}
|
||||
|
||||
/// Is `a` left out of a frame `doc` draws with `opts`, before any culling: hidden, a template or
|
||||
/// guide the view hides, drawn hidden, or the instance its symbol is being edited from?
|
||||
pub(crate) fn left_out(opts: &crate::RenderOptions, doc: &vectorcraft_doc::Document, a: &Node) -> bool {
|
||||
let view_item = (opts.skip_templates || opts.hide_templates) && matches!(a.kind, NodeKind::Layer { template: true, .. })
|
||||
|| (opts.skip_templates || opts.hide_guides) && matches!(a.kind, NodeKind::Path { guide: true, .. });
|
||||
!a.visible || view_item || opts.hidden.contains(&a.id) || edited_instance(opts, doc, a.id)
|
||||
}
|
||||
|
||||
/// Symbol editing mode on screen: is `id` the instance editing was entered from, which is not drawn
|
||||
/// (with its masks and effects), since the editing layer holds a copy of its art in its place?
|
||||
fn edited_instance(opts: &crate::RenderOptions, doc: &vectorcraft_doc::Document, id: vectorcraft_doc::NodeId) -> bool {
|
||||
opts.isolated.is_some() && doc.symbol_edit.as_ref().is_some_and(|e| e.instance == Some(id))
|
||||
}
|
||||
|
||||
/// Does [`crate::Renderer::bounds_of`] measure `n` by the 3D art the renderer draws and keeps for
|
||||
/// it: a Revolve, Extrude & Bevel, Inflate or Rotate without Crop Marks or Vector Halftone, which
|
||||
/// draw other art?
|
||||
pub(crate) fn measures_three_d(n: &Node) -> bool {
|
||||
(effects::has_revolve(n) || effects::has_solid(n)) && !effects::has_crop_marks(n) && !effects::addons::halftone::has_halftone(n)
|
||||
}
|
||||
|
||||
/// Does `n` hold an object that [`crate::Renderer::bounds_of`] measures by its 3D effect's art
|
||||
/// ([`measures_three_d`])?
|
||||
pub(crate) fn holds_measured_three_d(n: &Node) -> bool {
|
||||
measures_three_d(n) || n.children().is_some_and(|c| c.iter().any(|c| holds_measured_three_d(c)))
|
||||
}
|
||||
|
||||
/// Does `n` hold an object with a visible plug-in effect ([`effects::has_plugin_effect`]), in it or
|
||||
/// in the opacity masks of the objects in it?
|
||||
pub(crate) fn holds_plugin_effect(n: &Node) -> bool {
|
||||
|
||||
@@ -45,6 +45,7 @@ impl Renderer {
|
||||
kept: PtrMap::default(),
|
||||
symbols: vec![],
|
||||
plugin_revision: 0,
|
||||
brush_library: None,
|
||||
painting_plugins: false,
|
||||
paint_names: 0,
|
||||
strokes: PtrMap::default(),
|
||||
@@ -70,6 +71,14 @@ impl Renderer {
|
||||
knockout: false,
|
||||
shape_of: 0,
|
||||
fx_arts: FrameCache::new(lru::FX_ART_BUDGET, 256),
|
||||
measured: PtrMap::default(),
|
||||
measured_bytes: 0,
|
||||
measured_budget: lru::MEASURED_BUDGET,
|
||||
pass_bounds: PtrMap::default(),
|
||||
three_d_bounds: PtrMap::default(),
|
||||
three_d_kept: 0,
|
||||
#[cfg(test)]
|
||||
three_d_sweeps: 0,
|
||||
placed_arts: FrameCache::new(lru::PLACED_ART_BUDGET, 256),
|
||||
placed_frame: None,
|
||||
nested: 0,
|
||||
@@ -227,6 +236,8 @@ impl Renderer {
|
||||
self.clips.sweep(g);
|
||||
self.clip_paints.sweep(g);
|
||||
self.fx_arts.sweep(g);
|
||||
// Only the 3D art still kept (in `fx_arts`) keeps its bounds.
|
||||
self.let_go_of_measured();
|
||||
self.placed_arts.sweep(g);
|
||||
self.brushes.entries.sweep(g);
|
||||
self.live.sweep(g);
|
||||
|
||||
@@ -5,6 +5,7 @@ use super::{
|
||||
shape_masked_image, single_plain_fill, text_geom_snapped, white_shape_mask,
|
||||
};
|
||||
use crate::kept_paths::{self, KeptPath};
|
||||
use crate::node_checks::{holds_measured_three_d, measures_three_d};
|
||||
|
||||
impl Renderer {
|
||||
/// Cached cull bounds of a node (containers union their cached children), symbol instances
|
||||
@@ -29,6 +30,24 @@ impl Renderer {
|
||||
}
|
||||
acc
|
||||
}
|
||||
// A 3D effect's art, measured as this renderer draws and keeps it ([`Self::fx_art`]): one
|
||||
// build for the measure and the draw. Crop Marks and Vector Halftone draw other art, and
|
||||
// an object whose 3D effect builds nothing (Revolve on a group, a solid too complex) is
|
||||
// drawn otherwise, measured as below.
|
||||
_ if measures_three_d(a)
|
||||
&& let Some(b) = {
|
||||
let art = self.measured_fx_art(symbols.document(), a);
|
||||
self.three_d_art_bounds(symbols, &art)
|
||||
} =>
|
||||
{
|
||||
// Culling leaves room for brush art as for any object ([`brush_fx::cull_bounds`]);
|
||||
// the measure is made again when the brush library changes ([`Self::check_symbols`]).
|
||||
if self.brush_library.is_none() {
|
||||
self.brush_library = Some(self.brushes.library_key_now(symbols.document(), self.stamp));
|
||||
}
|
||||
let pad = brush_fx::pad(symbols, a);
|
||||
b.map(|b| fx::with_own_raster_reach(symbols, a, b).inflate(pad, pad))
|
||||
}
|
||||
_ => self.kept_bounds(symbols, a).unwrap_or_else(|| brush_fx::cull_bounds(symbols, a)),
|
||||
};
|
||||
self.geom.insert(key, GeomEntry { node: a.clone(), bounds: b, path: None, stamp: self.stamp });
|
||||
@@ -81,7 +100,8 @@ impl Renderer {
|
||||
|
||||
/// Drop the cached bounds of symbol instances and of the objects that hold them when `doc`'s
|
||||
/// symbols are not those of the document last drawn (by name and art): those bounds measured
|
||||
/// the instances by their symbol's art.
|
||||
/// the instances by their symbol's art. Likewise drop those of objects measured by a 3D
|
||||
/// effect's art, and of the objects that hold them, when the brush library has changed.
|
||||
pub(crate) fn check_symbols(&mut self, doc: &Document) {
|
||||
let same = self.symbols.len() == doc.symbols.len()
|
||||
&& self.symbols.iter().zip(&doc.symbols).all(|((name, art), s)| *name == s.name && std::ptr::eq(art.as_ptr(), Arc::as_ptr(&s.art)));
|
||||
@@ -89,6 +109,14 @@ impl Renderer {
|
||||
self.geom.retain(|_, e| !holds_instance(&e.node));
|
||||
self.symbols = doc.symbols.iter().map(|s| (s.name.clone(), Arc::downgrade(&s.art))).collect();
|
||||
}
|
||||
// That art is measured with the art its brushed strokes draw ([`Self::bounds_of`]). Without
|
||||
// such bounds, nothing is checked.
|
||||
if let Some(was) = self.brush_library
|
||||
&& self.brushes.library_key_now(doc, self.stamp) != was
|
||||
{
|
||||
self.geom.retain(|_, e| !holds_measured_three_d(&e.node));
|
||||
self.brush_library = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// When a plug-in has been installed or removed since the last check
|
||||
@@ -188,19 +216,10 @@ impl Renderer {
|
||||
self.path_of(f.symbols, &owner)
|
||||
}
|
||||
|
||||
/// Symbol editing mode on screen: `id` is the instance editing was entered from, which is not
|
||||
/// drawn (with its masks and effects), since the editing layer holds a copy of its art in its
|
||||
/// place.
|
||||
fn edited_instance(f: &Frame, id: NodeId) -> bool {
|
||||
f.opts.isolated.is_some() && f.doc.symbol_edit.as_ref().is_some_and(|e| e.instance == Some(id))
|
||||
}
|
||||
|
||||
/// Whether `a` is left out of this frame: hidden, a skipped template or guide, the instance a
|
||||
/// symbol is edited from, or culled.
|
||||
fn skipped(&mut self, f: &Frame, a: &Arc<Node>) -> bool {
|
||||
let skipped_view_item = (f.opts.skip_templates || f.opts.hide_templates) && matches!(a.kind, NodeKind::Layer { template: true, .. })
|
||||
|| (f.opts.skip_templates || f.opts.hide_guides) && matches!(a.kind, NodeKind::Path { guide: true, .. });
|
||||
if !a.visible || skipped_view_item || f.opts.hidden.contains(&a.id) || Self::edited_instance(f, a.id) {
|
||||
if crate::node_checks::left_out(f.opts, f.doc, a) {
|
||||
return true;
|
||||
}
|
||||
match self.bounds_of(f.symbols, a) {
|
||||
@@ -635,11 +654,8 @@ impl Renderer {
|
||||
/// `fx_owner`, so its path, its live-effect geometry and, through [`Self::draw_kept_node`], a
|
||||
/// live object's evaluation come from the caches.
|
||||
fn draw_outline_member(&mut self, ctx: &mut RenderContext, f: &Frame, c: &Arc<Node>) {
|
||||
if !c.visible
|
||||
|| f.opts.hidden.contains(&c.id)
|
||||
|| f.opts.hide_guides && matches!(c.kind, NodeKind::Path { guide: true, .. })
|
||||
|| Self::edited_instance(f, c.id)
|
||||
{
|
||||
// Left out as any object is ([`Self::skipped`]).
|
||||
if crate::node_checks::left_out(f.opts, f.doc, c) {
|
||||
return;
|
||||
}
|
||||
match self.bounds_of(f.symbols, c) {
|
||||
|
||||
@@ -55,20 +55,32 @@ fn document() -> (Document, [NodeId; 5]) {
|
||||
}
|
||||
|
||||
/// Draws `new` again only where it changed from `old`, over `old` drawn whole, and checks that
|
||||
/// this is `new` drawn whole; returns what changed.
|
||||
/// this is `new` drawn whole; returns what changed. Measured afresh ([`changed`]) and by the
|
||||
/// renderer that drew `old`, which then draws the part ([`Renderer::changed`]): both must hold.
|
||||
fn redraw(old: &Document, new: &Document) -> Changed {
|
||||
let mut r = Renderer::new();
|
||||
let opts = RenderOptions::default();
|
||||
let before = r.render(old, W, H, view(), &opts);
|
||||
let after = r.render(new, W, H, view(), &opts);
|
||||
let after = Renderer::new().render(new, W, H, view(), &opts);
|
||||
let change = changed(old, new);
|
||||
let mut fresh = Renderer::new();
|
||||
let before = fresh.render(old, W, H, view(), &opts);
|
||||
patched(&mut fresh, &before, new, change, &after, &opts);
|
||||
let mut r = Renderer::new();
|
||||
let before = r.render(old, W, H, view(), &opts);
|
||||
let measured = r.changed(old, new, &opts);
|
||||
patched(&mut r, &before, new, measured, &after, &opts);
|
||||
change
|
||||
}
|
||||
|
||||
/// `before` with `new` drawn again by `r` with `opts` only where `change` says, checked against
|
||||
/// `after`.
|
||||
fn patched(r: &mut Renderer, before: &Rendered, new: &Document, change: Changed, after: &Rendered, opts: &RenderOptions) {
|
||||
let mut patched = Rendered { width: W, height: H, pixels: before.pixels.clone() };
|
||||
match change {
|
||||
Changed::Everything => patched.pixels = after.pixels.clone(),
|
||||
Changed::Nothing => {}
|
||||
Changed::Area(area) => {
|
||||
if let Some([x0, y0, x1, y1]) = view_pixels(area, view(), W, H) {
|
||||
let part = r.render(new, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view(), &opts);
|
||||
let part = r.render(new, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view(), opts);
|
||||
for y in 0..y1 - y0 {
|
||||
let (from, to) = ((y * (x1 - x0) * 4) as usize, (((y0 + y) * W + x0) * 4) as usize);
|
||||
let n = ((x1 - x0) * 4) as usize;
|
||||
@@ -78,7 +90,6 @@ fn redraw(old: &Document, new: &Document) -> Changed {
|
||||
}
|
||||
}
|
||||
assert!(patched.pixels == after.pixels, "{change:?}: drawn again only there, it differs from drawing it all");
|
||||
change
|
||||
}
|
||||
|
||||
fn area(c: Changed) -> Rect {
|
||||
@@ -421,3 +432,307 @@ fn a_path_painted_by_its_whole_shape_changes_whole_when_an_anchor_moves() {
|
||||
let a = area(redraw(&plain, &added));
|
||||
assert!(a.width() > 80.0 && a.height() > 60.0, "{a:?}");
|
||||
}
|
||||
|
||||
/// Changing a 3D effect's parameters on `make`'s object, from `before` to `after`.
|
||||
fn three_d_change(make: impl Fn(&mut Document) -> Node, id: &str, before: serde_json::Value, after: serde_json::Value) -> Changed {
|
||||
let effect = |params: serde_json::Value| vectorcraft_doc::Effect { id: id.into(), params, visible: true };
|
||||
let mut old = Document::new(110.0, 80.0);
|
||||
let l = old.layers[0].id;
|
||||
let mut n = make(&mut old);
|
||||
n.appearance.effects.push(effect(before));
|
||||
let n = old.insert(Some(l), 0, n).unwrap();
|
||||
let mut new = old.clone();
|
||||
new.node_mut(n).unwrap().appearance.effects[0] = effect(after);
|
||||
redraw(&old, &new)
|
||||
}
|
||||
|
||||
/// Type and a group draw a 3D effect as solids that reach past their letters and members: a
|
||||
/// shallower or turned solid changes where the deeper one drew too.
|
||||
#[test]
|
||||
fn a_3d_effect_changed_on_type_or_a_group_changes_where_its_solid_was() {
|
||||
use vectorcraft_doc::{CharStyle, TextObject};
|
||||
let blue = Color::rgb(0.1, 0.2, 0.6);
|
||||
let type_k = move |d: &mut Document| {
|
||||
let style = CharStyle { size: 30.0, fill: Paint::solid(blue), ..Default::default() };
|
||||
Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(40.0, 50.0), "K", style))))
|
||||
};
|
||||
let group = move |d: &mut Document| {
|
||||
let (a, b) = (rect(d, Rect::new(40.0, 30.0, 50.0, 50.0), blue), rect(d, Rect::new(52.0, 30.0, 60.0, 50.0), blue));
|
||||
Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)])
|
||||
};
|
||||
let at = |depth: f64| serde_json::json!({"depth": depth, "position": "isometricTop"});
|
||||
for make in [&type_k as &dyn Fn(&mut Document) -> Node, &group] {
|
||||
for (id, before, after) in [
|
||||
("threeD.extrude", at(60.0), at(5.0)),
|
||||
("threeD.inflate", at(40.0), at(2.0)),
|
||||
("threeD.rotate", serde_json::json!({"rotationX": 0.0}), serde_json::json!({"rotationX": 80.0})),
|
||||
] {
|
||||
// `redraw` holds the pixels drawn again only where it changed to drawing it all, and
|
||||
// only an area of the document is drawn again.
|
||||
area(three_d_change(make, id, before, after));
|
||||
}
|
||||
}
|
||||
// Type on a path, a group with a hidden member, and a group in a group.
|
||||
let on_path = move |d: &mut Document| {
|
||||
let style = CharStyle { size: 14.0, fill: Paint::solid(blue), ..Default::default() };
|
||||
let mut t = TextObject::point(Point::ZERO, "Along", style);
|
||||
let line = kurbo::Line::new((30.0, 55.0), (80.0, 35.0)).to_path(0.1);
|
||||
t.kind = vectorcraft_doc::TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&line), start: 0.0, end: None };
|
||||
t.xf = Affine::IDENTITY;
|
||||
t.cached_bounds = Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds);
|
||||
Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)))
|
||||
};
|
||||
let hiding = move |d: &mut Document| {
|
||||
let mut hidden = rect(d, Rect::new(10.0, 10.0, 20.0, 20.0), blue);
|
||||
hidden.visible = false;
|
||||
let shown = rect(d, Rect::new(40.0, 30.0, 60.0, 50.0), blue);
|
||||
Node::group(d.alloc_id(), vec![Arc::new(hidden), Arc::new(shown)])
|
||||
};
|
||||
let nested = move |d: &mut Document| {
|
||||
let inner = group(d);
|
||||
Node::group(d.alloc_id(), vec![Arc::new(inner)])
|
||||
};
|
||||
for make in [&on_path as &dyn Fn(&mut Document) -> Node, &hiding, &nested] {
|
||||
area(three_d_change(make, "threeD.extrude", at(60.0), at(5.0)));
|
||||
}
|
||||
// A revolved path, and revolved type, which Revolve leaves as it is.
|
||||
let path = move |d: &mut Document| rect(d, Rect::new(40.0, 30.0, 60.0, 50.0), blue);
|
||||
for make in [&path as &dyn Fn(&mut Document) -> Node, &type_k] {
|
||||
area(three_d_change(make, "threeD.revolve", serde_json::json!({"offset": 30.0}), serde_json::json!({"offset": 0.0})));
|
||||
}
|
||||
}
|
||||
|
||||
/// A renderer that drew a document measures what an edit changed through what it keeps
|
||||
/// ([`Renderer::changed`]): the same area as [`changed`], and the 3D art it made to measure the
|
||||
/// edit it draws the edit with, making none again. [`changed`] makes its own, and the renderer
|
||||
/// makes the art again to draw it.
|
||||
#[test]
|
||||
fn a_renderer_measures_an_edit_with_the_3d_art_it_draws() {
|
||||
use vectorcraft_doc::{CharStyle, TextObject};
|
||||
let mut old = Document::new(110.0, 80.0);
|
||||
let style = CharStyle { size: 30.0, fill: Paint::solid(Color::rgb(0.1, 0.2, 0.6)), ..Default::default() };
|
||||
let mut n = Node::new(old.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(40.0, 50.0), "K", style))));
|
||||
let extrude = |depth: f64| vectorcraft_doc::Effect {
|
||||
id: "threeD.extrude".into(),
|
||||
params: serde_json::json!({"depth": depth, "position": "isometricTop"}),
|
||||
visible: true,
|
||||
};
|
||||
n.appearance.effects.push(extrude(60.0));
|
||||
let l = old.layers[0].id;
|
||||
let id = old.insert(Some(l), 0, n).unwrap();
|
||||
let mut new = old.clone();
|
||||
new.node_mut(id).unwrap().appearance.effects[0] = extrude(5.0);
|
||||
let opts = RenderOptions::default();
|
||||
let _fonts = crate::tests_fontchange::fonts_steady();
|
||||
let (measured, built, apart) = crate::tests_fontchange::with_fonts_unchanged(|| {
|
||||
let mut r = Renderer::new();
|
||||
r.render(&old, W, H, view(), &opts);
|
||||
let measured = area(r.changed(&old, &new, &opts));
|
||||
r.render(&new, W, H, view(), &opts);
|
||||
let mut fresh = Renderer::new();
|
||||
fresh.render(&old, W, H, view(), &opts);
|
||||
let _ = changed(&old, &new);
|
||||
fresh.render(&new, W, H, view(), &opts);
|
||||
(measured, r.stats.three_d_built, fresh.stats.three_d_built)
|
||||
});
|
||||
assert_eq!(measured, area(changed(&old, &new)), "as changed measures it");
|
||||
assert_eq!(built, 0, "drawn with the art made to measure it");
|
||||
assert_eq!(apart, 1, "changed keeps nothing for the renderer");
|
||||
}
|
||||
|
||||
/// An Image Trace object shown with its source image (`view`): its hidden source, a red image at
|
||||
/// the left, and two traced shapes, at the right; the object's id, and the ids of the source and
|
||||
/// of the second shape.
|
||||
fn traced(view: &str) -> (Document, NodeId, NodeId, NodeId) {
|
||||
let mut d = Document::new(W as f64, H as f64);
|
||||
let mut png = vec![];
|
||||
let red = image::RgbaImage::from_pixel(1, 1, image::Rgba([255, 0, 0, 255]));
|
||||
red.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
d.images.insert("red".into(), vectorcraft_doc::ImageBlob::new("image/png", png));
|
||||
let source = red_image(&mut d, 10.0);
|
||||
let a = rect(&mut d, Rect::new(60.0, 10.0, 70.0, 20.0), Color::rgb(0.0, 0.5, 0.0));
|
||||
let b = rect(&mut d, Rect::new(80.0, 10.0, 90.0, 20.0), Color::rgb(1.0, 0.5, 0.0));
|
||||
let ids = (source.id, b.id);
|
||||
let mut g = Node::group(d.alloc_id(), vec![Arc::new(source), Arc::new(a), Arc::new(b)]);
|
||||
g.extras_mut().trace = Some(Box::new(serde_json::json!({ "view": view })));
|
||||
let l = d.layers[0].id;
|
||||
let g = d.insert(Some(l), 0, g).unwrap();
|
||||
(d, g, ids.0, ids.1)
|
||||
}
|
||||
|
||||
/// A hidden 30 pt square of the red image at (`x`, 10), as an Image Trace object's source.
|
||||
fn red_image(d: &mut Document, x: f64) -> Node {
|
||||
let xf = Affine::translate((x, 10.0)) * Affine::scale(30.0);
|
||||
let im = vectorcraft_doc::ImageObject { key: "red".into(), width: 1, height: 1, xf, link: None, placement: Default::default(), pixels: None };
|
||||
let mut source = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
|
||||
source.visible = false;
|
||||
source
|
||||
}
|
||||
|
||||
/// Draws `new` again only where the renderer that drew `old` on screen, its Image Trace objects in
|
||||
/// their views, measures that it changed, and checks that this is `new` drawn whole.
|
||||
fn redraw_traced(old: &Document, new: &Document) -> Changed {
|
||||
let opts = RenderOptions { trace_views: true, ..Default::default() };
|
||||
let after = Renderer::new().render(new, W, H, view(), &opts);
|
||||
let mut r = Renderer::new();
|
||||
let before = r.render(old, W, H, view(), &opts);
|
||||
let red = |r: &Rendered| r.pixels.chunks(4).any(|p| p[0] > 200 && p[1] < 60);
|
||||
assert!(red(&before) || red(&after), "a source shows");
|
||||
let measured = r.changed(old, new, &opts);
|
||||
patched(&mut r, &before, new, measured, &after, &opts);
|
||||
measured
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_trace_objects_source_changes_where_it_shows() {
|
||||
for view in ["sourceImage", "outlinesWithSourceImage"] {
|
||||
let (old, _, source, _) = traced(view);
|
||||
let mut new = old.clone();
|
||||
new.node_mut(source).unwrap().transform(Affine::translate((5.0, 30.0)), false);
|
||||
let a = area(redraw_traced(&old, &new));
|
||||
assert!(a.contains(Point::new(12.0, 12.0)) && a.contains(Point::new(44.0, 69.0)), "{view}: before and after: {a:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A trace object's source that is first again after an edit (undoing Send to Back on a traced
|
||||
/// shape), or a new source in its place, draws where it shows.
|
||||
#[test]
|
||||
fn a_trace_objects_new_first_member_is_its_source() {
|
||||
let (back, g, source, shape) = traced("sourceImage");
|
||||
// The shape sent to the back, before the source.
|
||||
let mut sent = back.clone();
|
||||
let children = sent.node_mut(g).unwrap().children_mut().unwrap();
|
||||
let at = children.iter().position(|c| c.id == shape).unwrap();
|
||||
let moved = children.remove(at);
|
||||
children.insert(0, moved);
|
||||
redraw_traced(&sent, &back);
|
||||
// A new source image, with an id of its own.
|
||||
let mut replaced = back.clone();
|
||||
let new_source = red_image(&mut replaced, 55.0);
|
||||
let children = replaced.node_mut(g).unwrap().children_mut().unwrap();
|
||||
assert_eq!(children[0].id, source);
|
||||
children[0] = Arc::new(new_source);
|
||||
redraw_traced(&back, &replaced);
|
||||
}
|
||||
|
||||
/// Recoloring a traced shape changes only the shape, as the free measure and the renderer that drew
|
||||
/// the trace object in its view measure it. Through the renderer the area is bounded, not drawn
|
||||
/// again and compared: in this view (outlines with the source image) a partial render of the
|
||||
/// outlines differs from the whole by one in alpha on a few dozen hairline pixels, either way,
|
||||
/// which is older than this test and has nothing to do with what changed (the recolor changes no
|
||||
/// pixel in this view).
|
||||
#[test]
|
||||
fn recolouring_a_traced_shape_changes_only_the_shape() {
|
||||
let (old, _, _, shape) = traced("outlinesWithSourceImage");
|
||||
let mut new = old.clone();
|
||||
new.node_mut(shape).unwrap().appearance.fill_mut().unwrap().paint = Paint::solid(Color::rgb(0.0, 0.0, 1.0));
|
||||
// The shape, half its 2 pt stroke and a pixel's anti-aliasing, at most: measured by the
|
||||
// renderer that drew the trace object in its view, and by the free measure.
|
||||
let opts = RenderOptions { trace_views: true, ..Default::default() };
|
||||
let mut r = Renderer::new();
|
||||
r.render(&old, W, H, view(), &opts);
|
||||
let measured = area(r.changed(&old, &new, &opts));
|
||||
let shape = Rect::new(80.0, 10.0, 90.0, 20.0).inflate(2.0, 2.0);
|
||||
for (what, a) in [("measured", measured), ("free", area(redraw(&old, &new)))] {
|
||||
assert!(a.union(shape) == shape, "{what}: only the shape: {a:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Templates and guides that the view hides draw nothing: an edit to them changes nothing.
|
||||
#[test]
|
||||
fn an_edit_to_what_the_view_hides_changes_nothing() {
|
||||
let mut old = Document::new(W as f64, H as f64);
|
||||
let template = Node::layer(old.alloc_id(), "Template", vectorcraft_doc::LayerColor::Preset(1));
|
||||
let template = old.insert(None, usize::MAX, template).unwrap();
|
||||
if let Some(NodeKind::Layer { template: t, .. }) = old.node_mut(template).map(|n| &mut n.kind) {
|
||||
*t = true;
|
||||
}
|
||||
let traced = rect(&mut old, Rect::new(10.0, 10.0, 40.0, 40.0), Color::rgb(0.5, 0.5, 0.5));
|
||||
let traced = old.insert(Some(template), 0, traced).unwrap();
|
||||
let mut guide = rect(&mut old, Rect::new(60.0, 10.0, 90.0, 40.0), Color::BLACK);
|
||||
if let NodeKind::Path { guide: g, .. } = &mut guide.kind {
|
||||
*g = true;
|
||||
}
|
||||
let l = old.layers[0].id;
|
||||
let guide = old.insert(Some(l), 0, guide).unwrap();
|
||||
let mut new = old.clone();
|
||||
for id in [traced, guide] {
|
||||
new.node_mut(id).unwrap().transform(Affine::translate((5.0, 5.0)), false);
|
||||
}
|
||||
let opts = RenderOptions { hide_templates: true, hide_guides: true, ..Default::default() };
|
||||
let mut r = Renderer::new();
|
||||
r.render(&old, W, H, view(), &opts);
|
||||
assert_eq!(r.changed(&old, &new, &opts), Changed::Nothing);
|
||||
assert!(matches!(changed(&old, &new), Changed::Area(_)), "shown, they change");
|
||||
}
|
||||
|
||||
/// Overprint Preview and separations draw objects through the inks they print, not through the
|
||||
/// art a renderer keeps for them: a renderer measures an edit there holding none of the 3D art it
|
||||
/// made to measure it, and as [`changed`] does.
|
||||
#[test]
|
||||
fn an_edit_measured_under_overprint_holds_no_art() {
|
||||
use vectorcraft_doc::{CharStyle, TextObject};
|
||||
let mut old = Document::new(110.0, 80.0);
|
||||
let style = CharStyle { size: 30.0, fill: Paint::solid(Color::rgb(0.1, 0.2, 0.6)), ..Default::default() };
|
||||
let mut n = Node::new(old.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(40.0, 50.0), "K", style))));
|
||||
let extrude = |depth: f64| vectorcraft_doc::Effect {
|
||||
id: "threeD.extrude".into(),
|
||||
params: serde_json::json!({"depth": depth, "position": "isometricTop"}),
|
||||
visible: true,
|
||||
};
|
||||
n.appearance.effects.push(extrude(60.0));
|
||||
let l = old.layers[0].id;
|
||||
let id = old.insert(Some(l), 0, n).unwrap();
|
||||
let mut new = old.clone();
|
||||
new.node_mut(id).unwrap().appearance.effects[0] = extrude(5.0);
|
||||
let _fonts = crate::tests_fontchange::fonts_steady();
|
||||
let held = |opts: &RenderOptions| {
|
||||
crate::tests_fontchange::with_fonts_unchanged(|| {
|
||||
let mut r = Renderer::new();
|
||||
r.render(&old, W, H, view(), opts);
|
||||
(area(r.changed(&old, &new, opts)), r.measured_bytes())
|
||||
})
|
||||
};
|
||||
let (_, on_screen) = held(&RenderOptions::default());
|
||||
assert!(on_screen > 0, "the screen view holds the art it measured with");
|
||||
let (measured, overprinted) = held(&RenderOptions { overprint_preview: true, ..Default::default() });
|
||||
assert_eq!(overprinted, 0);
|
||||
// A consistency check: the area itself doesn't tell the branch taken.
|
||||
assert_eq!(measured, area(changed(&old, &new)), "as changed measures it");
|
||||
}
|
||||
|
||||
/// Symbol editing mode on screen doesn't draw the instance it was entered from: an edit to that
|
||||
/// instance changes nothing there, and changes where it draws in an export.
|
||||
#[test]
|
||||
fn an_edit_to_the_instance_symbol_editing_came_from_changes_nothing_on_screen() {
|
||||
use vectorcraft_doc::{Symbol, SymbolEdit};
|
||||
let mut old = Document::new(W as f64, H as f64);
|
||||
let art = rect(&mut old, Rect::new(20.0, 20.0, 50.0, 50.0), Color::rgb(0.0, 0.5, 0.0));
|
||||
old.symbols.push(Symbol { dynamic: false, name: "Square".into(), art: Arc::new(art.clone()) });
|
||||
let inst = Node::new(old.alloc_id(), NodeKind::SymbolInstance { symbol: "Square".into(), xf: Affine::IDENTITY });
|
||||
let l = old.layers[0].id;
|
||||
let inst = old.insert(Some(l), 0, inst).unwrap();
|
||||
let layer = old.add_layer(Some("Square"));
|
||||
let copy = old.reid(&art);
|
||||
old.insert(Some(layer), usize::MAX, copy).unwrap();
|
||||
old.symbol_edit = Some(SymbolEdit {
|
||||
symbol: "Square".into(),
|
||||
layer,
|
||||
instance: Some(inst),
|
||||
xf: Affine::IDENTITY,
|
||||
group: false,
|
||||
changed: false,
|
||||
back_isolation: None,
|
||||
back_layer: None,
|
||||
});
|
||||
let mut new = old.clone();
|
||||
new.node_mut(inst).unwrap().transform(Affine::translate((30.0, 30.0)), false);
|
||||
let screen = RenderOptions { isolated: Some(layer), ..Default::default() };
|
||||
let mut r = Renderer::new();
|
||||
r.render(&old, W, H, view(), &screen);
|
||||
assert_eq!(r.changed(&old, &new, &screen), Changed::Nothing);
|
||||
let export = RenderOptions::default();
|
||||
let mut r = Renderer::new();
|
||||
r.render(&old, W, H, view(), &export);
|
||||
assert!(matches!(r.changed(&old, &new, &export), Changed::Area(_)), "an export draws it");
|
||||
}
|
||||
|
||||
@@ -371,3 +371,34 @@ fn a_renderer_maps_enveloped_type_from_its_glyphs_in_the_current_font() {
|
||||
assert!(off(before, after) > 0.5, "the type draws in the font that arrived: {before:?} vs {after:?}");
|
||||
assert!(off(src(), after) < 1e-6, "{:?} vs {after:?}", src());
|
||||
}
|
||||
|
||||
/// 3D type measured for an edit ([`Renderer::changed`]) and drawn after its font became available
|
||||
/// is drawn in that font: the art made to measure it, in the font it had, isn't taken.
|
||||
#[test]
|
||||
fn three_d_type_measured_before_its_font_arrives_draws_in_it() {
|
||||
let _adding = adding_fonts();
|
||||
const FAMILY: &str = "Solid Serif 44";
|
||||
let mut old = Document::new(300.0, 150.0);
|
||||
let style = CharStyle { size: 60.0, font_family: FAMILY.into(), ..Default::default() };
|
||||
let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 100.0), "Hamburg", style))));
|
||||
n.id = old.alloc_id();
|
||||
let extrude = |depth: f64| vectorcraft_doc::Effect {
|
||||
id: "threeD.extrude".into(),
|
||||
params: serde_json::json!({"depth": depth, "position": "isometricTop"}),
|
||||
visible: true,
|
||||
};
|
||||
n.appearance.effects.push(extrude(40.0));
|
||||
let l = old.layers[0].id;
|
||||
let id = old.insert(Some(l), 0, n).unwrap();
|
||||
let mut new = old.clone();
|
||||
new.node_mut(id).unwrap().appearance.effects[0] = extrude(10.0);
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let mut r = Renderer::new();
|
||||
r.render(&old, 300, 150, Affine::IDENTITY, &opts);
|
||||
let _ = r.changed(&old, &new, &opts);
|
||||
assert!(vectorcraft_text::FontDb::global().add_font(serif_as(FAMILY)) > 0);
|
||||
let drawn = r.render(&new, 300, 150, Affine::IDENTITY, &opts).pixels;
|
||||
let fresh = Renderer::new().render(&new, 300, 150, Affine::IDENTITY, &opts).pixels;
|
||||
let differ = drawn.iter().zip(&fresh).filter(|(a, b)| a != b).count();
|
||||
assert_eq!(differ, 0, "as a new renderer draws it");
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ pub struct CanvasCache {
|
||||
pub key: Option<CacheKey>,
|
||||
/// The document the frame shown was rendered from, and the options it was rendered with, with
|
||||
/// [`Self::key`]: when only the document changes, the canvas renders again only where it
|
||||
/// changed ([`vectorcraft_render::changed`]).
|
||||
/// changed, measured by [`Self::renderer`], which drew it ([`vectorcraft_render::Renderer::changed`]).
|
||||
pub shown: Option<(std::sync::Arc<vectorcraft_doc::Document>, vectorcraft_render::RenderOptions)>,
|
||||
pub last_ms: f64,
|
||||
/// The frame shown drew raster effects from rasters made before their objects moved, or at a
|
||||
|
||||
@@ -739,6 +739,10 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
|
||||
// rendered again, here, into the texture. `last_ms` stays the whole frame's.
|
||||
(_, _, Some(region)) => {
|
||||
let t0 = now_ms();
|
||||
if region.is_none() {
|
||||
// Nothing in view to draw again: what was made to measure the edit isn't drawn.
|
||||
app.canvas.renderer.let_go_of_measured();
|
||||
}
|
||||
if let (Some([x0, y0, x1, y1]), Some(tex)) = (region, &mut app.canvas.texture) {
|
||||
let part = app.canvas.renderer.render(doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts);
|
||||
let color = color_image(&part);
|
||||
@@ -751,6 +755,9 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
|
||||
app.perf.render_ms = now_ms() - t0;
|
||||
}
|
||||
(Some(worker), true, None) => {
|
||||
// The worker draws the frame: what this thread's renderer made to measure the edit
|
||||
// isn't drawn here.
|
||||
app.canvas.renderer.let_go_of_measured();
|
||||
let queued = worker.submit(crate::render_worker::Job {
|
||||
key: key.clone(),
|
||||
doc: doc.clone(),
|
||||
|
||||
@@ -35,8 +35,10 @@ pub(crate) fn color_image_owned(img: vectorcraft_render::Rendered) -> egui::Colo
|
||||
/// objects showing those bitmaps' files cover (`Some(None)`: none), grown to whole eighths of the
|
||||
/// frame, as long as they are at most half of it. None: render the whole frame. Frames the worker
|
||||
/// renders stay whole: its renderer keeps the rasters of the art's effects, this thread's doesn't.
|
||||
/// What changed is measured by this thread's renderer, which drew the frame shown: it keeps what
|
||||
/// it measures (a 3D effect's art) to draw the part again.
|
||||
pub(super) fn changed_pixels(
|
||||
app: &VectorcraftApp,
|
||||
app: &mut VectorcraftApp,
|
||||
key: &CacheKey,
|
||||
doc: &vectorcraft_doc::Document,
|
||||
opts: &vectorcraft_render::RenderOptions,
|
||||
@@ -60,7 +62,7 @@ pub(super) fn changed_pixels(
|
||||
} else {
|
||||
vectorcraft_render::placed_area(doc, &vectorcraft_render::placed_document::made_between(shown_key.placed, key.placed)?)
|
||||
};
|
||||
match vectorcraft_render::changed(shown, doc).and(placed) {
|
||||
match app.canvas.renderer.changed(shown, doc, opts).and(placed) {
|
||||
vectorcraft_render::Changed::Nothing => Some(None),
|
||||
vectorcraft_render::Changed::Area(area) => match vectorcraft_render::view_pixels(area, view, w, h) {
|
||||
None => Some(None),
|
||||
|
||||
@@ -10730,6 +10730,73 @@ fn direct_selection_shows_the_bounding_box_while_cmd_is_held() {
|
||||
assert!(app.ui.view.bounding_box, "View › Show Bounding Box is unchanged");
|
||||
}
|
||||
|
||||
/// An edit to a 3D effect renders the part it changed on this thread, measured by the renderer
|
||||
/// that drew the frame shown ([`vectorcraft_render::Renderer::changed`]): it draws the part with
|
||||
/// the 3D art it made to measure it.
|
||||
#[test]
|
||||
fn an_edit_to_a_3d_effect_redraws_with_the_art_measured() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
app.canvas.worker_started = true; // render on this thread
|
||||
let id = app.run("shape.rectangle", json!({"x": 380, "y": 280, "width": 40, "height": 40})).unwrap()["id"].clone();
|
||||
app.run("effect.apply", json!({"effect": "threeD.extrude", "ids": [id], "params": {"depth": 60.0}})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
shapes(&mut app, &ctx);
|
||||
assert_eq!(app.canvas.renderer.stats.three_d_built, 1, "the whole frame made the art");
|
||||
app.run("effect.setParams", json!({"ids": [id], "index": 0, "item": null, "params": {"depth": 5.0}})).unwrap();
|
||||
// Drawing only a part keeps the whole frame's time; drawing it whole measures it again.
|
||||
app.canvas.last_ms = 7.25;
|
||||
shapes(&mut app, &ctx);
|
||||
assert!(!app.canvas.from_worker);
|
||||
assert_eq!(app.canvas.last_ms, 7.25, "only the part that changed was drawn");
|
||||
assert_eq!(app.canvas.renderer.stats.three_d_built, 0, "the part was drawn with the art made to measure the edit");
|
||||
}
|
||||
|
||||
/// An edit to a 3D effect off the view, and one the render worker draws, leave none of the art made
|
||||
/// to measure them held by this thread's renderer.
|
||||
#[test]
|
||||
fn art_measured_for_an_edit_not_drawn_here_is_let_go() {
|
||||
for worker in [false, true] {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
app.canvas.worker_started = true; // render on this thread
|
||||
// Off the view, or filling it, for the worker to draw.
|
||||
let (x, size) = if worker { (0, 800) } else { (5000, 40) };
|
||||
let id = app.run("shape.rectangle", json!({"x": x, "y": 0, "width": size, "height": size})).unwrap()["id"].clone();
|
||||
app.run("effect.apply", json!({"effect": "threeD.extrude", "ids": [id], "params": {"depth": 60.0}})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
shapes(&mut app, &ctx);
|
||||
// A worker whose jobs the test receives: none finishes unless the test says so.
|
||||
let mut jobs = None;
|
||||
if worker {
|
||||
let (w, rx, done) = crate::render_worker::Worker::test_channel();
|
||||
app.canvas.worker = Some(w);
|
||||
jobs = Some((rx, done));
|
||||
app.canvas.last_ms = 100.0;
|
||||
}
|
||||
app.run("effect.setParams", json!({"ids": [id], "index": 0, "item": null, "params": {"depth": 5.0}})).unwrap();
|
||||
// Measured through the canvas's renderer, the edit holds the art made for it.
|
||||
let (shown, opts) = app.canvas.shown.clone().unwrap();
|
||||
let now = app.session.active().unwrap().doc.clone();
|
||||
app.canvas.renderer.changed(&shown, &now, &opts);
|
||||
assert!(app.canvas.renderer.measured_bytes() > 0, "worker {worker}: art held");
|
||||
if !worker {
|
||||
app.canvas.last_ms = 7.25;
|
||||
}
|
||||
shapes(&mut app, &ctx);
|
||||
if !worker {
|
||||
// Nothing in view to draw: no frame, whole or part, began since the measure.
|
||||
assert_eq!(app.canvas.last_ms, 7.25, "not drawn whole");
|
||||
assert!(app.canvas.renderer.stats.three_d_built > 0, "no part drawn");
|
||||
}
|
||||
if let Some((rx, _)) = &jobs {
|
||||
assert!(rx.try_recv().is_ok(), "the worker draws it");
|
||||
}
|
||||
assert_eq!(app.canvas.renderer.measured_bytes(), 0, "worker {worker}");
|
||||
app.canvas.worker = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The real canvas decision must stay visible without a native host, with no painted glyph.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
|
||||
@@ -5854,6 +5854,11 @@ digits Malayalam Malaiàlam
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? paràmetre desconegut `{key}`; volies dir `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters paràmetre desconegut `{key}`; l’ordre no admet cap paràmetre
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} paràmetre desconegut `{key}`; l’ordre admet: {keys}
|
||||
@msg the params must be an object, not {got} els paràmetres han de ser un objecte, no {got}
|
||||
@msg unknown parameter `{path}`; the object takes no keys paràmetre desconegut `{path}`; l’objecte no admet claus
|
||||
@msg unknown parameter `{path}`; it takes: {keys} paràmetre desconegut `{path}`; admet: {keys}
|
||||
@msg missing `{path}` falta `{path}`
|
||||
@msg `{path}` must be {expected}, not {got} `{path}` ha de ser {expected}, no {got}
|
||||
@msg unknown preference `{key}` preferència desconeguda `{key}`
|
||||
@msg unknown preset (one of {_1}) preajust desconegut (un de {_1})
|
||||
@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) preajust desconegut `{name}` (high, medium, low o un de desat: consulta flattener.presets.list)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5595,6 +5595,11 @@ mark-style Japanese Japonské
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? neznámý parametr `{key}`; mysleli jste `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters neznámý parametr `{key}`; příkaz nemá žádné parametry
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} neznámý parametr `{key}`; příkaz přijímá: {keys}
|
||||
@msg the params must be an object, not {got} parametry musí být objekt, nikoli {got}
|
||||
@msg unknown parameter `{path}`; the object takes no keys neznámý parametr `{path}`; objekt nepřijímá žádné klíče
|
||||
@msg unknown parameter `{path}`; it takes: {keys} neznámý parametr `{path}`; přijímá: {keys}
|
||||
@msg missing `{path}` chybí `{path}`
|
||||
@msg `{path}` must be {expected}, not {got} `{path}` musí být {expected}, nikoli {got}
|
||||
Load EPS into Clipboard Načíst EPS do schránky
|
||||
@msg an image of {w} × {h} pixels is too large to draw obraz o velikosti {w} × {h} obrazových bodů je příliš velký na vykreslení
|
||||
@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: není obraz PNG, JPEG, WebP, TIFF ani BMP
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5162,6 +5162,11 @@ lighting Light Licht
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? unbekannter Parameter `{key}`; meinten Sie `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters unbekannter Parameter `{key}`; der Befehl hat keine Parameter
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} unbekannter Parameter `{key}`; der Befehl akzeptiert: {keys}
|
||||
@msg the params must be an object, not {got} die Parameter müssen ein Objekt sein, nicht {got}
|
||||
@msg unknown parameter `{path}`; the object takes no keys unbekannter Parameter `{path}`; das Objekt akzeptiert keine Schlüssel
|
||||
@msg unknown parameter `{path}`; it takes: {keys} unbekannter Parameter `{path}`; akzeptiert werden: {keys}
|
||||
@msg missing `{path}` `{path}` fehlt
|
||||
@msg `{path}` must be {expected}, not {got} `{path}` muss {expected} sein, nicht {got}
|
||||
Load EPS into Clipboard EPS in die Zwischenablage laden
|
||||
@msg an image of {w} × {h} pixels is too large to draw ein Bild mit {w} × {h} Pixeln ist zu groß, um gezeichnet zu werden
|
||||
@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: kein PNG-, JPEG-, WebP-, TIFF- oder BMP-Bild
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4956,6 +4956,11 @@ digits Malayalam Μαλαγιάλαμ
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? άγνωστη παράμετρος `{key}`· μήπως εννοείτε `{near}`;
|
||||
@msg unknown parameter `{key}`; the command takes no parameters άγνωστη παράμετρος `{key}`· η εντολή δεν δέχεται παραμέτρους
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} άγνωστη παράμετρος `{key}`· η εντολή δέχεται: {keys}
|
||||
@msg the params must be an object, not {got} οι παράμετροι πρέπει να είναι αντικείμενο, όχι {got}
|
||||
@msg unknown parameter `{path}`; the object takes no keys άγνωστη παράμετρος `{path}`· το αντικείμενο δεν δέχεται κλειδιά
|
||||
@msg unknown parameter `{path}`; it takes: {keys} άγνωστη παράμετρος `{path}`· δέχεται: {keys}
|
||||
@msg missing `{path}` λείπει το `{path}`
|
||||
@msg `{path}` must be {expected}, not {got} Το `{path}` πρέπει να είναι {expected}, όχι {got}
|
||||
1 Layer 1 επίπεδο
|
||||
1 bit 1 bit
|
||||
16 Colors 16 χρώματα
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -5536,6 +5536,11 @@ mark-style Japanese Japonesas
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? parámetro desconocido `{key}`; ¿quiso decir `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters parámetro desconocido `{key}`; el comando no tiene parámetros
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} parámetro desconocido `{key}`; el comando acepta: {keys}
|
||||
@msg the params must be an object, not {got} los parámetros deben ser un objeto, no {got}
|
||||
@msg unknown parameter `{path}`; the object takes no keys parámetro desconocido `{path}`; el objeto no acepta claves
|
||||
@msg unknown parameter `{path}`; it takes: {keys} parámetro desconocido `{path}`; acepta: {keys}
|
||||
@msg missing `{path}` falta `{path}`
|
||||
@msg `{path}` must be {expected}, not {got} `{path}` debe ser {expected}, no {got}
|
||||
Load EPS into Clipboard Cargar EPS en el portapapeles
|
||||
@msg an image of {w} × {h} pixels is too large to draw una imagen de {w} × {h} píxeles es demasiado grande para dibujarse
|
||||
@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: no es una imagen PNG, JPEG, WebP, TIFF ni BMP
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5479,6 +5479,11 @@ mark-style Japanese Japanilainen
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? tuntematon parametri `{key}`; tarkoititko `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters tuntematon parametri `{key}`; komento ei ota parametreja
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} tuntematon parametri `{key}`; komennon parametrit: {keys}
|
||||
@msg the params must be an object, not {got} parametrien on oltava objekti, ei {got}
|
||||
@msg unknown parameter `{path}`; the object takes no keys tuntematon parametri `{path}`; objekti ei ota avaimia
|
||||
@msg unknown parameter `{path}`; it takes: {keys} tuntematon parametri `{path}`; parametrit: {keys}
|
||||
@msg missing `{path}` `{path}` puuttuu
|
||||
@msg `{path}` must be {expected}, not {got} `{path}` on oltava {expected}, ei {got}
|
||||
Load EPS into Clipboard Lataa EPS leikepöydälle
|
||||
@msg an image of {w} × {h} pixels is too large to draw kuva, jonka koko on {w} × {h} pikseliä, on liian suuri piirrettäväksi
|
||||
@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: ei ole PNG-, JPEG-, WebP-, TIFF- tai BMP-kuva
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5166,6 +5166,11 @@ lighting Light Lumière
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? paramètre inconnu `{key}` ; vouliez-vous dire `{near}` ?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters paramètre inconnu `{key}` ; la commande n’a aucun paramètre
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} paramètre inconnu `{key}` ; la commande accepte : {keys}
|
||||
@msg the params must be an object, not {got} les paramètres doivent être un objet, pas {got}
|
||||
@msg unknown parameter `{path}`; the object takes no keys paramètre inconnu `{path}` ; l’objet n’accepte aucune clé
|
||||
@msg unknown parameter `{path}`; it takes: {keys} paramètre inconnu `{path}` ; il accepte : {keys}
|
||||
@msg missing `{path}` `{path}` manquant
|
||||
@msg `{path}` must be {expected}, not {got} `{path}` doit être {expected}, pas {got}
|
||||
1 Layer 1 calque
|
||||
1 bit 1 bit
|
||||
16 Colors 16 couleurs
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -7794,6 +7794,11 @@ antiAlias Sharp शार्प
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? अज्ञात पैरामीटर `{key}`; क्या आपको `{near}` का मतलब है?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters अज्ञात पैरामीटर `{key}`; कमांड कोई पैरामीटर नहीं लेता है
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} अज्ञात पैरामीटर `{key}`; कमांड लेता है: {keys}
|
||||
@msg the params must be an object, not {got} पैरामीटर एक ऑब्जेक्ट होने चाहिए, {got} नहीं
|
||||
@msg unknown parameter `{path}`; the object takes no keys अज्ञात पैरामीटर `{path}`; ऑब्जेक्ट कोई कुंजी नहीं लेता
|
||||
@msg unknown parameter `{path}`; it takes: {keys} अज्ञात पैरामीटर `{path}`; यह लेता है: {keys}
|
||||
@msg missing `{path}` लापता `{path}`
|
||||
@msg `{path}` must be {expected}, not {got} `{path}` {expected} होना चाहिए, {got} नहीं
|
||||
@msg unknown PDF preset `{name}` (presets: {_1}) अज्ञात PDF प्रीसेट `{name}` (presets: {_1})
|
||||
@msg unknown pieLegend `{v}` (none, standard or wedges) अज्ञात pieLegend `{v}` (one, मानक या wedges)
|
||||
@msg unknown piePosition `{v}` (even, ratio or stacked) अज्ञात piePosition `{v}` (यहां तक कि अनुपात या स्टैक्ड)
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -5578,6 +5578,11 @@ imageTrace Outline Körvonal
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? ismeretlen paraméter: `{key}`; erre gondolt: `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters ismeretlen paraméter: `{key}`; a parancsnak nincs paramétere
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} ismeretlen paraméter: `{key}`; a parancs ezeket fogadja el: {keys}
|
||||
@msg the params must be an object, not {got} a paramétereknek objektumnak kell lenniük, nem ez: {got}
|
||||
@msg unknown parameter `{path}`; the object takes no keys ismeretlen paraméter: `{path}`; az objektum nem fogad el kulcsokat
|
||||
@msg unknown parameter `{path}`; it takes: {keys} ismeretlen paraméter: `{path}`; ezeket fogadja el: {keys}
|
||||
@msg missing `{path}` hiányzik: `{path}`
|
||||
@msg `{path}` must be {expected}, not {got} `{path}`: {expected} kell, nem ez: {got}
|
||||
Load EPS into Clipboard EPS betöltése a vágólapra
|
||||
@msg an image of {w} × {h} pixels is too large to draw egy {w} × {h} pixeles kép túl nagy a kirajzoláshoz
|
||||
@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: nem PNG, JPEG, WebP, TIFF vagy BMP kép
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5204,6 +5204,11 @@ lighting Light Luce
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? parametro sconosciuto `{key}`; forse intendevate `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters parametro sconosciuto `{key}`; il comando non ha parametri
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} parametro sconosciuto `{key}`; il comando accetta: {keys}
|
||||
@msg the params must be an object, not {got} i parametri devono essere un oggetto, non {got}
|
||||
@msg unknown parameter `{path}`; the object takes no keys parametro sconosciuto `{path}`; l'oggetto non accetta chiavi
|
||||
@msg unknown parameter `{path}`; it takes: {keys} parametro sconosciuto `{path}`; accetta: {keys}
|
||||
@msg missing `{path}` manca `{path}`
|
||||
@msg `{path}` must be {expected}, not {got} `{path}` deve essere {expected}, non {got}
|
||||
Load EPS into Clipboard Carica EPS negli appunti
|
||||
@msg an image of {w} × {h} pixels is too large to draw un'immagine di {w} × {h} pixel è troppo grande per essere disegnata
|
||||
@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: non è un'immagine PNG, JPEG, WebP, TIFF o BMP
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5246,6 +5246,11 @@ lighting Light ライト
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? `{key}` は不明なパラメーターです。`{near}` のことですか?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters `{key}` は不明なパラメーターです。このコマンドにパラメーターはありません
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} `{key}` は不明なパラメーターです。このコマンドで使えるパラメーター: {keys}
|
||||
@msg the params must be an object, not {got} パラメーターはオブジェクトにしてください ({got} は使えません)
|
||||
@msg unknown parameter `{path}`; the object takes no keys `{path}` は不明なパラメーターです。このオブジェクトにはキーを指定できません
|
||||
@msg unknown parameter `{path}`; it takes: {keys} `{path}` は不明なパラメーターです。使えるパラメーター: {keys}
|
||||
@msg missing `{path}` `{path}` が指定されていません
|
||||
@msg `{path}` must be {expected}, not {got} `{path}` は {expected} にしてください ({got} は使えません)
|
||||
Load EPS into Clipboard EPS をクリップボードに読み込む
|
||||
@msg an image of {w} × {h} pixels is too large to draw {w} × {h} ピクセルの画像は大きすぎて描画できません
|
||||
@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: PNG、JPEG、WebP、TIFF、BMP のいずれの画像でもありません
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4967,6 +4967,11 @@ font-technology Variable ცვლადი
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? უცნობი პარამეტრი `{key}`; იქნებ `{near}` იგულისხმეთ?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters უცნობი პარამეტრი `{key}`; ბრძანება პარამეტრებს არ იღებს
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} უცნობი პარამეტრი `{key}`; ბრძანება იღებს: {keys}
|
||||
@msg the params must be an object, not {got} პარამეტრები უნდა იყოს ობიექტი და არა {got}
|
||||
@msg unknown parameter `{path}`; the object takes no keys უცნობი პარამეტრი `{path}`; ობიექტი გასაღებებს არ იღებს
|
||||
@msg unknown parameter `{path}`; it takes: {keys} უცნობი პარამეტრი `{path}`; იღებს: {keys}
|
||||
@msg missing `{path}` აკლია `{path}`
|
||||
@msg `{path}` must be {expected}, not {got} `{path}` უნდა იყოს {expected} და არა {got}
|
||||
file-filter All readable files ყველა წაკითხვადი ფაილი
|
||||
file-filter All placeable files ყველა განთავსებადი ფაილი
|
||||
file-filter Swatch libraries ფერის ნიმუშების ბიბლიოთეკები
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -5165,6 +5165,11 @@ lighting Light 광원
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? 알 수 없는 매개변수 `{key}`: `{near}`을(를) 의미하셨습니까?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters 알 수 없는 매개변수 `{key}`: 이 명령에는 매개변수가 없습니다
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} 알 수 없는 매개변수 `{key}`: 이 명령이 받는 매개변수: {keys}
|
||||
@msg the params must be an object, not {got} 매개변수는 객체여야 합니다({got} 사용 불가)
|
||||
@msg unknown parameter `{path}`; the object takes no keys 알 수 없는 매개변수 `{path}`: 이 객체는 키를 받지 않습니다
|
||||
@msg unknown parameter `{path}`; it takes: {keys} 알 수 없는 매개변수 `{path}`: 받는 매개변수: {keys}
|
||||
@msg missing `{path}` `{path}`이(가) 없습니다
|
||||
@msg `{path}` must be {expected}, not {got} `{path}`은(는) {expected}이어야 합니다({got} 사용 불가)
|
||||
Load EPS into Clipboard EPS를 클립보드로 불러오기
|
||||
@msg an image of {w} × {h} pixels is too large to draw {w} × {h}픽셀 이미지는 너무 커서 그릴 수 없습니다
|
||||
@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: PNG, JPEG, WebP, TIFF 또는 BMP 이미지가 아닙니다
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5434,6 +5434,11 @@ mark-style Japanese Japońskie
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? nieznany parametr `{key}`; czy chodziło o `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters nieznany parametr `{key}`; polecenie nie przyjmuje parametrów
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} nieznany parametr `{key}`; polecenie przyjmuje: {keys}
|
||||
@msg the params must be an object, not {got} parametry muszą być obiektem, a nie {got}
|
||||
@msg unknown parameter `{path}`; the object takes no keys nieznany parametr `{path}`; obiekt nie przyjmuje kluczy
|
||||
@msg unknown parameter `{path}`; it takes: {keys} nieznany parametr `{path}`; przyjmuje: {keys}
|
||||
@msg missing `{path}` brak `{path}`
|
||||
@msg `{path}` must be {expected}, not {got} `{path}` musi być {expected}, a nie {got}
|
||||
Load EPS into Clipboard Wczytaj EPS do schowka
|
||||
@msg an image of {w} × {h} pixels is too large to draw obraz o wymiarach {w} × {h} pikseli jest zbyt duży, aby go narysować
|
||||
@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: to nie jest obraz PNG, JPEG, WebP, TIFF ani BMP
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4505,6 +4505,11 @@ axis Both Ambos
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? parâmetro desconhecido `{key}`; você quis dizer `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters parâmetro desconhecido `{key}`; o comando não tem parâmetros
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} parâmetro desconhecido `{key}`; o comando aceita: {keys}
|
||||
@msg the params must be an object, not {got} os parâmetros devem ser um objeto, não {got}
|
||||
@msg unknown parameter `{path}`; the object takes no keys parâmetro desconhecido `{path}`; o objeto não aceita chaves
|
||||
@msg unknown parameter `{path}`; it takes: {keys} parâmetro desconhecido `{path}`; aceita: {keys}
|
||||
@msg missing `{path}` falta `{path}`
|
||||
@msg `{path}` must be {expected}, not {got} `{path}` deve ser {expected}, não {got}
|
||||
Load EPS into Clipboard Carregar EPS na Área de Transferência
|
||||
Drawer menu Menu da gaveta
|
||||
Toolbar: Show Controls Barra de ferramentas: Mostrar controles
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -5198,6 +5198,11 @@ lighting Light Свет
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? неизвестный параметр `{key}`; возможно, имелся в виду `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters неизвестный параметр `{key}`; у команды нет параметров
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} неизвестный параметр `{key}`; команда принимает: {keys}
|
||||
@msg the params must be an object, not {got} параметры должны быть объектом, а не {got}
|
||||
@msg unknown parameter `{path}`; the object takes no keys неизвестный параметр `{path}`; объект не принимает ключей
|
||||
@msg unknown parameter `{path}`; it takes: {keys} неизвестный параметр `{path}`; принимает: {keys}
|
||||
@msg missing `{path}` отсутствует `{path}`
|
||||
@msg `{path}` must be {expected}, not {got} `{path}` должно быть {expected}, а не {got}
|
||||
Load EPS into Clipboard Загрузить EPS в буфер обмена
|
||||
@msg an image of {w} × {h} pixels is too large to draw изображение размером {w} × {h} пикселей слишком велико для отрисовки
|
||||
@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: это не изображение PNG, JPEG, WebP, TIFF или BMP
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5070,6 +5070,11 @@ lighting Light Ljus
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? okänd parameter `{key}`; menade du `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters okänd parameter `{key}`; kommandot tar inga parametrar
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} okänd parameter `{key}`; kommandot tar: {keys}
|
||||
@msg the params must be an object, not {got} parametrarna måste vara ett objekt, inte {got}
|
||||
@msg unknown parameter `{path}`; the object takes no keys okänd parameter `{path}`; objektet tar inga nycklar
|
||||
@msg unknown parameter `{path}`; it takes: {keys} okänd parameter `{path}`; den tar: {keys}
|
||||
@msg missing `{path}` `{path}` saknas
|
||||
@msg `{path}` must be {expected}, not {got} `{path}` måste vara {expected}, inte {got}
|
||||
1 Layer 1 lager
|
||||
1 bit 1 bit
|
||||
16 Colors 16 färger
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -1998,6 +1998,47 @@ fn dataset_import_repeated_column_error_is_translated() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The errors of a command's parameter schema are translated in every language that translates
|
||||
/// every message, with the parameter's path and the refused value kept.
|
||||
#[test]
|
||||
fn parameter_schema_errors_are_translated() {
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
let scanned = message_literals();
|
||||
for template in [
|
||||
"the params must be an object, not {got}",
|
||||
"unknown parameter `{path}`; the object takes no keys",
|
||||
"unknown parameter `{path}`; it takes: {keys}",
|
||||
"missing `{path}`",
|
||||
"`{path}` must be {expected}, not {got}",
|
||||
] {
|
||||
assert!(scanned.contains(template), "{template}");
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let errors = [
|
||||
(s.execute("view.saved.new", &json!({"center": [0, 0], "zoom": -1})).unwrap_err().to_string(), "`zoom`", "must be", "-1"),
|
||||
(s.execute("view.saved.new", &json!({"center": [0, 0]})).unwrap_err().to_string(), "`zoom`", "missing", ""),
|
||||
(s.execute("view.saved.list", &json!(5)).unwrap_err().to_string(), "5", "must be", ""),
|
||||
];
|
||||
s.execute("view.saved.new", &json!({"name": "A", "center": [0, 0], "zoom": 1})).unwrap();
|
||||
let nested = [
|
||||
(s.execute("view.saved.edit", &json!({"edits": [{"name": "A"}, {"name": "A", "delete": 1}]})).unwrap_err().to_string(), "`edits[1].delete`"),
|
||||
(s.execute("view.saved.edit", &json!({"edits": [{"name": "A", "x": 1}]})).unwrap_err().to_string(), "`edits[0].x`"),
|
||||
];
|
||||
for code in COMPLETE_MESSAGES {
|
||||
let lang = Lang::from_code(code).expect("registered");
|
||||
for (e, kept, english, value) in &errors {
|
||||
let shown = message(lang, e);
|
||||
assert!(shown.contains(kept) && shown.contains(value) && !shown.contains(english), "{code}: {shown}");
|
||||
}
|
||||
for (e, kept) in &nested {
|
||||
let shown = message(lang, e);
|
||||
assert!(shown.contains(kept) && !shown.contains("unknown parameter") && !shown.contains("must be"), "{code}: {shown}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The warning for a PDF/X file that leaves out the document's bleed is translated in every catalog,
|
||||
/// alone and in the status bar's note.
|
||||
#[test]
|
||||
|
||||
@@ -4866,6 +4866,11 @@ lighting Light Işık
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? bilinmeyen parametre `{key}`; `{near}` mi demek istediniz?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters bilinmeyen parametre `{key}`; komut hiçbir parametre almaz
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} bilinmeyen parametre `{key}`; komut şunları alır: {keys}
|
||||
@msg the params must be an object, not {got} parametreler bir nesne olmalıdır, {got} değil
|
||||
@msg unknown parameter `{path}`; the object takes no keys bilinmeyen parametre `{path}`; nesne anahtar almaz
|
||||
@msg unknown parameter `{path}`; it takes: {keys} bilinmeyen parametre `{path}`; şunları alır: {keys}
|
||||
@msg missing `{path}` `{path}` eksik
|
||||
@msg `{path}` must be {expected}, not {got} `{path}` {expected} olmalıdır, {got} değil
|
||||
1 Layer 1 Katman
|
||||
1 bit 1 bit
|
||||
16 Colors 16 Renk
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -5184,6 +5184,11 @@ lighting Light Світло
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? невідомий параметр `{key}`; можливо, ви мали на увазі `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters невідомий параметр `{key}`; команда не має параметрів
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} невідомий параметр `{key}`; команда приймає: {keys}
|
||||
@msg the params must be an object, not {got} параметри мають бути об'єктом, а не {got}
|
||||
@msg unknown parameter `{path}`; the object takes no keys невідомий параметр `{path}`; об'єкт не приймає ключів
|
||||
@msg unknown parameter `{path}`; it takes: {keys} невідомий параметр `{path}`; приймає: {keys}
|
||||
@msg missing `{path}` відсутнє `{path}`
|
||||
@msg `{path}` must be {expected}, not {got} `{path}` має бути {expected}, а не {got}
|
||||
Load EPS into Clipboard Завантажити EPS у буфер обміну
|
||||
@msg an image of {w} × {h} pixels is too large to draw зображення розміром {w} × {h} пікселів завелике для відображення
|
||||
@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: це не зображення PNG, JPEG, WebP, TIFF чи BMP
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 182 and column 5.
|
@@ -5119,6 +5119,11 @@ theme Appearance Giao diện
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? tham số không xác định `{key}`; ý bạn là `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters tham số không xác định `{key}`; lệnh này không nhận tham số
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} tham số không xác định `{key}`; lệnh nhận: {keys}
|
||||
@msg the params must be an object, not {got} các tham số phải là một đối tượng, không phải {got}
|
||||
@msg unknown parameter `{path}`; the object takes no keys tham số không xác định `{path}`; đối tượng không nhận khóa nào
|
||||
@msg unknown parameter `{path}`; it takes: {keys} tham số không xác định `{path}`; nhận: {keys}
|
||||
@msg missing `{path}` thiếu `{path}`
|
||||
@msg `{path}` must be {expected}, not {got} `{path}` phải là {expected}, không phải {got}
|
||||
@msg unknown preference `{key}` tùy chọn không xác định `{key}`
|
||||
@msg unknown preset (one of {_1}) thiết lập sẵn không xác định (một trong {_1})
|
||||
@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) thiết lập sẵn không xác định `{name}` (high, medium, low hoặc thiết lập đã lưu: xem flattener.presets.list)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 204 and column 5.
|
@@ -5465,6 +5465,11 @@ lighting Light 光源
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? 未知参数 `{key}`;您是否想输入 `{near}`?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters 未知参数 `{key}`;该命令没有参数
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} 未知参数 `{key}`;该命令接受:{keys}
|
||||
@msg the params must be an object, not {got} 参数必须是对象,而不是 {got}
|
||||
@msg unknown parameter `{path}`; the object takes no keys 未知参数 `{path}`;该对象不接受任何键
|
||||
@msg unknown parameter `{path}`; it takes: {keys} 未知参数 `{path}`;它接受:{keys}
|
||||
@msg missing `{path}` 缺少 `{path}`
|
||||
@msg `{path}` must be {expected}, not {got} `{path}` 必须是 {expected},而不是 {got}
|
||||
Load EPS into Clipboard 将 EPS 载入剪贴板
|
||||
@msg an image of {w} × {h} pixels is too large to draw {w} × {h} 像素的图像过大,无法绘制
|
||||
@msg the JPEG XL file is cut short JPEG XL 文件不完整
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5291,6 +5291,11 @@ lighting Light 光源
|
||||
@msg unknown parameter `{key}`; did you mean `{near}`? 未知的參數 `{key}`;您是指 `{near}` 嗎?
|
||||
@msg unknown parameter `{key}`; the command takes no parameters 未知的參數 `{key}`;此指令沒有參數
|
||||
@msg unknown parameter `{key}`; the command takes: {keys} 未知的參數 `{key}`;此指令接受:{keys}
|
||||
@msg the params must be an object, not {got} 參數必須是物件,而不是 {got}
|
||||
@msg unknown parameter `{path}`; the object takes no keys 未知的參數 `{path}`;此物件不接受任何鍵
|
||||
@msg unknown parameter `{path}`; it takes: {keys} 未知的參數 `{path}`;它接受:{keys}
|
||||
@msg missing `{path}` 未提供 `{path}`
|
||||
@msg `{path}` must be {expected}, not {got} `{path}` 必須是 {expected},而不是 {got}
|
||||
Load EPS into Clipboard 將 EPS 載入剪貼簿
|
||||
@msg an image of {w} × {h} pixels is too large to draw {w} × {h} 像素的影像過大,無法繪製
|
||||
@msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}:不是 PNG、JPEG、WebP、TIFF 或 BMP 影像
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -312,14 +312,11 @@ pub struct MenuFacts {
|
||||
pub(crate) any_art: bool,
|
||||
}
|
||||
|
||||
/// Whether an unlocked artboard of `indices` has art that Fit to Artwork Bounds fits it to.
|
||||
/// Whether an unlocked artboard of `indices` has art that Fit to Artwork Bounds fits it to, kept by
|
||||
/// the document ([`vectorcraft_engine::DocState::any_art_to_fit`]): the canvas menu asks on every
|
||||
/// frame it is open without its [`MenuFacts`].
|
||||
pub(crate) fn fit_has_art(app: &VectorcraftApp, indices: &[usize]) -> bool {
|
||||
app.session.active().is_some_and(|st| {
|
||||
indices
|
||||
.iter()
|
||||
.filter_map(|i| st.doc.artboards.get(*i))
|
||||
.any(|a| !a.locked && vectorcraft_render::encode::art_bounds_on(&st.doc, a.rect).is_some())
|
||||
})
|
||||
app.session.active().is_some_and(|st| st.any_art_to_fit(indices))
|
||||
}
|
||||
|
||||
/// A right-click on the canvas at `p` with the Artboard tool: an artboard under the pointer that
|
||||
|
||||
@@ -1656,6 +1656,29 @@ fn the_open_menu_reads_what_the_right_click_measured() {
|
||||
assert!(labels.contains(&"Delete Empty Artboards") && !labels.contains(&"Fit to Artwork Bounds"), "{labels:?}");
|
||||
}
|
||||
|
||||
/// Whether Fit to Artwork Bounds has art to fit is kept for a version of the document, not its
|
||||
/// revision: a document replaced with no revision moved (composite fonts synced as its tab comes
|
||||
/// to the front) is measured again.
|
||||
#[test]
|
||||
fn fit_to_art_is_measured_for_each_version_of_the_document() {
|
||||
let mut app = menu_app(2);
|
||||
assert!(!super::fit_has_art(&app, &[0, 1]));
|
||||
// Art on the first artboard, the revision left as it was.
|
||||
let st = app.session.active_mut().unwrap();
|
||||
let mut doc = (*st.doc).clone();
|
||||
let l = doc.layers[0].id;
|
||||
let id = doc.alloc_id();
|
||||
let art = vectorcraft_doc::Node::path(
|
||||
id,
|
||||
vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(10.0, 10.0, 20.0, 20.0)),
|
||||
vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), vectorcraft_color::Paint::None, 0.0),
|
||||
);
|
||||
doc.insert(Some(l), 0, art).unwrap();
|
||||
st.doc = std::sync::Arc::new(doc);
|
||||
assert!(super::fit_has_art(&app, &[0, 1]), "measured again");
|
||||
assert!(!super::fit_has_art(&app, &[1]), "measured for the artboards asked about");
|
||||
}
|
||||
|
||||
/// `artboard.selectAll` chooses the Artboard tool first when another tool is in use, and fails
|
||||
/// without a document.
|
||||
#[test]
|
||||
|
||||
+38
@@ -1382,6 +1382,42 @@ reference that names no value fails the step, so a command never runs with one l
|
||||
vectorcraft-cli run --cmd file.new --params '{"width":600,"height":200}' --cmd text.create --params '{"x":10,"y":60,"text":"plain and bold words","size":30}' --cmd text.setRangeStyle --params '{"id":"$1.id","start":10,"end":14,"style":"Bold"}'
|
||||
```
|
||||
|
||||
### Parameter schemas
|
||||
|
||||
Some commands have a machine-readable parameter schema: at present `view.capsLock`, `view.saved.new`,
|
||||
`view.saved.edit`, `view.saved.list`, `view.transparencyGrid` and `view.snapToGlyph.options`. The engine's
|
||||
`engine.commands` list gives such a command a `schema` field: an object schema in the shape of JSON Schema 2020-12
|
||||
(https://json-schema.org/draft/2020-12), with `type`, `properties`, `required`, `additionalProperties: false`,
|
||||
`minimum`, `maximum`, `exclusiveMinimum` and `exclusiveMaximum` for ranges, `enum`, and `default` for a default the engine fills in. VectorCraft's own
|
||||
fields are `x-since` (the API level that added the command or the parameter), `x-deprecatedSince`, `x-lenient`,
|
||||
`x-unit`, `x-kind` (`id`, `color` or `point`), `x-default` (a default the command works out, such as `toggle`),
|
||||
`x-cost`, `x-writes` (`nothing`, `session`, `preferences` or `document`) and `x-returns`. A parameter that reads null
|
||||
as left out is an `anyOf` with `{"type": "null"}`. Null params read as `{}`, so a schema with no required parameter has
|
||||
the root `type` `["object", "null"]`. A schema with a required parameter has the root `type` `"object"`, and the engine
|
||||
refuses null params for it as a missing parameter. A number with no fractional part, such as `2.0`, is an integer, as
|
||||
JSON Schema defines one, and the command receives `2`. An `x-lenient` parameter's schema states the documented type,
|
||||
and the engine also passes a value of another type to the command. The command's `params` text equals the text the
|
||||
schema writes, and a test checks that they are the same.
|
||||
|
||||
The engine checks the params of such a command against its schema before the command runs, for every new call: from
|
||||
the desktop app, the control channel, MCP or the CLI, and when one command runs another. The steps of `command.batch`
|
||||
and `action.batch` are checked before the first step runs. A value of the wrong type, out of range or not in its list,
|
||||
a missing required parameter or an unknown key is refused with a parameter error that names the parameter, with the
|
||||
path to a value inside a list or an object, and the document and its undo history stay as they were:
|
||||
|
||||
```json
|
||||
{"id": "view.saved.new", "params": {"center": [0, 0], "zoom": 0}}
|
||||
→ invalid parameters for `view.saved.new`: `zoom` must be a number > 0, not 0
|
||||
{"id": "view.saved.edit", "params": {"edits": [{"name": "A", "delete": 1}]}}
|
||||
→ invalid parameters for `view.saved.edit`: `edits[0].delete` must be true or false, not 1
|
||||
```
|
||||
|
||||
An omitted value with a `default` takes it (`rotation` 0 in `view.saved.new`); the journal keeps the params as they
|
||||
were given. Saved steps played from a journal or an action keep a key the schema does not name, unless its value nests
|
||||
more than 16 levels deep, and the values of the parameters it names are checked as in a new call. The schemas of built-in commands belong to API level 1
|
||||
(`vectorcraft_engine::schemas::API_VERSION`); a change to any of them raises the level. Commands that plug-ins add have
|
||||
no schema here.
|
||||
|
||||
## Checking scripts written for the reference app
|
||||
|
||||
`script.check {path? | data? | dataBase64?, name?, includes?}` reads a script written for the reference app
|
||||
@@ -2471,6 +2507,8 @@ the image; linked images are left alone) and the tiles of pattern fills and stro
|
||||
pattern swatch, e.g. "Dots 2"; the original pattern is untouched). Colorized image fills are recolored when fills are in
|
||||
scope, but their pixels are never recolored. `includeImages: false` leaves other images' pixels unchanged.
|
||||
For `edit.colors.*`, `fill: false` leaves colorized image fills unchanged. `recolor.apply` always includes fills.
|
||||
`edit.colors.toGrayscale` and `edit.colors.adjustBalance` with `mode: "gray"` and `convert: true` store an embedded image
|
||||
with one gray channel, so a PDF writes it in gray.
|
||||
`includePatterns: false` leaves patterns out. The Blend commands also grade gradient meshes, which keep their shading.
|
||||
`edit.colors.saturate` needs `intensity`, a number from -100 to 100 (a number outside the range counts as its nearest
|
||||
end). Without one, or with a value that is not a number, it answers `` missing number `intensity` `` and changes nothing.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Parity with Adobe Illustrator
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Line Arrow arrowhead (#5421); scores and estimates unchanged; Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Line Arrow arrowhead (#5421); scores and estimates unchanged; Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -42,6 +42,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-09 | minor | Stroke panel: Line Arrow, a 41st generated arrowhead: two arms as thick as the line at right angles, a sharp tip and square-cut ends, twice as wide as the other heads; bounds and clicks follow each head's own reach (`stroke.set` `LineArrow`, #5421); scores and estimates unchanged |
|
||||
| 2026-11-09 | minor | Canvas: a 3D effect (Extrude & Bevel, Inflate, Rotate, Revolve) changed on type or a group draws again where its art was and is, measured by that art; the canvas measures through its renderer and draws the part with the art it made to measure it. Exports and Object › Rasterize take the solid of 3D type. Fit to Artwork Bounds and per-artboard SVG export measure every artboard through one renderer, building each solid once. A 3D effect on a fill or stroke that builds nothing (Revolve after Roughen) no longer overflows the stack on the canvas, in Object › Rasterize and in raster and EPS exports: the fill or stroke draws as without it. Tests `tests::changed::a_3d_effect_changed_on_type_or_a_group_changes_where_its_solid_was`, `fx::tests::a_renderer_measures_the_art_on_each_artboard_building_each_solid_once`, `canvas::tests::an_edit_to_a_3d_effect_redraws_with_the_art_measured`, `fx::tests::a_3d_effect_that_builds_nothing_draws_the_item_as_without_it`. Scores and estimates unchanged. |
|
||||
| 2026-11-07 | minor | File formats and Type core (#6147): `.ai`/EPS type keeps its paragraphs' tab stops (positions and alignments; leaders aren't read; at most 1,024 a paragraph and 2^20 a story), and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops; centred and justified lines are aligned as a whole as before; evidence added. Scores and estimates unchanged. |
|
||||
| 2026-11-07 | minor | File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them, and a line a soft return ends is justified; evidence added. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. |
|
||||
| 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. |
|
||||
@@ -667,6 +668,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Linux file associations:** the desktop entry lists the MIME type of every format File › Open reads but PSD, PSB, `.ps` PostScript, Targa, PCX, Pixar, JPEG 2000, HEIC/HEIF and JPEG XL, `.ai`, `.ait` and EPS included, and the MIME package declares the types shared-mime-info lacks (VectorCraft documents and templates, `.ait`, Affinity), so file managers list VectorCraft under Open With for them, as Finder does on macOS. `the_linux_desktop_entry_opens_every_readable_format` in `apps/vectorcraft/src/tests.rs` checks both files against `OPEN_EXTS`. On Debian 13, Ubuntu 24.04 and Fedora 42, `desktop-file-validate` accepts the desktop entry, `update-mime-database` accepts the MIME package, and `gio` registers the app for every one of these types.
|
||||
|
||||
- **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction.
|
||||
- **Parameter schemas:** a command can carry a machine-readable parameter schema (the new `vectorcraft-apischema` crate): each parameter's type, range, unit, whether it is required, its default, whether null counts as left out, and the API levels it was added and deprecated at. `engine.commands` lists the schema in the shape of a JSON Schema 2020-12 object schema, a test keeps the command's `params` text equal to the text the schema writes, and a new call has the params checked against it before the command runs (batch steps before the first step), with an error naming the parameter and no change to the document; saved steps keep a key the schema does not name, and their values are checked. The View commands `view.capsLock`, `view.saved.new`, `view.saved.edit`, `view.saved.list`, `view.transparencyGrid` and `view.snapToGlyph.options` have schemas. The schemas belong to API level 1 (`vectorcraft_engine::schemas::API_VERSION`), and a test fails when a schema changes without a new level. Tests: `crates/apischema/src/tests.rs` (with `conformance.rs`, a separate check of the JSON form against the JSON Schema 2020-12 rules), `schemas::tests` and `cmd::views::tests::each_view_command_refuses_a_bad_value_naming_the_parameter` in `vectorcraft-engine`.
|
||||
- **Automation:**
|
||||
- Variables: Update Data Set writes what the art holds into the active data set for the variables that data set holds, and keeps the data set's value for a text, image or graph variable whose objects are locked or inside a locked layer or group; `dataset.update {all: true}` also adds the other variables that the art reports a value for.
|
||||
- Variables: `dataset.select`, `dataset.next` and `dataset.prev` rewrite type and apply the other values on hidden objects and in hidden layers and groups, which stay hidden unless a visibility value shows them, so a layer shown later holds the selected data set's text; objects that are locked or in a locked layer or group are skipped, except by a visibility value.
|
||||
@@ -1480,6 +1482,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
Offset Path looks up the segments near each candidate hole or island in a tree of the source path's segment bounds when it decides whether to drop that contour, instead of testing the bounds of every segment. The output paths are the same. On compound and open paths of 25,000 to 40,000 anchors with 6,400 to 11,976 candidate contours, that check takes 11 to 13 ms instead of 140 to 649 ms (medians of five interleaved runs). The limit on overlapping segment pairs is the same, so a path that stops with "Offset Path: the path is too complex to offset" still stops there.
|
||||
Finding the distinct regions of an arrangement lists the shapes that cover each region instead of keeping a flag for every shape, so that step's time follows how many shapes cover each region rather than how many shapes there are. Divide, Hard Mix, Soft Mix, Shape Builder, Flatten Transparency, trapping, Intertwine and the Pathfinder effects are among the commands that use that step. The regions, their order and the output are the same. Divide on 3,000 overlapping rectangles takes 0.84 to 1.13 billion CPU cycles on its thread instead of 1.25 to 1.46 billion (dev build, six runs of each, alternating, while other builds were running).
|
||||
The contour passes of the two-path boolean operations, Offset Path, compound shapes and Intertwine test the list of shapes that cover each face instead of a flag for every shape, so each test's time follows how many shapes cover the face rather than how many shapes there are. The contours, their order and the output are the same. The many-shape boolean that compound shapes use takes 0.75 to 0.83 billion CPU cycles on its thread instead of 1.28 to 1.33 billion to unite 10,000 squares that do not touch, and 2.27 to 2.30 billion instead of 2.43 to 2.47 billion for 1,500 members of two squares each over 3,000 overlapping squares (dev build, six runs of each, alternating, while other builds were running).
|
||||
Pathfinder › Unite and the other commands that unite filled paths sweep a single input once instead of twice when its own sweep neither splits nor drops any of its segments and no two of its segments lie within twice the sweep tolerance. The output is the same. Unite on one compound path of 4,000 rings takes 485 to 542 ms instead of 872 to 1,008 ms, and on one zigzag polygon of 20,000 teeth 70 to 76 ms instead of 118 to 130 ms (release build, medians of three alternating rounds of five runs, while other builds were running).
|
||||
With more than 10,000 type on a path objects selected, the Selection and Direct Selection tools neither draw nor hit-test the objects' brackets.
|
||||
The renderer outlines the stroke of a path object with more than 1,024 path elements, without dashes or a width profile, only in and around the view, with a margin of the view's size on each side, and outlines it again when the view moves past that margin. The pixels are the same. At 1600 × 1200, the frame after one anchor of an open path of 200,000 anchors moves takes 28 to 30 ms instead of 103 ms, and with a closed path of 50,000 anchors 10 ms instead of 30 ms (`cargo run --release -p vectorcraft-engine --example anchor_bench`).
|
||||
The renderer outlines a dashed stroke of such a path the same way, without a width profile, and draws its dashes and dots in the same places as when the whole path is dashed, so the pixels are the same. A pattern too fine to draw is drawn solid, as before, and its stroke is outlined near the view too. At 1600 × 1200, the frame after one anchor moves takes 14 ms instead of 102 ms with a filled closed path of 50,000 anchors and a stroke of 6 pt dashes and 4 pt gaps (`cargo run --release -p vectorcraft-engine --example anchor_bench`, which now includes that document).
|
||||
@@ -1488,6 +1491,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
When a path object moves without changing shape, the renderer draws its stroke from the outline it made before the move, moved by the same offset. It outlines the stroke again when the path is scaled, rotated or edited, when the stroke changes, and when the move takes the view past the part of a long path it outlined. The pixels are the same. At 1600 × 1200, the frame after a 1 pt nudge takes 123 ms instead of 403 ms with an open path of 200,000 anchors in view (103 ms with nothing changed), and 13 ms instead of 26 ms with a closed path of 50,000 anchors whose stroke is dashed and has a width profile.
|
||||
When the stroke of a path moved this way has a gradient along or across it, the renderer also draws the gradient from the slices it made before the move, moved by the same offset. It makes the slices again when the path is scaled, rotated or edited, and when the stroke or its gradient changes. The pixels are the same. On Windows at 1600 × 1000, the frame after a 1 pt nudge of an open path of 200,000 anchors takes 75 ms instead of 1,237 ms with the gradient along the stroke and 69 ms instead of 1,420 ms with it across, and with an open path of 50,000 anchors 22 ms instead of 313 ms and 21 ms instead of 352 ms (medians of five interleaved runs).
|
||||
The renderer keeps the path of a path or compound path of 1,024 anchors or more by object, with the bounds of its segments and of its miter spikes in runs of 128 segments. When anchors move and the subpaths and anchor counts stay the same, the next frame changes only the segments that start or end at a moved anchor and measures again only the runs that hold them or the segments with length before them, instead of making the whole path again and measuring it whole for culling. The pixels and the cull bounds are the same. On Windows at 1600 × 1200, the frame after one anchor moves takes 8.2 ms instead of 30.1 ms with an open path of 200,000 anchors, 4.3 ms instead of 10.4 ms with a closed path of 50,000 anchors, and 6.6 ms instead of 11.9 ms with the filled closed path of 50,000 anchors and a dashed stroke (medians of five runs of `anchor_bench`).
|
||||
When an edit changes a 3D effect (Extrude & Bevel, Inflate, Rotate, Revolve), the canvas draws again where its art was and is, measured by that art: type and groups were measured by their letters or members, so a shallower or turned solid left pieces of the old one on the canvas. When the canvas draws the part on its own thread, it measures through its renderer and draws the part with the art it made to measure it. Exports and Object › Rasterize take the solid of 3D type, where they cropped it to the letters, and Fit to Artwork Bounds and per-artboard SVG export measure every artboard through one renderer, building each solid once unless the object has brushed strokes: 30 extruded type objects measured over 30 artboards take about 2 ms in a release build, where measuring their letters took under 1 ms. A document's first frame builds every 3D object's solid, off the view too, to know whether it reaches into the view.
|
||||
When only anchors of a path or compound path move, the canvas draws again only around the segments that start or end at a moved anchor, grown by the farthest its strokes reach, instead of the whole object before and after. This holds while every fill and stroke is a solid color, and the strokes have no dashes, width profile, arrowheads or brush, and the object has no effects or opacity mask; any other object still counts whole. On a loaded Windows machine at 1600 × 1200, single-threaded in a development build, the frame after one anchor of a filled closed path moves takes 16.6 million CPU cycles instead of 56.4 million with 50,000 anchors and 65.6 million instead of 180.0 million with 200,000 anchors (medians of 15 edits, the two builds run in turn three times).
|
||||
Opening a document keeps the layouts it makes for its type objects, up to 128 MiB, and the first frame uses them instead of laying those objects out again. It does not keep a layout with bitmap or color glyphs or a text object with a gradient. The frame uses a layout only for a text object equal to the one laid out, and only while the fonts and the hyphenation exceptions have not changed. It uses each layout once. The renderer drops the rest when a frame that used one ends, and all of them after eight frames that use none. The frames of a threaded story are laid out while the file is read, and the first frame lays them out again. The pixels are the same. On Windows at 1600 × 1200, in medians of seven interleaved rounds of a local example, counted in the main thread's CPU cycles because other builds were running: with 5,000 point type objects in view the first frame takes 409 million cycles instead of 894 million and the open 816 million instead of 665 million, so the two together take 1,225 million instead of 1,559 million; with one area type object of 500 paragraphs the first frame takes 697 million instead of 1,193 million and the open 535 million instead of 568 million.
|
||||
Type › Show Hidden Characters keeps the marks it places on each text object from one frame to the next. The canvas lays a text object out again when an edit changes it or its threaded story, or when the last frame that showed the marks did not draw it, and it lays out all type in view again when the fonts or the hyphenation exceptions change. With 1,000 two-line labels in view, drawing the marks takes 0.8 ms per frame in a release build instead of 19.7 ms.
|
||||
@@ -1911,6 +1915,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-10 | minor | Raster effects: the Effect Gallery shows a thumbnail of each filter and a preview of the visible stack (or the filter being browsed) inside the dialog, both an original landscape drawing passed through the filter ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)); 52% → 55%, 1–2 h → none (estimated). The canvas preview and one-undo OK are unchanged. Headline numbers unchanged. |
|
||||
| 2026-11-09 | minor | Canvas: a 3D effect changed on type or a group redraws where its solid was, measured by the effect's art through the renderer that drew the frame; exports and Object › Rasterize take the solid of 3D type; Fit to Artwork Bounds and per-artboard SVG export measure all artboards through one renderer; a 3D effect on a fill or stroke that builds nothing no longer overflows the stack on the canvas, in Object › Rasterize and in raster and EPS exports (evidence in Feature revisions). Scores and estimates unchanged. |
|
||||
| 2026-11-09 | minor | Type advanced: Auto Spell Check (`edit.autoSpellCheck`), #4936; evidence added; the dialogs and the right-click Spelling menu remain; scores and hours unchanged. |
|
||||
| 2026-11-09 | minor | Bundled content: built-in UI Frames symbol library (Panel, Button, Bar with default 9-slice); symbol libraries evidence updated; ~30% → ~32%; more built-in symbol libraries remain. |
|
||||
| 2026-11-09 | minor | Symbols: on-canvas dynamic part appearance takes gradient/pattern fill and stroke and opacity/blend; evidence updated; area ~87% → ~88%; strokes remaining no longer lists Overlap/auto corners. |
|
||||
|
||||
@@ -64,6 +64,7 @@ pub const TABLE: &[(&str, Class)] = &[
|
||||
("affinity", Class::Standalone),
|
||||
("scriptcheck", Class::Standalone),
|
||||
("js", Class::Standalone),
|
||||
("apischema", Class::Standalone),
|
||||
];
|
||||
|
||||
/// Explicit orderings *within* a layer (earlier may be used by later).
|
||||
|
||||
Reference in New Issue
Block a user