mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 19:36:46 -06:00
* M6: Dev Mode measurements kept on the canvas, a read-only layers panel Measurements placed between two layers, along a side or to the layer around one (devMode.addMeasurement / removeMeasurement / measurements), stored on the layer they start from (saved, synced, undoable; old files open without them), drawn in Dev Mode as labelled redlines that follow the layers and listed, placed and removed in the Inspect tab. In Dev Mode the layers panel only selects and expands. * M6: Dev Mode measurements: explicit imports, a measurement row of its own (Sonar)
223 lines
8.8 KiB
Rust
223 lines
8.8 KiB
Rust
//! Measurements kept on the canvas (Dev Mode): a developer places one between two layers, or
|
|
//! across one side of a layer, and everyone who opens the file sees it. Each is stored on the layer
|
|
//! it starts from (`Node::measurements`: saved, synced, undoable) and drawn from the layers'
|
|
//! current bounds, so it follows them when they move or resize. One whose other layer was deleted
|
|
//! or moved to another page is neither drawn nor listed (undoing the deletion brings it back).
|
|
|
|
use figcraft_doc::{Document, Measurement, Node, NodeId, Side};
|
|
use figcraft_geom::{Point, Rect};
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::cmd::CommandSpec;
|
|
use crate::distance::{between, measured};
|
|
use crate::params::{get, opt_id, opt_index};
|
|
use crate::snap::Guide;
|
|
use crate::{EngineError, Result, Session, bad, spec};
|
|
|
|
/// Most measurements starting from one layer.
|
|
pub const MAX_PER_LAYER: usize = 64;
|
|
/// Most measurements listed or drawn for one page.
|
|
pub const MAX_LISTED: usize = 2_000;
|
|
|
|
/// A measurement on a page: the layer it starts from, its index there, what it measures and the
|
|
/// lines it draws (page space).
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct Placed {
|
|
pub from: NodeId,
|
|
pub index: usize,
|
|
pub measurement: Measurement,
|
|
pub lines: Vec<Guide>,
|
|
}
|
|
|
|
impl Placed {
|
|
/// Whether it starts or ends at layer `id`.
|
|
pub fn touches(&self, id: NodeId) -> bool {
|
|
self.from == id || self.measurement.to == Some(id)
|
|
}
|
|
|
|
/// The distances it shows, as on its labels.
|
|
pub fn labels(&self) -> Vec<&str> {
|
|
self.lines.iter().filter_map(|g| g.label.as_deref()).collect()
|
|
}
|
|
}
|
|
|
|
/// The line along side `side` of box `b`, labelled with that side's length.
|
|
fn side_line(b: Rect, side: Side) -> Vec<Guide> {
|
|
let (p, q) = match side {
|
|
Side::Top => (Point::new(b.x0, b.y0), Point::new(b.x1, b.y0)),
|
|
Side::Bottom => (Point::new(b.x0, b.y1), Point::new(b.x1, b.y1)),
|
|
Side::Left => (Point::new(b.x0, b.y0), Point::new(b.x0, b.y1)),
|
|
Side::Right => (Point::new(b.x1, b.y0), Point::new(b.x1, b.y1)),
|
|
Side::Auto => return Vec::new(),
|
|
};
|
|
let mut out = Vec::new();
|
|
measured(&mut out, p, q);
|
|
out
|
|
}
|
|
|
|
/// The lines measurement `m` from layer `from` draws, in page space (none when a layer is gone).
|
|
pub fn lines(doc: &Document, from: NodeId, m: Measurement) -> Vec<Guide> {
|
|
let Some(a) = doc.world_bounds(from) else { return Vec::new() };
|
|
match (m.to, m.side) {
|
|
(Some(to), _) => doc.world_bounds(to).map(|b| between(a, b)).unwrap_or_default(),
|
|
(None, Some(side)) => side_line(a, side),
|
|
(None, None) => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// The measurements on `page` whose layers are both there, in layer order (bounded).
|
|
pub fn on_page(doc: &Document, page: NodeId) -> Vec<Placed> {
|
|
let mut out = Vec::new();
|
|
let mut stack = vec![page];
|
|
let mut visited = 0usize;
|
|
while let Some(id) = stack.pop() {
|
|
visited += 1;
|
|
if out.len() >= MAX_LISTED || visited > 200_000 {
|
|
break;
|
|
}
|
|
let Some(n) = doc.node(id) else { continue };
|
|
for (index, m) in n.measurements.iter().enumerate() {
|
|
if m.to.is_some_and(|t| t == id || doc.page_of(t) != Some(page)) {
|
|
continue;
|
|
}
|
|
out.push(Placed { from: id, index, measurement: *m, lines: lines(doc, id, *m) });
|
|
}
|
|
stack.extend(n.children.iter().rev().copied());
|
|
}
|
|
out
|
|
}
|
|
|
|
fn placed_json(doc: &Document, p: &Placed) -> Value {
|
|
let name = |id: NodeId| doc.node(id).map(|n| n.name.clone()).unwrap_or_default();
|
|
let mut j = json!({"from": p.from, "fromName": name(p.from), "index": p.index, "labels": p.labels(), "lines": p.lines});
|
|
if let Some(t) = p.measurement.to {
|
|
j["to"] = json!(t);
|
|
j["toName"] = json!(name(t));
|
|
}
|
|
if let Some(s) = p.measurement.side {
|
|
j["side"] = json!(s);
|
|
}
|
|
j
|
|
}
|
|
|
|
/// The layer a command starts from: `from`, else the first selected layer; never a page.
|
|
fn from_layer(cmd: &str, s: &Session, p: &Value) -> Result<NodeId> {
|
|
let id = match opt_id(cmd, p, "from")? {
|
|
Some(id) => id,
|
|
None => s.selection().first().copied().ok_or_else(|| bad(cmd, "select a layer or pass `from`"))?,
|
|
};
|
|
let n = s.doc()?.node(id).ok_or(EngineError::NoNode(id))?;
|
|
if n.is_page() {
|
|
return Err(bad(cmd, "measure from a layer, not a page"));
|
|
}
|
|
Ok(id)
|
|
}
|
|
|
|
/// The `side` parameter: top, right, bottom or left.
|
|
fn side_param(cmd: &str, p: &Value) -> Result<Option<Side>> {
|
|
match get(p, "side") {
|
|
None | Some(Value::Null) => Ok(None),
|
|
Some(Value::String(v)) => Side::from_id(v).filter(|s| *s != Side::Auto).map(Some).ok_or_else(|| bad(cmd, "side: top, right, bottom or left")),
|
|
Some(_) => Err(bad(cmd, "side: top, right, bottom or left")),
|
|
}
|
|
}
|
|
|
|
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "devMode.addMeasurement";
|
|
let from = from_layer(cmd, s, p)?;
|
|
let (to, side) = (opt_id(cmd, p, "to")?, side_param(cmd, p)?);
|
|
let doc = s.doc()?;
|
|
let n = doc.node(from).ok_or(EngineError::NoNode(from))?;
|
|
let to = match (to, side) {
|
|
(Some(_), Some(_)) => return Err(bad(cmd, "pass `to` or `side`, not both")),
|
|
(Some(t), None) => Some(t),
|
|
(None, Some(_)) => None,
|
|
// Neither: to the layer around it.
|
|
(None, None) => Some(
|
|
n.parent
|
|
.filter(|q| doc.node(*q).is_some_and(|q| !q.is_page()))
|
|
.ok_or_else(|| bad(cmd, "pass `to` or `side` (the layer is at the top of its page)"))?,
|
|
),
|
|
};
|
|
if let Some(t) = to {
|
|
let target = doc.node(t).ok_or(EngineError::NoNode(t))?;
|
|
if t == from {
|
|
return Err(bad(cmd, "measure to another layer, or across a `side` of this one"));
|
|
}
|
|
if target.is_page() || doc.page_of(t) != doc.page_of(from) {
|
|
return Err(bad(cmd, "measure to a layer on the same page"));
|
|
}
|
|
}
|
|
let m = Measurement { to, side };
|
|
// The same two layers measured from the other end are the same measurement.
|
|
let back = to.and_then(|t| doc.node(t)).is_some_and(|t| t.measurements.contains(&Measurement { to: Some(from), side: None }));
|
|
if n.measurements.contains(&m) || back {
|
|
return Err(bad(cmd, "that measurement is already there"));
|
|
}
|
|
if n.measurements.len() >= MAX_PER_LAYER {
|
|
return Err(bad(cmd, format!("a layer starts at most {MAX_PER_LAYER} measurements")));
|
|
}
|
|
let drawn = lines(doc, from, m);
|
|
let n = s.doc_mut()?.get_mut(from)?;
|
|
n.measurements.push(m);
|
|
Ok(json!({"from": from, "index": n.measurements.len() - 1, "lines": drawn}))
|
|
}
|
|
|
|
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "devMode.removeMeasurement";
|
|
let from = from_layer(cmd, s, p)?;
|
|
let index = opt_index(cmd, p, "index")?;
|
|
let n = s.doc_mut()?.get_mut(from)?;
|
|
let count = n.measurements.len();
|
|
if count == 0 {
|
|
return Err(bad(cmd, "no measurement starts from this layer"));
|
|
}
|
|
let removed = match index {
|
|
Some(i) if i < count => {
|
|
n.measurements.remove(i);
|
|
1
|
|
}
|
|
Some(i) => return Err(bad(cmd, format!("no measurement {i} (the layer starts {count})"))),
|
|
None => std::mem::take(&mut n.measurements).len(),
|
|
};
|
|
Ok(json!({"removed": removed}))
|
|
}
|
|
|
|
fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "devMode.measurements";
|
|
let doc = s.doc()?;
|
|
let page = match opt_id(cmd, p, "page")? {
|
|
Some(pg) if doc.node(pg).is_some_and(Node::is_page) => pg,
|
|
Some(_) => return Err(bad(cmd, "`page` must be a page id")),
|
|
None => s.page()?,
|
|
};
|
|
let only = opt_id(cmd, p, "id")?;
|
|
Ok(Value::Array(on_page(doc, page).iter().filter(|m| only.is_none_or(|id| m.touches(id))).map(|m| placed_json(doc, m)).collect()))
|
|
}
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
spec!(
|
|
"devMode.addMeasurement",
|
|
"Add measurement",
|
|
"{from? (default the first selected layer), to? (a layer on the same page), side? (top|right|bottom|left)} keeps a measurement on the canvas: between `from` and `to`, across one side of `from`, or (neither) to the layer around it; Dev Mode draws it and it follows the layers → {from, index, lines}",
|
|
true,
|
|
add
|
|
),
|
|
spec!(
|
|
"devMode.removeMeasurement",
|
|
"Remove measurement",
|
|
"{from? (default the first selected layer), index? (omit: every measurement starting from the layer)} → {removed}",
|
|
true,
|
|
remove
|
|
),
|
|
spec!(
|
|
"devMode.measurements",
|
|
"List measurements",
|
|
"{page? (default the current page), id? (only those from or to this layer)} → [{from, fromName, index, to?, toName?, side?, labels, lines: [{x0, y0, x1, y1, label, dashed}]}] (page space)",
|
|
false,
|
|
list
|
|
),
|
|
]
|
|
}
|