use serde_json::{Map, Value};
use std::sync::{Arc, Mutex};
use uncad::CadDatabase;
pub struct Answer {
pub json: String,
pub warnings: Vec<String>,
}
pub enum Kind {
Path,
Integer,
Text,
Json,
Number,
NonNegative,
Positive,
Choice(&'static [&'static str]),
Choices(&'static [&'static str]),
Color,
Window,
Flag,
}
pub struct Param {
pub name: &'static str,
pub kind: Kind,
pub required: bool,
pub positional: bool,
pub description: &'static str,
}
pub struct Verb {
pub name: &'static str,
pub description: &'static str,
pub params: &'static [Param],
pub writes: bool,
run: fn(&Map<String, Value>) -> Result<Answer, String>,
}
pub const VERBS: &[Verb] = &[
Verb {
name: "summarize",
description: "What a drawing contains: the unit its header states (`units`, null when \
it states none), the identifiers it states for where it came from and which saved \
state it is (`drawing_ids` -- the fingerprint is shared by copies and drawings made \
from one template, so it says where a drawing came from, not which drawing it is), \
the entity count in all and per type (`entity_count`, `by_type`), every layer and \
block definition (`layers`, `blocks`) and every block reference whose block is \
missing (`unresolved_inserts`), the attribute values on block references \
(`attributes`), loose texts that read as label and value (`labelled_texts`) and \
those on a tilted plane left out of that (`unplaced_texts`), every dimension \
(`dimensions`), feature control frame (`tolerance_frames`) and table with what \
its cells say by row and column (`tables` -- `cells` null when they were not \
read), where each space \
lies (`extents`), its shape signature (`signature` -- model space counted into \
integers whose every place has a meaning), the lowest confidence of anything \
summarized (`confidence`) and the reader's diagnostics (`warnings`). A value that \
several places give differently is listed with every value, never one of them. \
Given a type, a layer, a box, a space or an ID, it also selects entities \
(`selection`) -- to find them by what they are rather than where -- and with \
`detail` gives each one's model record, the fields `set` addresses.",
params: &[
Param {
name: "input",
kind: Kind::Path,
required: true,
positional: true,
description: DRAWING,
},
Param {
name: "type",
kind: Kind::Text,
required: false,
positional: false,
description: "Select the entities of this type (CIRCLE, LINE ...), matched \
without regard to case. Any selection argument adds `selection` to the \
answer: the entities every given filter keeps, each with its layer, space \
and box, and `total` counting them all.",
},
Param {
name: "layer",
kind: Kind::Text,
required: false,
positional: false,
description: "Select the entities on this layer, matched without regard to \
case.",
},
Param {
name: "within",
kind: Kind::Window,
required: false,
positional: false,
description: "Select the entities that reach into this box, [x0, y0, x1, y1] \
in drawing units (a crossing selection). An entity whose extent is not \
measured cannot be judged: it is left out and its type named in \
`not_measured`.",
},
Param {
name: "space",
kind: Kind::Choice(&["model", "paper"]),
required: false,
positional: false,
description: "Select the entities of model space, or of the paper-space \
sheets.",
},
Param {
name: "id",
kind: Kind::Integer,
required: false,
positional: false,
description: "Select the entity with this reference ID -- with `detail`, to \
read it.",
},
Param {
name: "limit",
kind: Kind::Integer,
required: false,
positional: false,
description: "Select at most this many entities, the first by reference ID \
(default 100); `total` still counts all of them.",
},
Param {
name: "detail",
kind: Kind::Flag,
required: false,
positional: false,
description: "Give each selected entity's model record too: its fields as \
the model JSON names them, which are the `path`s `set` takes.",
},
],
writes: false,
run: summarize,
},
Verb {
name: "hit_test",
description: "The entities at a point of a drawing: every entity whose geometry passes \
within the tolerance (`hits`, nearest first, none preferred over another -- \
`hits_total` says how many there were when `limit` kept fewer), closed shapes that \
enclose the point (`enclosing`), what block references and tables draw there with \
the chain of references it was reached through, the cell of each table that holds \
the point by row and column (`table_cells` -- a merged cell by its first row and \
column), and what could not be searched: entity types \
not measured yet (`unsupported`) and entities passed over, each with why \
(`not_searched` -- a table whose cells cannot be placed among them). Coordinates are \
in the drawing's own units.",
params: &[
Param {
name: "input",
kind: Kind::Path,
required: true,
positional: true,
description: DRAWING,
},
Param {
name: "x",
kind: Kind::Number,
required: true,
positional: false,
description: "The point's x coordinate, in drawing units.",
},
Param {
name: "y",
kind: Kind::Number,
required: true,
positional: false,
description: "The point's y coordinate, in drawing units.",
},
Param {
name: "tolerance",
kind: Kind::NonNegative,
required: true,
positional: false,
description: "How far from the point an entity's geometry may pass and still \
be a hit, in drawing units. There is no default: the right value depends \
on the drawing's scale.",
},
Param {
name: "limit",
kind: Kind::Integer,
required: false,
positional: false,
description: "Keep at most this many hits, the nearest; `hits_total` then \
says how many there were. A wide search in a dense drawing otherwise \
answers with most of it.",
},
],
writes: false,
run: hit_test,
},
Verb {
name: "relate",
description: "What each dimension's definition points sit on (`dimensions` -- every \
dimension of the drawing, those a block reference draws with the chain of \
references it was reached through). For each, the points it is built on that are \
points of the geometry it measures, by what it measures (`kind` -- none when the \
file does not state it), and for each point every entity of the same space it is \
related to (`relations`, none preferred over another): `AT_POINT` with the point \
the format defines for the entity (`anchor` -- a line's end, a centre, a vertex, a \
corner ...) or `ON_CURVE` (a line, an arc, a circle or a polyline segment), with \
the distance. A point related to nothing has no relations; what it may lie on that \
could not be looked at is named beside the dimensions: blocks standing for other \
drawings (`external_references`), entity types whose geometry is not known \
(`unmodeled`) and entities passed over, each with why (`not_searched`). The \
tolerance used is in the answer (`tolerance`). A block reference's insertion point \
is not a point of the geometry. Coordinates are in the drawing's own units.",
params: &[
Param {
name: "input",
kind: Kind::Path,
required: true,
positional: true,
description: DRAWING,
},
Param {
name: "tolerance",
kind: Kind::Positive,
required: false,
positional: false,
description: "How close a point must be to an entity's point or curve to be \
related, in drawing units. Default: a millionth of the larger side of the \
box around the drawing's defined points -- the points coincide as the file \
stores them, not as they look.",
},
Param {
name: "id",
kind: Kind::Integer,
required: false,
positional: false,
description: "Answer for the dimension with this reference ID only, as \
summarize gives it; what is said about the whole drawing is kept.",
},
],
writes: false,
run: relate,
},
Verb {
name: "diff",
description: "The exact numeric difference between two drawing states (`changes`): \
which entities were added, removed or modified, and for a modified one every field \
that differs, by how much, and whether that is within or beyond the tolerance, which \
is written into the answer (`tolerance`). What `omit` left out is counted in \
`omitted`. The answer's `lineage` says whether the two files show one \
drawing (SAME, DIFFERENT or UNKNOWN, from the header's fingerprint GUID and the \
reference IDs both hold) and on what facts. `reference` matching pairs entities by \
their reference IDs (two saves of the same drawing); `geometry` pairs them by type \
and shape -- equal shapes where the pairing is certain both ways, then changed \
shapes where their similarity singles a pair out (each such pair says why in \
`matched_by`, under the thresholds in `pairing`; a type with too many pairs to \
score is listed in `unscored`) -- and reports the rest as unknown with the \
candidates; `auto`, the default, takes `reference` only when the lineage is SAME. \
The answer's `matching` is the mode used.",
params: &[
Param {
name: "before",
kind: Kind::Path,
required: true,
positional: true,
description: "The earlier drawing (.dwg, .dxf, or model JSON .json).",
},
Param {
name: "after",
kind: Kind::Path,
required: true,
positional: true,
description: "The later drawing (.dwg, .dxf, or model JSON .json).",
},
MATCHING,
LENGTH_TOLERANCE,
ANGLE_TOLERANCE,
MIN_SIMILARITY,
MIN_MARGIN,
MAX_PAIRS,
OMIT,
],
writes: false,
run: diff,
},
Verb {
name: "set",
description: "Sets one field of one entity to a value and writes the edited drawing as \
a new model JSON file, which every verb reads as a drawing -- so edits chain, each \
call starting from the last one's output. The answer is the numeric difference from \
the input to what was written, as `diff` answers it (`changes`, `matching`, \
`tolerance`, `lineage`, `omitted`), which shows that the one field changed and \
nothing else. The input is never changed and an existing file is never \
written over. An edit the drawing does not allow is refused with the reason, and \
nothing is written.",
params: &[
Param {
name: "input",
kind: Kind::Path,
required: true,
positional: true,
description: DRAWING,
},
Param {
name: "id",
kind: Kind::Integer,
required: true,
positional: false,
description: "The entity's reference ID, as summarize and hit_test give it.",
},
Param {
name: "path",
kind: Kind::Text,
required: true,
positional: false,
description: "The field, as the model JSON names it: `radius`, `center.x`, \
`vertices[2].point.y`, `common.layer`.",
},
Param {
name: "value",
kind: Kind::Json,
required: true,
positional: false,
description: "The new value, in the model JSON's form for that field: a number, \
a string in quotes, an object.",
},
Param {
name: "output",
kind: Kind::Path,
required: true,
positional: false,
description: "Where to write the edited drawing: a .json path that does not \
exist yet.",
},
OMIT,
],
writes: true,
run: set,
},
Verb {
name: "redline",
description: "Draws the difference between two drawing states on top of the first: the \
first drawing exactly as it renders alone, and over it, in one color, what each \
changed entity became, what was removed (dashed), and a revision cloud around every change. \
A change whose counterpart is uncertain gets a dashed cloud and no geometry. Writes \
an SVG or PNG file -- never over an existing one -- and answers with what it marked \
(`marked`) and what it could not (`not_marked` -- inside a block definition, or \
nothing drawn), each with the change's index in the change set `diff` gives for the \
same arguments; the world rectangle shown and the point its coordinates are relative \
to (`view_box`, `origin`) and the stroke width in drawing units (`stroke_width`), \
for marks of your own; the color the changes are drawn in (`proposal_color`) and \
original colors too close to it (`proposal_color_conflicts`); and entities the picture left out as outliers \
(`left_out`).",
params: &[
Param {
name: "before",
kind: Kind::Path,
required: true,
positional: true,
description: "The drawing as it is (.dwg, .dxf, or model JSON .json): drawn \
unchanged.",
},
Param {
name: "after",
kind: Kind::Path,
required: true,
positional: true,
description: "The drawing as proposed (.dwg, .dxf, or model JSON .json) -- \
what `set` writes.",
},
Param {
name: "output",
kind: Kind::Path,
required: true,
positional: false,
description: "Where to write the picture: a .svg or .png path that does not \
exist yet.",
},
Param {
name: "fit",
kind: Kind::Integer,
required: false,
positional: false,
description: "For a PNG: make the longer side this many pixels. Without it a \
drawing unit is a pixel, which a large drawing exceeds.",
},
Param {
name: "stroke",
kind: Kind::Positive,
required: false,
positional: false,
description: "Draw every line this many pixels wide: in a PNG, pixels of the image; in an SVG, pixels on the screen whatever the zoom (a style rule with vector-effect: non-scaling-stroke). Without it the lines are about 1/6000 of the picture's diagonal, under a pixel at most sizes.",
},
Param {
name: "paper",
kind: Kind::Choice(&["light", "dark"]),
required: false,
positional: false,
description: "The page: light (default; pure white drawn black) or dark (a black \
page; pure white kept white, pure black drawn white). Every other color, \
the changes' included, is drawn as it is.",
},
Param {
name: "frame",
kind: Kind::Choice(&["drawing", "changes"]),
required: false,
positional: false,
description: "What the picture shows: the whole drawing (default), or the \
changes with some of the drawing around them -- in a large drawing a small \
change is otherwise a few pixels of the picture.",
},
Param {
name: "proposal_color",
kind: Kind::Color,
required: false,
positional: false,
description: "The color the changes are drawn in. Without it, redline takes \
the first of red #e4002b, purple #c000ff, blue #0060ff, green #00a040 and \
orange #ff8000 that no color of the drawing comes close to (or, when every \
one does, the one the drawing uses least). Given, the changes are drawn in \
it whatever the drawing uses, and the answer lists the colors close to it \
as proposal_color_conflicts.",
},
MATCHING,
LENGTH_TOLERANCE,
ANGLE_TOLERANCE,
MIN_SIMILARITY,
MIN_MARGIN,
MAX_PAIRS,
OMIT,
],
writes: true,
run: redline,
},
Verb {
name: "balloon",
description: "Draws numbered balloons on top of a drawing: the drawing exactly as it \
renders alone, and over it, in one color, a circle holding each label you give, \
joined by a leader to the entity or point it numbers. The labels are yours, written \
as given -- the verb places the circles and numbers nothing. Writes an SVG file -- \
never over an existing one -- and answers with every balloon drawn (`placed` -- its \
`index` in your list, circle `center` and `radius`, `leader_end`, `via` the block \
references for an entity inside a block definition, and `crowded` when no free \
spot was found and the circle overlaps a text or another balloon) and every one \
that could not be (`not_placed`, with the `reason`); the world rectangle shown and \
the point its coordinates are relative to (`view_box`, `origin`) and the stroke \
width in drawing units (`stroke_width`), for marks of your own; and the color of \
the balloons (`color`) and original colors too close to it (`color_conflicts`). \
The same drawing and balloons give the same bytes.",
params: &[
Param {
name: "input",
kind: Kind::Path,
required: true,
positional: true,
description: DRAWING,
},
Param {
name: "balloons",
kind: Kind::Json,
required: true,
positional: false,
description: "The balloons to draw: an array of at most 1000 objects \
`{\"label\": \"5.1\", \"target\": {\"entity\": 289}, \"state\": \"active\"}`. \
`label` is 1 to 64 characters. `target` is `{\"entity\": <reference ID>}` \
(as summarize and hit_test give it) or `{\"point\": [x, y]}` in drawing \
units, for where no entity is left to point at. `state` is `active` \
(default; solid) or `dropped` (dashed: the number is kept, the thing it \
numbered is gone). On the command line, JSON text, or `@file.json` to \
read it from a file.",
},
Param {
name: "output",
kind: Kind::Path,
required: true,
positional: false,
description: "Where to write the picture: a .svg path that does not exist yet.",
},
Param {
name: "color",
kind: Kind::Color,
required: false,
positional: false,
description: "The color of the balloons. Without it, the first of red #e4002b, \
purple #c000ff, blue #0060ff, green #00a040 and orange #ff8000 that no \
color of the drawing comes close to (or, when every one does, the one the \
drawing uses least). Given, the balloons are drawn in it whatever the \
drawing uses, and the answer lists the colors close to it as \
color_conflicts.",
},
Param {
name: "size",
kind: Kind::Positive,
required: false,
positional: false,
description: "The circles' diameter in drawing units. Without it, one sixtieth \
of the picture's diagonal, so a circle keeps its size in the picture \
whatever the drawing's units.",
},
Param {
name: "space",
kind: Kind::Choice(&["model", "paper", "all"]),
required: false,
positional: false,
description: "Which space to draw (default: model). The balloons' targets are \
entities and points of that space.",
},
],
writes: true,
run: balloon,
},
];
const DRAWING: &str = "The drawing (.dwg, .dxf, or model JSON .json).";
const MATCHING: Param = Param {
name: "matching",
kind: Kind::Choice(&["auto", "reference", "geometry"]),
required: false,
positional: false,
description: "How entities of the two drawings are paired (default: auto -- by reference \
ID when the two files show one drawing, by shape otherwise).",
};
const LENGTH_TOLERANCE: Param = Param {
name: "length_tolerance",
kind: Kind::NonNegative,
required: false,
positional: false,
description: "Tolerance for every numeric field except angles, in drawing units (default: \
1e-6).",
};
const ANGLE_TOLERANCE: Param = Param {
name: "angle_tolerance",
kind: Kind::NonNegative,
required: false,
positional: false,
description: "Tolerance for angles, in radians (default: 1e-9).",
};
const MIN_SIMILARITY: Param = Param {
name: "min_similarity",
kind: Kind::NonNegative,
required: false,
positional: false,
description: "Under geometry matching, the least share of two changed entities' telling \
fields that must agree for them to be paired (default: 0.5; above 1 pairs only equal \
shapes).",
};
const MIN_MARGIN: Param = Param {
name: "min_margin",
kind: Kind::NonNegative,
required: false,
positional: false,
description: "Under geometry matching, how far a pair's similarity must stand above the \
next highest of either entity (default: 0.1).",
};
const MAX_PAIRS: Param = Param {
name: "max_pairs",
kind: Kind::Integer,
required: false,
positional: false,
description: "Under geometry matching, the most pairs of changed entities scored (default: \
10000000). A type that does not fit is listed in the answer's `unscored`.",
};
const OMIT: Param = Param {
name: "omit",
kind: Kind::Choices(&["within", "unstated"]),
required: false,
positional: false,
description: "Field changes to leave out of the answer: `within` (numeric fields that moved \
within tolerance), `unstated` (fields one drawing does not state -- a newer format \
states what an older one had no place for). A modified entity left with no field is \
left out too. The answer counts what was left out in `omitted`. Default: nothing is \
left out.",
};
pub fn find(name: &str) -> Option<&'static Verb> {
let name = name.replace('-', "_");
VERBS.iter().find(|v| v.name == name)
}
pub fn cli_spelling(name: &str) -> String {
name.replace('_', "-")
}
impl Verb {
pub fn call(&self, args: &Map<String, Value>) -> Result<Answer, String> {
self.check(args)?;
(self.run)(args)
}
fn check(&self, args: &Map<String, Value>) -> Result<(), String> {
for key in args.keys() {
if !self.params.iter().any(|p| p.name == key) {
return Err(format!("{} takes no argument '{key}'", self.name));
}
}
for param in self.params {
let Some(value) = args.get(param.name) else {
if param.required {
return Err(format!("{} needs '{}'", self.name, param.name));
}
continue;
};
let fits = match ¶m.kind {
Kind::Path | Kind::Text => value.as_str().is_some_and(|s| !s.is_empty()),
Kind::Integer => value.as_u64().is_some(),
Kind::Json => true,
Kind::Number => value.as_f64().is_some_and(f64::is_finite),
Kind::NonNegative => value.as_f64().is_some_and(|v| v.is_finite() && v >= 0.0),
Kind::Positive => value.as_f64().is_some_and(|v| v.is_finite() && v > 0.0),
Kind::Choice(words) => value.as_str().is_some_and(|s| words.contains(&s)),
Kind::Color => value.as_str().and_then(color).is_some(),
Kind::Window => window(value).is_some(),
Kind::Flag => value.is_boolean(),
Kind::Choices(words) => value.as_array().is_some_and(|items| {
items
.iter()
.all(|i| i.as_str().is_some_and(|s| words.contains(&s)))
&& items
.iter()
.filter_map(Value::as_str)
.collect::<std::collections::BTreeSet<_>>()
.len()
== items.len()
}),
};
if !fits {
return Err(format!(
"'{}' must be {} (got {value})",
param.name,
param.kind.expected()
));
}
}
Ok(())
}
pub fn input_schema(&self) -> Map<String, Value> {
let mut properties = Map::new();
for param in self.params {
let mut schema = Map::new();
match ¶m.kind {
Kind::Path | Kind::Text => {
schema.insert("type".into(), "string".into());
}
Kind::Integer => {
schema.insert("type".into(), "integer".into());
schema.insert("minimum".into(), 0.into());
}
Kind::Json => {}
Kind::Number => {
schema.insert("type".into(), "number".into());
}
Kind::NonNegative => {
schema.insert("type".into(), "number".into());
schema.insert("minimum".into(), 0.into());
}
Kind::Positive => {
schema.insert("type".into(), "number".into());
schema.insert("exclusiveMinimum".into(), 0.into());
}
Kind::Choice(words) => {
schema.insert("type".into(), "string".into());
schema.insert("enum".into(), (*words).into());
}
Kind::Color => {
schema.insert("type".into(), "string".into());
schema.insert("pattern".into(), "^#[0-9A-Fa-f]{6}$".into());
}
Kind::Window => {
let mut item = Map::new();
item.insert("type".into(), "number".into());
schema.insert("type".into(), "array".into());
schema.insert("items".into(), item.into());
schema.insert("minItems".into(), 4.into());
schema.insert("maxItems".into(), 4.into());
}
Kind::Flag => {
schema.insert("type".into(), "boolean".into());
}
Kind::Choices(words) => {
let mut item = Map::new();
item.insert("type".into(), "string".into());
item.insert("enum".into(), (*words).into());
schema.insert("type".into(), "array".into());
schema.insert("items".into(), item.into());
schema.insert("uniqueItems".into(), true.into());
}
}
schema.insert("description".into(), param.description.into());
properties.insert(param.name.into(), schema.into());
}
let required: Vec<&str> = self
.params
.iter()
.filter(|p| p.required)
.map(|p| p.name)
.collect();
let mut schema = Map::new();
schema.insert("type".into(), "object".into());
schema.insert("properties".into(), properties.into());
schema.insert("required".into(), required.into());
schema.insert("additionalProperties".into(), false.into());
schema
}
pub fn parse_cli(&self, argv: &[String]) -> Result<Map<String, Value>, String> {
let verb = cli_spelling(self.name);
let mut args = Map::new();
let mut positionals = self.params.iter().filter(|p| p.positional);
let mut i = 0;
while i < argv.len() {
let word = match argv[i].as_str() {
"-o" => "--output",
other => other,
};
if let Some(flag) = word.strip_prefix("--").filter(|f| !f.is_empty()) {
let param = self
.params
.iter()
.find(|p| !p.positional && cli_spelling(p.name) == flag)
.ok_or_else(|| format!("unknown option '{word}' for uncad {verb}"))?;
if matches!(param.kind, Kind::Flag) {
args.insert(param.name.into(), Value::Bool(true));
i += 1;
continue;
}
i += 1;
let raw = argv.get(i).ok_or_else(|| format!("{word} needs a value"))?;
let value = match param.kind {
Kind::Number | Kind::NonNegative | Kind::Positive => raw
.parse::<f64>()
.ok()
.and_then(|v| serde_json::Number::from_f64(v).map(Value::Number))
.ok_or_else(|| format!("{word} must be a finite number (got '{raw}')"))?,
Kind::Integer => raw.parse::<u64>().map(Value::from).map_err(|_| {
format!("{word} must be an integer, 0 or more (got '{raw}')")
})?,
Kind::Json => {
let text = match raw.strip_prefix('@') {
Some(file) => std::fs::read_to_string(file)
.map_err(|e| format!("cannot read {word} from '{file}': {e}"))?,
None => raw.clone(),
};
serde_json::from_str(&text).map_err(|e| {
format!("{word} must be JSON -- a string in quotes (got '{raw}': {e})")
})?
}
Kind::Window => Value::Array(
raw.split(',')
.map(|n| {
n.trim()
.parse::<f64>()
.ok()
.and_then(serde_json::Number::from_f64)
.map(Value::Number)
.ok_or_else(|| {
format!(
"{word} must be four numbers x0,y0,x1,y1 (got '{raw}')"
)
})
})
.collect::<Result<_, _>>()?,
),
Kind::Flag => unreachable!("a flag takes no value"),
Kind::Choices(_) => Value::Array(
raw.split(',')
.map(|w| Value::String(w.trim().to_string()))
.collect(),
),
Kind::Path | Kind::Text | Kind::Choice(_) | Kind::Color => {
Value::String(raw.clone())
}
};
args.insert(param.name.into(), value);
} else if word.starts_with('-') && word.len() > 1 {
return Err(format!("unknown option '{word}' for uncad {verb}"));
} else {
let param = positionals
.next()
.ok_or_else(|| format!("unexpected argument '{word}' for uncad {verb}"))?;
args.insert(param.name.into(), Value::String(word.to_string()));
}
i += 1;
}
Ok(args)
}
pub fn help(&self) -> String {
let mut out = format!(
"{}\n\n{}\nArguments:\n",
self.usage(),
wrap(self.description, 0)
);
for param in self.params {
let required = if param.required { " (required)" } else { "" };
out.push_str(&format!(" {}{required}\n", param.word()));
out.push_str(&wrap(param.description, 6));
out.push('\n');
}
out.push_str(" --pretty\n");
out.push_str(&wrap(
"Print the answer indented over several lines instead of as one line of JSON.",
6,
));
out
}
pub fn usage(&self) -> String {
let mut line = format!("uncad {}", cli_spelling(self.name));
for param in self.params {
let word = param.word();
if param.required {
line.push_str(&format!(" {word}"));
} else {
line.push_str(&format!(" [{word}]"));
}
}
line
}
}
impl Param {
fn word(&self) -> String {
if self.positional {
format!("<{}>", self.name)
} else if matches!(self.kind, Kind::Flag) {
format!("--{}", cli_spelling(self.name))
} else {
format!(
"--{} <{}>",
cli_spelling(self.name),
self.kind.placeholder()
)
}
}
}
fn wrap(text: &str, indent: usize) -> String {
let mut out = String::new();
let mut line = String::new();
for word in text.split_whitespace() {
if !line.is_empty() && indent + line.len() + 1 + word.len() > 80 {
out.push_str(&" ".repeat(indent));
out.push_str(&line);
out.push('\n');
line.clear();
}
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
}
if !line.is_empty() {
out.push_str(&" ".repeat(indent));
out.push_str(&line);
out.push('\n');
}
out
}
impl Kind {
fn expected(&self) -> String {
match self {
Kind::Path => "a file path".into(),
Kind::Integer => "an integer, 0 or more".into(),
Kind::Text => "a non-empty string".into(),
Kind::Json => "a JSON value".into(),
Kind::Number => "a finite number".into(),
Kind::NonNegative => "a finite number, 0 or more".into(),
Kind::Positive => "a finite number greater than 0".into(),
Kind::Choice(words) => format!("one of {}", words.join(", ")),
Kind::Choices(words) => format!("a list of distinct words from {}", words.join(", ")),
Kind::Color => "a color as #rrggbb".into(),
Kind::Window => "four finite numbers [x0, y0, x1, y1], x0 <= x1 and y0 <= y1".into(),
Kind::Flag => "true or false".into(),
}
}
fn placeholder(&self) -> String {
match self {
Kind::Path => "path".into(),
Kind::Integer | Kind::Number | Kind::NonNegative | Kind::Positive => "n".into(),
Kind::Text => "text".into(),
Kind::Json => "json".into(),
Kind::Choice(words) => words.join("|"),
Kind::Choices(words) => format!("{}[,...]", words.join("|")),
Kind::Color => "#rrggbb".into(),
Kind::Window => "x0,y0,x1,y1".into(),
Kind::Flag => String::new(),
}
}
}
fn window(value: &Value) -> Option<iron_scout_cad::Bounds> {
let n: Vec<f64> = value
.as_array()?
.iter()
.map(|v| v.as_f64().filter(|x| x.is_finite()))
.collect::<Option<_>>()?;
let [x0, y0, x1, y1] = n[..] else {
return None;
};
(x0 <= x1 && y0 <= y1).then_some(iron_scout_cad::Bounds {
min: uncad::model::Point2D { x: x0, y: y0 },
max: uncad::model::Point2D { x: x1, y: y1 },
})
}
fn color(text: &str) -> Option<[u8; 3]> {
let hex = text
.strip_prefix('#')
.filter(|h| h.len() == 6 && h.bytes().all(|b| b.is_ascii_hexdigit()))?;
let channel = |at: usize| u8::from_str_radix(&hex[at..at + 2], 16).ok();
Some([channel(0)?, channel(2)?, channel(4)?])
}
fn path(args: &Map<String, Value>, name: &str) -> String {
args[name]
.as_str()
.expect("checked by Verb::call")
.to_string()
}
fn number(args: &Map<String, Value>, name: &str) -> Option<f64> {
args.get(name)
.map(|v| v.as_f64().expect("checked by Verb::call"))
}
pub fn is_model_json(path: &str) -> bool {
std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("json"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Reading {
ModelJson,
Drawing(uncad::Format),
}
struct Held {
capacity: usize,
entries: std::collections::VecDeque<(Reading, Vec<u8>, Arc<CadDatabase>)>,
}
impl Held {
fn get(&mut self, reading: Reading, bytes: &[u8]) -> Option<Arc<CadDatabase>> {
let at = self
.entries
.iter()
.position(|(r, b, _)| *r == reading && b.as_slice() == bytes)?;
let entry = self.entries.remove(at)?;
let db = Arc::clone(&entry.2);
self.entries.push_back(entry);
Some(db)
}
fn put(&mut self, reading: Reading, bytes: Vec<u8>, db: &Arc<CadDatabase>) {
if self.capacity == 0 {
return;
}
while self.entries.len() >= self.capacity {
self.entries.pop_front();
}
self.entries.push_back((reading, bytes, Arc::clone(db)));
}
}
static HELD: Mutex<Held> = Mutex::new(Held {
capacity: 0,
entries: std::collections::VecDeque::new(),
});
pub fn hold_reads(drawings: usize) {
let mut held = HELD.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
held.capacity = drawings;
held.entries.truncate(drawings);
}
pub fn read(input: &str, warnings: &mut Vec<String>) -> Result<Arc<CadDatabase>, String> {
let reading = if is_model_json(input) {
Reading::ModelJson
} else {
Reading::Drawing(uncad::Format::from_path(input))
};
if std::fs::metadata(input).is_ok_and(|meta| meta.is_dir()) {
return Err(format!("input path is a directory, not a file: '{input}'"));
}
let bytes =
std::fs::read(input).map_err(|e| format!("cannot open input file '{input}': {e}"))?;
let held = HELD
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.get(reading, &bytes);
let db = match held {
Some(db) => db,
None => {
let db = Arc::new(decode(input, reading, &bytes)?);
HELD.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.put(reading, bytes, &db);
db
}
};
if !db.read_diagnostics.is_clean() {
warnings.push(if is_model_json(input) {
format!(
"'{input}' records non-fatal problems ({}) from the read of the drawing it was \
written from; objects that read could not decode are missing from it",
db.read_diagnostics.warnings.join(", ")
)
} else {
crate::read_warning(input, &db)
});
}
Ok(db)
}
fn decode(input: &str, reading: Reading, bytes: &[u8]) -> Result<CadDatabase, String> {
match reading {
Reading::ModelJson => {
let text = std::str::from_utf8(bytes)
.map_err(|e| format!("cannot open input file '{input}': {e}"))?;
serde_json::from_str::<CadDatabase>(text).map_err(|e| {
format!(
"cannot read '{input}' as model JSON ({e}) -- write it again from the \
drawing with `uncad <drawing> -o <file.json>`"
)
})
}
Reading::Drawing(format) => uncad::parse_bytes(bytes, format)
.map_err(|e| format!("could not parse '{input}' ({e}) -- is it a valid DWG/DXF file?")),
}
}
fn projected(
changes: iron_diff_cad::ChangeSet,
args: &Map<String, Value>,
) -> iron_diff_cad::ChangeSet {
let Some(words) = args.get("omit").and_then(Value::as_array) else {
return changes;
};
let has = |w: &str| words.iter().any(|v| v == w);
changes.without(iron_diff_cad::Omit {
within: has("within"),
unstated: has("unstated"),
})
}
fn answer(result: &impl serde::Serialize, warnings: Vec<String>) -> Result<Answer, String> {
let json = serde_json::to_string(result).map_err(|e| e.to_string())?;
Ok(Answer { json, warnings })
}
const SELECTION_LIMIT: u64 = 100;
fn summarize(args: &Map<String, Value>) -> Result<Answer, String> {
let mut warnings = Vec::new();
let db = read(&path(args, "input"), &mut warnings)?;
let selecting = ["type", "layer", "within", "space", "id", "limit", "detail"]
.iter()
.any(|name| args.contains_key(*name));
if !selecting {
return answer(&iron_scout_cad::summarize(&db), warnings);
}
let text = |name: &str| args.get(name).and_then(Value::as_str);
let limit = args
.get("limit")
.and_then(Value::as_u64)
.unwrap_or(SELECTION_LIMIT);
let mut selection = iron_scout_cad::Selection::new().limit(limit as usize);
if let Some(t) = text("type") {
selection = selection.of_type(t);
}
if let Some(l) = text("layer") {
selection = selection.on_layer(l);
}
if let Some(w) = args.get("within") {
selection = selection.crossing(window(w).expect("checked by Verb::call"));
}
match text("space") {
Some("model") => selection = selection.in_space(iron_scout_cad::SpaceFilter::Model),
Some("paper") => selection = selection.in_space(iron_scout_cad::SpaceFilter::Paper),
_ => {}
}
if let Some(id) = args.get("id").and_then(Value::as_u64) {
let id: uncad::model::EntityId =
serde_json::from_value(Value::from(id)).expect("a reference ID is an integer");
selection = selection.with_ids([id]);
}
if args.get("detail").and_then(Value::as_bool) == Some(true) {
selection = selection.with_detail();
}
answer(&iron_scout_cad::summarize_with(&db, &selection), warnings)
}
fn hit_test(args: &Map<String, Value>) -> Result<Answer, String> {
let mut warnings = Vec::new();
let db = read(&path(args, "input"), &mut warnings)?;
let point = uncad::model::Point2D {
x: number(args, "x").expect("required"),
y: number(args, "y").expect("required"),
};
let tolerance = number(args, "tolerance").expect("required");
let mut found = iron_scout_cad::hit_test(&db, point, tolerance);
if let Some(n) = args.get("limit").and_then(Value::as_u64) {
found = found.limited(n as usize);
}
answer(&found, warnings)
}
fn relate(args: &Map<String, Value>) -> Result<Answer, String> {
let mut warnings = Vec::new();
let db = read(&path(args, "input"), &mut warnings)?;
let mut related =
iron_scout_cad::relate(&db, number(args, "tolerance")).map_err(|e| e.to_string())?;
if let Some(id) = args.get("id").and_then(Value::as_u64) {
let id: uncad::model::EntityId =
serde_json::from_value(Value::from(id)).expect("a reference ID is an integer");
related = related.of_dimension(id);
}
answer(&related, warnings)
}
fn diff(args: &Map<String, Value>) -> Result<Answer, String> {
let mut warnings = Vec::new();
let before = read(&path(args, "before"), &mut warnings)?;
let after = read(&path(args, "after"), &mut warnings)?;
answer(&compare(&before, &after, args), warnings)
}
fn compare(
before: &CadDatabase,
after: &CadDatabase,
args: &Map<String, Value>,
) -> iron_diff_cad::ChangeSet {
let mut options = iron_diff_cad::DiffOptions::default();
if let Some(matching) = args.get("matching").and_then(Value::as_str) {
options.matching = match matching {
"reference" => iron_diff_cad::Matching::Reference,
"geometry" => iron_diff_cad::Matching::Geometry,
_ => iron_diff_cad::Matching::Auto,
};
}
if let Some(length) = number(args, "length_tolerance") {
options.tolerance.length = length;
}
if let Some(angle) = number(args, "angle_tolerance") {
options.tolerance.angle = angle;
}
if let Some(similarity) = number(args, "min_similarity") {
options.pairing.min_similarity = similarity;
}
if let Some(margin) = number(args, "min_margin") {
options.pairing.min_margin = margin;
}
if let Some(pairs) = args.get("max_pairs") {
options.pairing.max_pairs = pairs.as_u64().expect("checked by Verb::call");
}
projected(iron_diff_cad::diff(before, after, options), args)
}
fn create_new(output: &str, bytes: &[u8]) -> Result<(), String> {
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(output)
.map_err(|e| format!("cannot write '{output}': {e}"))?;
std::io::Write::write_all(&mut file, bytes).map_err(|e| format!("cannot write '{output}': {e}"))
}
fn redline(args: &Map<String, Value>) -> Result<Answer, String> {
let output = path(args, "output");
let extension = std::path::Path::new(&output)
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase);
let png = match extension.as_deref() {
Some("svg") => false,
Some("png") => true,
_ => {
return Err(format!(
"redline writes SVG or PNG: '{output}' ends in neither .svg nor .png"
))
}
};
let stroke_px = args.get("stroke").and_then(Value::as_f64);
if std::path::Path::new(&output).exists() {
return Err(format!(
"'{output}' already exists -- redline never writes over a file; give a new path"
));
}
let fit = args.get("fit").and_then(Value::as_u64);
let mut warnings = Vec::new();
let before = read(&path(args, "before"), &mut warnings)?;
let after = read(&path(args, "after"), &mut warnings)?;
let changes = compare(&before, &after, args);
let mut options = iron_render_cad::OverlayOptions::default();
if args.get("frame").and_then(Value::as_str) == Some("changes") {
options.frame = iron_render_cad::OverlayFrame::Changes;
}
if let Some(given) = args.get("proposal_color").and_then(Value::as_str) {
options.proposal_color =
iron_render_cad::ProposalColor::Given(color(given).expect("checked by Verb::call"));
}
if args.get("paper").and_then(Value::as_str) == Some("dark") {
options.svg.paper = iron_render_cad::Paper::Dark;
}
if !png {
options.svg.screen_stroke = stroke_px;
}
let mut overlay = iron_render_cad::overlay_to_svg(&before, &after, &changes, options);
if let Some(px) = stroke_px.filter(|_| png) {
for _ in 0..STROKE_ROUNDS {
let units = px / px_per_unit(&overlay, fit)?;
if (overlay.stroke_width - units).abs() <= units * 1e-9 {
break;
}
options.svg.stroke_width = Some(units);
overlay = iron_render_cad::overlay_to_svg(&before, &after, &changes, options);
}
}
if !overlay.proposal_color_conflicts.is_empty() {
let colors: Vec<String> = overlay
.proposal_color_conflicts
.iter()
.map(|c| format!("{} ({} uses)", c.color, c.uses))
.collect();
let advice = match options.proposal_color {
iron_render_cad::ProposalColor::Given(_) => {
"draw the changes in another color with --proposal-color <#rrggbb> \
(MCP: proposal_color)"
}
_ => {
"every color redline chooses from clashes with the drawing and this one \
clashes least -- give one with --proposal-color <#rrggbb> (MCP: proposal_color)"
}
};
warnings.push(format!(
"the original is drawn in colors close to {}, the color the changes are drawn in \
({}): a change may not stand out from the lines around it -- {advice}",
overlay.proposal_color,
colors.join(", ")
));
}
let picture_px = if png {
let longer = overlay.view_box.width().max(overlay.view_box.height());
longer * px_per_unit(&overlay, fit)?
} else {
NOMINAL_PX
};
let small: Vec<String> = overlay
.marked
.iter()
.filter(|m| m.share * picture_px < MIN_MARK_PX)
.map(|m| m.entry.to_string())
.collect();
if !small.is_empty() && options.frame != iron_render_cad::OverlayFrame::Changes {
let size = if png {
format!("in this {} px picture", picture_px.round())
} else {
format!("when the picture is shown {NOMINAL_PX} px across")
};
warnings.push(format!(
"{} of {} marks are under {MIN_MARK_PX} px {size} and may not be found by eye \
(entries {}) -- frame the changes with --frame changes (MCP: frame)",
small.len(),
overlay.marked.len(),
small.join(", ")
));
}
if !overlay.left_out.is_empty() {
let names: Vec<String> = overlay
.left_out
.iter()
.map(|l| format!("{} {}", l.type_name, l.id.value()))
.collect();
warnings.push(format!(
"the original is drawn without the entities far larger or farther than the rest \
of the drawing (listed in left_out): {}",
names.join(", ")
));
}
if png {
let scale = px_per_unit(&overlay, fit)?;
let bytes = iron_render_cad::svg_to_png(&overlay.svg, scale as f32)
.map_err(|e| format!("{e} -- ask for a smaller picture with --fit <px> (MCP: fit)"))?;
create_new(&output, &bytes)?;
} else {
create_new(&output, overlay.svg.as_bytes())?;
}
answer(&overlay, warnings)
}
const MAX_BALLOONS: usize = 1000;
const MAX_LABEL_CHARS: usize = 64;
fn balloon_list(value: &Value) -> Result<Vec<iron_render_cad::Balloon>, String> {
let items = value
.as_array()
.ok_or("'balloons' must be an array of {label, target, state} objects")?;
if items.len() > MAX_BALLOONS {
return Err(format!(
"'balloons' lists {} balloons: at most {MAX_BALLOONS} are drawn in one call",
items.len()
));
}
items
.iter()
.enumerate()
.map(|(at, item)| {
let fields = item.as_object().ok_or_else(|| {
format!("balloons[{at}] must be an object {{label, target, state}}")
})?;
if let Some(key) = fields
.keys()
.find(|k| !["label", "target", "state"].contains(&k.as_str()))
{
return Err(format!("balloons[{at}] takes no field '{key}'"));
}
let label = fields
.get("label")
.and_then(Value::as_str)
.filter(|l| !l.is_empty())
.ok_or_else(|| format!("balloons[{at}] needs a 'label': a non-empty string"))?;
let length = label.chars().count();
if length > MAX_LABEL_CHARS {
return Err(format!(
"balloons[{at}].label is {length} characters: at most {MAX_LABEL_CHARS} fit \
a balloon"
));
}
let target = fields
.get("target")
.and_then(Value::as_object)
.ok_or_else(|| {
format!(
"balloons[{at}] needs a 'target': {{\"entity\": <ID>}} or \
{{\"point\": [x, y]}}"
)
})?;
let target = match (target.len(), target.get("entity"), target.get("point")) {
(1, Some(id), None) => {
let id = id.as_u64().ok_or_else(|| {
format!("balloons[{at}].target.entity must be an entity ID: an integer, 0 or more")
})?;
iron_render_cad::BalloonTarget::Entity(uncad::model::EntityId::new(id))
}
(1, None, Some(point)) => {
let xy: Option<Vec<f64>> = point.as_array().filter(|p| p.len() == 2).and_then(
|p| {
p.iter()
.map(|n| n.as_f64().filter(|n| n.is_finite()))
.collect()
},
);
let Some(&[x, y]) = xy.as_deref() else {
return Err(format!(
"balloons[{at}].target.point must be two finite numbers [x, y]"
));
};
iron_render_cad::BalloonTarget::Point(uncad::model::Point2D { x, y })
}
_ => {
return Err(format!(
"balloons[{at}].target must hold exactly one of 'entity' and 'point'"
))
}
};
let state = match fields.get("state").map(Value::as_str) {
None | Some(Some("active")) => iron_render_cad::BalloonState::Active,
Some(Some("dropped")) => iron_render_cad::BalloonState::Dropped,
Some(_) => {
return Err(format!(
"balloons[{at}].state must be \"active\" or \"dropped\""
))
}
};
let mut balloon = iron_render_cad::Balloon::new(label, target);
balloon.state = state;
Ok(balloon)
})
.collect()
}
fn balloon(args: &Map<String, Value>) -> Result<Answer, String> {
let output = path(args, "output");
let is_svg = std::path::Path::new(&output)
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("svg"));
if !is_svg {
return Err(format!(
"balloon writes SVG only: '{output}' does not end in .svg"
));
}
if std::path::Path::new(&output).exists() {
return Err(format!(
"'{output}' already exists -- balloon never writes over a file; give a new path"
));
}
let balloons = balloon_list(&args["balloons"])?;
let mut options = iron_render_cad::BalloonOptions::default();
if let Some(given) = args.get("color").and_then(Value::as_str) {
options.color =
iron_render_cad::ProposalColor::Given(color(given).expect("checked by Verb::call"));
}
if let Some(diameter) = number(args, "size") {
options.size = iron_render_cad::BalloonSize::Units(diameter);
}
options.svg.space = match args.get("space").and_then(Value::as_str) {
Some("paper") => iron_render_cad::Space::Paper,
Some("all") => iron_render_cad::Space::All,
_ => iron_render_cad::Space::Model,
};
let mut warnings = Vec::new();
let db = read(&path(args, "input"), &mut warnings)?;
let drawn = iron_render_cad::balloons_to_svg(&db, &balloons, options);
if !drawn.color_conflicts.is_empty() {
let colors: Vec<String> = drawn
.color_conflicts
.iter()
.map(|c| format!("{} ({} uses)", c.color, c.uses))
.collect();
let advice = match options.color {
iron_render_cad::ProposalColor::Given(_) => {
"draw the balloons in another color with --color <#rrggbb> (MCP: color)"
}
_ => {
"every color balloon chooses from clashes with the drawing and this one \
clashes least -- give one with --color <#rrggbb> (MCP: color)"
}
};
warnings.push(format!(
"the original is drawn in colors close to {}, the color the balloons are drawn in \
({}): a balloon may not stand out from the lines around it -- {advice}",
drawn.color,
colors.join(", ")
));
}
create_new(&output, drawn.svg.as_bytes())?;
answer(&drawn, warnings)
}
const MIN_MARK_PX: f64 = 8.0;
const NOMINAL_PX: f64 = 1600.0;
const STROKE_ROUNDS: usize = 8;
fn px_per_unit(overlay: &iron_render_cad::OverlayResult, fit: Option<u64>) -> Result<f64, String> {
let Some(fit) = fit else { return Ok(1.0) };
let longer = overlay.view_box.width().max(overlay.view_box.height());
if !(longer.is_finite() && longer > 0.0) || fit == 0 {
return Err(format!(
"cannot fit a picture of size {longer} into {fit} px"
));
}
Ok(fit as f64 / longer)
}
fn set(args: &Map<String, Value>) -> Result<Answer, String> {
let input = path(args, "input");
let output = path(args, "output");
if !is_model_json(&output) {
return Err(format!(
"set writes model JSON only: '{output}' does not end in .json"
));
}
if std::path::Path::new(&output).exists() {
return Err(format!(
"'{output}' already exists -- set never writes over a file, its input included; \
give a new path"
));
}
let mut warnings = Vec::new();
let before = read(&input, &mut warnings)?;
let id = uncad::model::EntityId::new(args["id"].as_u64().expect("checked by Verb::call"));
let field = args["path"].as_str().expect("checked by Verb::call");
let after =
iron_hand_cad::set(&before, id, field, args["value"].clone()).map_err(|refusal| {
let reason = serde_json::to_value(&refusal)
.ok()
.and_then(|v| v["reason"].as_str().map(str::to_string))
.unwrap_or_default();
format!("refused ({reason}): {refusal}")
})?;
let json = after
.to_json(uncad::ToJsonOptions::default())
.map_err(|e| e.to_string())?;
create_new(&output, json.as_bytes())?;
answer(
&projected(
iron_diff_cad::diff(
&before,
&after,
iron_diff_cad::DiffOptions {
matching: iron_diff_cad::Matching::Reference,
..iron_diff_cad::DiffOptions::default()
},
),
args,
),
warnings,
)
}
#[cfg(test)]
mod tests {
use super::*;
fn held(capacity: usize) -> Held {
Held {
capacity,
entries: std::collections::VecDeque::new(),
}
}
fn model() -> Arc<CadDatabase> {
let empty = b" 0\nSECTION\n 2\nENTITIES\n 0\nENDSEC\n 0\nEOF\n";
Arc::new(uncad::parse_bytes(empty, uncad::Format::Dxf).expect("an empty DXF reads"))
}
const DWG: Reading = Reading::Drawing(uncad::Format::Dwg);
const DXF: Reading = Reading::Drawing(uncad::Format::Dxf);
#[test]
fn a_held_model_is_reused_only_for_the_same_bytes_read_the_same_way() {
let mut h = held(4);
let db = model();
h.put(DWG, b"one".to_vec(), &db);
assert!(Arc::ptr_eq(&h.get(DWG, b"one").expect("held"), &db));
assert!(h.get(DWG, b"two").is_none());
assert!(h.get(DXF, b"one").is_none());
assert!(h.get(Reading::ModelJson, b"one").is_none());
}
#[test]
fn the_least_recently_used_model_goes_first() {
let mut h = held(2);
h.put(DWG, b"a".to_vec(), &model());
h.put(DWG, b"b".to_vec(), &model());
assert!(h.get(DWG, b"a").is_some());
h.put(DWG, b"c".to_vec(), &model());
assert!(h.get(DWG, b"b").is_none());
assert!(h.get(DWG, b"a").is_some());
assert!(h.get(DWG, b"c").is_some());
}
#[test]
fn nothing_is_held_when_holding_is_off() {
let mut h = held(0);
h.put(DWG, b"a".to_vec(), &model());
assert!(h.get(DWG, b"a").is_none());
}
}