Fix #1372: Dimension tool and Object > Dimensions: linked length, angle, radius and diameter annotations

This commit is contained in:
Osama Amin
2026-10-16 01:36:24 +07:00
committed by bflatastic
parent 6f23190925
commit 9d813e906a
34 changed files with 2220 additions and 4 deletions
+5
View File
@@ -98,3 +98,8 @@ sync_github_name = false
real_name = "Tomasz Lewandowski"
display_name = "Tomasz Lewandowski"
sync_github_name = false
[people.osamaamin2011]
real_name = "Osama Amin"
display_name = "Osama Amin"
sync_github_name = false
+341
View File
@@ -0,0 +1,341 @@
//! Persistent dimension recipes. Children are ordinary paths and type for all renderers/exporters.
use crate::{Document, NodeId, Unit};
use serde::{Deserialize, Serialize};
use vectorcraft_geom::{Affine, Point};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
pub struct DimensionOptions {
pub units: String,
pub precision: usize,
/// Drawing : real size. 1:10 multiplies the drawn length by ten.
pub scale: String,
pub hide_units: bool,
pub line_weight: f64,
pub line_type: String,
pub line_color: [u8; 3],
pub arrow_style: String,
pub arrow_scale: f64,
pub extension_weight: f64,
pub extension_type: String,
pub extension_color: [u8; 3],
pub extension_offset: f64,
pub extension_length: f64,
pub hide_extensions: bool,
pub font_family: String,
pub font_style: String,
pub font_size: f64,
pub text_color: [u8; 3],
pub text_position: String,
pub text_alignment: String,
pub text_offset: f64,
}
impl Default for DimensionOptions {
fn default() -> Self {
Self {
units: "cm".into(),
precision: 1,
scale: "1:1".into(),
hide_units: false,
line_weight: 1.0,
line_type: "solid".into(),
line_color: [224, 55, 65],
arrow_style: "filled".into(),
arrow_scale: 100.0,
extension_weight: 0.5,
extension_type: "solid".into(),
extension_color: [224, 55, 65],
extension_offset: 5.0,
extension_length: 9.0,
hide_extensions: false,
font_family: "Arial".into(),
font_style: "Regular".into(),
font_size: 14.0,
text_color: [224, 55, 65],
text_position: "above".into(),
text_alignment: "aligned".into(),
text_offset: 9.0,
}
}
}
impl DimensionOptions {
pub fn unit(&self) -> Option<Unit> {
Some(match self.units.as_str() {
"pt" => Unit::Points,
"px" => Unit::Pixels,
"mm" => Unit::Millimeters,
"cm" => Unit::Centimeters,
"m" => Unit::Meters,
"in" => Unit::Inches,
"ft" => Unit::Feet,
"yd" => Unit::Yards,
_ => return None,
})
}
pub fn factor(&self) -> Result<f64, String> {
let (a, b) = self.scale.split_once(':').ok_or("Scale must be drawing:real, for example 1:10")?;
let parse = |s: &str| s.trim().parse::<f64>().ok().filter(|n| n.is_finite() && *n > 0.0 && *n <= 1e9);
let a = parse(a).ok_or("Invalid drawing scale")?;
let b = parse(b).ok_or("Invalid real scale")?;
let f = b / a;
if !(1e-6..=1e6).contains(&f) {
return Err("Scale ratio must be between 1:1000000 and 1000000:1".into());
}
Ok(f)
}
pub fn validate(&self) -> Result<(), String> {
self.factor()?;
if self.unit().is_none() || self.precision > 6 {
return Err("Choose a length unit and precision from 0 to 6".into());
}
for (v, min, max) in [
(self.line_weight, 0.1, 100.0),
(self.extension_weight, 0.1, 100.0),
(self.arrow_scale, 1.0, 1000.0),
(self.font_size, 1.0, 1000.0),
(self.extension_offset, 0.0, 10000.0),
(self.extension_length, 0.0, 10000.0),
(self.text_offset, 0.0, 10000.0),
] {
if !v.is_finite() || v < min || v > max {
return Err("Dimension size is outside its supported range".into());
}
}
if !["solid", "dashed", "dotted"].contains(&self.line_type.as_str())
|| !["solid", "dashed", "dotted"].contains(&self.extension_type.as_str())
|| !["filled", "open", "tick", "none"].contains(&self.arrow_style.as_str())
|| !["above", "center", "below"].contains(&self.text_position.as_str())
|| !["aligned", "horizontal"].contains(&self.text_alignment.as_str())
{
return Err("Unknown dimension style".into());
}
if self.font_family.is_empty() || self.font_family.len() > 256 || self.font_style.len() > 128 {
return Err("Invalid dimension font name".into());
}
Ok(())
}
pub fn label(&self, points: f64, angle: bool) -> Result<String, String> {
self.validate()?;
let value = if angle { points } else { points * self.factor()? / self.unit().ok_or("Unknown unit")?.points() };
if !value.is_finite() {
return Err("Non-finite dimension".into());
}
let suffix = if self.hide_units {
""
} else if angle {
"°"
} else {
self.units.as_str()
};
let sep = if suffix.is_empty() || angle { "" } else { " " };
Ok(format!("{:.*}{sep}{suffix}", self.precision, value))
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SegmentRef {
pub node: NodeId,
pub subpath: usize,
pub segment: usize,
}
impl SegmentRef {
pub fn endpoints(&self, doc: &Document) -> Option<(Point, Point, f64)> {
let sp = doc.node(self.node)?.path_data()?.subpaths.get(self.subpath)?;
if self.segment >= sp.segment_count() {
return None;
}
let seg = sp.segment(self.segment);
use vectorcraft_geom::kurbo::ParamCurveArclen;
Some((seg.p0, seg.p3, if sp.segment_is_line(self.segment) { seg.p0.distance(seg.p3) } else { seg.arclen(1e-5) }))
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
pub enum DimensionSource {
Segment { edge: SegmentRef },
Angle { first: SegmentRef, second: SegmentRef },
Circle { node: NodeId },
Points { a: Option<PointRef>, b: Option<PointRef>, vertex: Option<PointRef> },
}
impl DimensionSource {
pub fn nodes(&self) -> Vec<NodeId> {
match self {
Self::Segment { edge } => vec![edge.node],
Self::Angle { first, second } => vec![first.node, second.node],
Self::Circle { node } => vec![*node],
Self::Points { a, b, vertex } => [a, b, vertex].into_iter().filter_map(|p| p.as_ref().map(|r| r.node)).collect(),
}
}
/// Anchor indexes cannot safely follow a topology replacement. Ordinary moves and handle
/// edits keep their topology and remain linked.
pub fn topology_changed(&self, before: &Document, after: &Document) -> bool {
if matches!(self, Self::Circle { .. }) {
return false;
}
self.nodes().iter().any(|id| {
let counts = |doc: &Document| {
doc.node(*id).and_then(|n| n.path_data()).map(|p| p.subpaths.iter().map(|sp| (sp.anchors.len(), sp.closed)).collect::<Vec<_>>())
};
let old = counts(before);
old.is_some() && old != counts(after)
})
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PointRef {
pub node: NodeId,
pub subpath: usize,
pub anchor: usize,
}
impl PointRef {
pub fn position(&self, doc: &Document) -> Option<Point> {
Some(doc.node(self.node)?.path_data()?.subpaths.get(self.subpath)?.anchors.get(self.anchor)?.p)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Dimension {
/// linear, angular, radius or diameter.
pub mode: String,
pub a: Point,
pub b: Point,
/// Angular vertex. For radius/diameter a is the centre and b lies on the circle.
pub vertex: Point,
pub offset: f64,
pub length: f64,
pub options: DimensionOptions,
pub source: Option<DimensionSource>,
}
impl Dimension {
pub fn transform(&mut self, xf: Affine) {
// Linked recipes are resolved again from the source by the engine.
if self.source.is_none() {
self.a = xf * self.a;
self.b = xf * self.b;
self.vertex = xf * self.vertex;
self.length = self.a.distance(self.b);
}
}
pub fn resolve(&mut self, doc: &Document) -> Result<(), String> {
match &self.source {
Some(DimensionSource::Points { a, b, vertex }) => {
for (reference, point) in [(a, &mut self.a), (b, &mut self.b), (vertex, &mut self.vertex)] {
if let Some(r) = reference {
*point = r.position(doc).ok_or("Measured anchor no longer exists")?;
}
}
self.length = self.a.distance(self.b);
}
Some(DimensionSource::Segment { edge }) => {
let (a, b, length) = edge.endpoints(doc).ok_or("Measured segment no longer exists")?;
self.a = a;
self.b = b;
self.length = length;
}
Some(DimensionSource::Angle { first, second }) => {
for edge in [first, second] {
let sp = doc
.node(edge.node)
.and_then(|n| n.path_data())
.and_then(|p| p.subpaths.get(edge.subpath))
.ok_or("Measured path no longer exists")?;
if edge.segment >= sp.segment_count() || !sp.segment_is_line(edge.segment) {
return Err("Angles require two straight segments".into());
}
}
let (a, b, _) = first.endpoints(doc).ok_or("First measured segment no longer exists")?;
let (c, d, _) = second.endpoints(doc).ok_or("Second measured segment no longer exists")?;
let u = b - a;
let v = d - c;
let den = u.cross(v);
if den.abs() < 1e-9 {
return Err("Choose two non-parallel straight segments".into());
}
let p = a + u * ((c - a).cross(v) / den);
self.vertex = p;
self.a = if a.distance(p) > b.distance(p) { a } else { b };
self.b = if c.distance(p) > d.distance(p) { c } else { d };
}
Some(DimensionSource::Circle { node }) => {
let (center, radius) = circle(doc, *node).ok_or("Choose a circular path")?;
let direction = self.b - self.a;
let dir = if direction.hypot() > 1e-9 { direction / direction.hypot() } else { vectorcraft_geom::Vec2::new(1.0, 0.0) };
self.a = center;
self.b = center + dir * radius;
self.length = radius;
}
None => {}
}
self.validate()
}
pub fn validate(&self) -> Result<(), String> {
self.options.validate()?;
for p in [self.a, self.b, self.vertex] {
if !p.x.is_finite() || !p.y.is_finite() || p.x.abs() > 1e8 || p.y.abs() > 1e8 {
return Err("Dimension point is outside the canvas".into());
}
}
if !self.offset.is_finite() || self.offset.abs() > 1e6 || !self.length.is_finite() || self.length < 0.0 || self.length > 1e9 {
return Err("Invalid dimension distance".into());
}
if !["linear", "angular", "radius", "diameter"].contains(&self.mode.as_str()) {
return Err("Unknown dimension mode".into());
}
if self.a.distance(self.b) < 1e-6 {
return Err("Choose two different points".into());
}
if self.mode == "angular" && (self.a.distance(self.vertex) < 1e-6 || self.b.distance(self.vertex) < 1e-6) {
return Err("Choose two nonzero angle arms".into());
}
Ok(())
}
pub fn angle(&self) -> f64 {
let u = self.a - self.vertex;
let v = self.b - self.vertex;
u.cross(v).atan2(u.dot(v))
}
pub fn label(&self) -> Result<String, String> {
let (value, prefix) = match self.mode.as_str() {
"angular" => (self.angle().abs().to_degrees(), ""),
"radius" => (self.a.distance(self.b), "R "),
"diameter" => (self.a.distance(self.b) * 2.0, "Ø "),
_ => (self.length, if self.length > self.a.distance(self.b) + 0.01 { "Arc " } else { "" }),
};
Ok(format!("{prefix}{}", self.options.label(value, self.mode == "angular")?))
}
}
/// Accept circular paths (including imported circles), reject ellipses and arbitrary closed shapes.
pub fn circle(doc: &Document, id: NodeId) -> Option<(Point, f64)> {
use vectorcraft_geom::kurbo::ParamCurve;
let path = doc.node(id)?.path_data()?;
let sp = path.subpaths.first()?;
if path.subpaths.len() != 1 || !sp.closed || !(2..=64).contains(&sp.segment_count()) {
return None;
}
let bounds = path.bounds()?;
let center = bounds.center();
let radius = bounds.width() * 0.5;
if radius < 1e-6 || (bounds.height() * 0.5 - radius).abs() > radius * 0.002 {
return None;
}
for i in 0..sp.segment_count() {
for t in [0.0, 0.25, 0.5, 0.75] {
if (sp.segment(i).eval(t).distance(center) - radius).abs() > radius * 0.002 {
return None;
}
}
}
Some((center, radius))
}
+8
View File
@@ -12,6 +12,7 @@ pub mod clipnest;
pub mod cmyk;
pub mod corners;
pub mod depth;
pub mod dimension;
pub mod graph;
pub mod hit;
mod id_index;
@@ -1399,6 +1400,13 @@ impl Document {
pub fn reid(&mut self, node: &Node) -> Node {
let mut n = node.clone();
n.id = self.alloc_id();
// A copied annotation must never bind its source ids to unrelated art in a different
// document. Keep the measured geometry; new measurements can be linked after pasting.
if let Some(e) = n.extras_if_any()
&& let Some(dimension) = &mut e.dimension
{
dimension.source = None;
}
if let Some(ch) = n.children_mut() {
let old: Vec<Arc<Node>> = std::mem::take(ch);
*ch = old.iter().map(|c| Arc::new(self.reid(c))).collect();
+15
View File
@@ -555,6 +555,9 @@ pub struct NodeExtras {
pub slice: Option<Box<crate::SliceOptions>>,
/// Object › Perspective: the perspective grid plane the object is attached to.
pub perspective: Option<Box<crate::PerspectiveAttachment>>,
/// Dimension object: the live measurement it draws; its ordinary group children hold its
/// exportable appearance (Object › Dimensions, the Dimension tool).
pub dimension: Option<Box<crate::dimension::Dimension>>,
}
/// No extras: what [`Node::extras`] gives for a node without any.
@@ -569,6 +572,7 @@ static NO_EXTRAS: NodeExtras = NodeExtras {
attrs: None,
slice: None,
perspective: None,
dimension: None,
};
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
@@ -699,6 +703,7 @@ impl Serialize for Node {
e.slice.is_none(),
is_default(&self.bbox_angle),
e.perspective.is_none(),
e.dimension.is_none(),
];
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
let mut skip = skips.into_iter();
@@ -733,6 +738,7 @@ impl Serialize for Node {
field!("slice", &e.slice);
field!("bbox_angle", &self.bbox_angle);
field!("perspective", &e.perspective);
field!("dimension", &e.dimension);
st.end()
}
}
@@ -784,6 +790,8 @@ struct NodeWire {
bbox_angle: f64,
#[serde(default)]
perspective: Option<Box<crate::PerspectiveAttachment>>,
#[serde(default)]
dimension: Option<Box<crate::dimension::Dimension>>,
}
impl From<NodeWire> for Node {
@@ -799,6 +807,7 @@ impl From<NodeWire> for Node {
attrs: w.attrs,
slice: w.slice,
perspective: w.perspective,
dimension: w.dimension,
};
Node {
id: w.id,
@@ -1159,6 +1168,12 @@ impl Node {
self.transform_scaled(a, &scaling.into());
}
fn transform_scaled(&mut self, a: Affine, sc: &Scaling) {
// A dimension's measurement moves with it.
if let Some(e) = self.extras_if_any()
&& let Some(dimension) = &mut e.dimension
{
dimension.transform(a);
}
if !self.is_layer() {
self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a);
}
+423
View File
@@ -0,0 +1,423 @@
//! Linked dimensions: generated groups share the existing text/path render and export pipeline.
use super::*;
use serde_json::{Value, json};
use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::dimension::{Dimension, DimensionOptions, DimensionSource, SegmentRef};
use vectorcraft_doc::{Appearance, Arrowhead, CharStyle, Dash, Document, Node, NodeId, NodeKind, TextObject};
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Vec2};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"dimension.create",
"Create Dimension",
[],
None,
"{mode?: linear|angular|radius|diameter, a?: [x,y], b?: [x,y], vertex?: [x,y], source?: {kind: segment, edge: {node,subpath,segment}}|{kind: angle,first: edge,second: edge}|{kind: circle,node}, offset?: pt, options?: {units,precision,scale,hideUnits,lineWeight,lineType,lineColor,arrowStyle,arrowScale,extensionWeight,extensionType,extensionColor,extensionOffset,extensionLength,hideExtensions,fontFamily,fontStyle,fontSize,textColor,textPosition,textAlignment,textOffset}} → {id,label}; scale is drawing:real; linked measurements follow source edits",
has_doc,
create
),
cmd!(
"dimension.selection",
"Dimension Selected Paths",
["Object", "Dimensions"],
None,
"{offset?: pt = 30, options?: dimension options} add a linked dimension to every segment of the selected paths (up to 1000), as one undo step → {ids}",
has_selection,
selection
),
cmd!(
"dimension.update",
"Update Dimensions",
[],
None,
"{ids?: [dimension ids] (default selection), options?: dimension options, offset?: pt} edit existing dimensions → {ids}",
has_doc,
update
),
]
}
fn error(s: impl ToString) -> EngineError {
EngineError::Other(s.to_string())
}
fn options(s: &Session, p: &Value) -> Result<DimensionOptions> {
let default = s.tool_options_of("dimension");
let value = p.get("options").cloned().unwrap_or_else(|| default.get("style").cloned().unwrap_or(json!({})));
let o: DimensionOptions = serde_json::from_value(value).map_err(error)?;
o.validate().map_err(error)?;
Ok(o)
}
fn point(p: &Value, key: &str) -> Result<Point> {
let a = p.get(key).and_then(Value::as_array).filter(|a| a.len() == 2).ok_or_else(|| error(format!("{key} must be [x,y]")))?;
let x = a.first().and_then(Value::as_f64).ok_or_else(|| error("Invalid point"))?;
let y = a.get(1).and_then(Value::as_f64).ok_or_else(|| error("Invalid point"))?;
Ok(Point::new(x, y))
}
fn distance(p: &Value, fallback: f64) -> Result<f64> {
match p.get("offset") {
Some(v) => v.as_f64().filter(|n| n.is_finite() && n.abs() <= 1e6).ok_or_else(|| error("Invalid dimension offset")),
None => Ok(fallback),
}
}
/// Return the signed winding area of a closed path subpath. Positive area means
/// the path interior is to the left of its directed segments, which lets the
/// automatic dimension command put its positive (left-normal) offset outside.
fn subpath_area(path: &PathData, subpath: usize) -> Option<f64> {
use vectorcraft_geom::kurbo::ParamCurve;
let sp = path.subpaths.get(subpath)?;
if !sp.closed || sp.segment_count() == 0 {
return None;
}
let mut area = 0.0;
for index in 0..sp.segment_count() {
let segment = sp.segment(index);
let mut previous = segment.eval(0.0);
for step in 1..=16 {
let current = segment.eval(step as f64 / 16.0);
area += previous.x * current.y - current.x * previous.y;
previous = current;
}
}
(area.is_finite() && area.abs() > 1e-9).then_some(area * 0.5)
}
fn recipe(s: &Session, p: &Value) -> Result<Dimension> {
let mode = p.get("mode").map_or(Ok("linear"), |v| v.as_str().ok_or_else(|| error("Invalid dimension mode")))?;
let source: Option<DimensionSource> = p.get("source").map(|v| serde_json::from_value(v.clone()).map_err(error)).transpose()?;
if let Some(source) = &source {
let doc = &s.doc()?.doc;
for id in source.nodes() {
if doc.ancestry(id).is_some_and(|path| path.iter().any(|id| doc.node(*id).is_some_and(|n| n.extras().dimension.is_some()))) {
return Err(error("Choose original artwork, not a dimension's generated paths"));
}
}
}
if let Some(source) = &source {
let compatible = matches!(
(mode, source),
("linear", DimensionSource::Segment { .. })
| ("angular", DimensionSource::Angle { .. })
| ("radius" | "diameter", DimensionSource::Circle { .. })
);
if !compatible && !matches!((mode, source), ("linear" | "angular", DimensionSource::Points { .. })) {
return Err(error("Dimension mode does not match its source"));
}
}
let a = if source.is_none() || p.get("a").is_some() { point(p, "a")? } else { Point::ZERO };
let b = if source.is_none() || p.get("b").is_some() { point(p, "b")? } else { Point::new(1.0, 0.0) };
let vertex = if mode == "angular" && (source.is_none() || matches!(source, Some(DimensionSource::Points { .. }))) {
point(p, "vertex")?
} else {
Point::ZERO
};
let mut d = Dimension { mode: mode.into(), a, b, vertex, offset: distance(p, 30.0)?, length: a.distance(b), options: options(s, p)?, source };
d.resolve(&s.doc()?.doc).map_err(error)?;
Ok(d)
}
fn insert(doc: &mut Document, parent: Option<NodeId>, dim: Dimension) -> Result<NodeId> {
let id = doc.alloc_id();
let mut group = Node::new(id, NodeKind::Group { children: vec![], clip: false });
group.name = Some(format!("Dimension · {}", dim.label().map_err(error)?));
group.kind = NodeKind::Group { children: artwork(doc, &dim, &[])?, clip: false };
group.extras_mut().dimension = Some(Box::new(dim));
doc.insert(parent, usize::MAX, group)?;
Ok(id)
}
fn create(s: &mut Session, p: &Value) -> Result<Value> {
let dim = recipe(s, p)?;
let label = dim.label().map_err(error)?;
let parent = s.doc()?.insertion_parent();
s.note_journal("options", serde_json::to_value(&dim.options).map_err(error)?);
let id = s.edit("Create Dimension", |doc, sel| {
let id = insert(doc, parent, dim)?;
sel.set([id]);
Ok(id)
})?;
Ok(json!({"id":id.0,"label":label}))
}
fn selection(s: &mut Session, p: &Value) -> Result<Value> {
let o = options(s, p)?;
let offset = distance(p, 30.0)?;
let st = s.doc()?;
let mut refs = Vec::new();
fn gather(n: &Node, refs: &mut Vec<SegmentRef>) -> Result<()> {
if !n.visible || n.locked || n.extras().dimension.is_some() {
return Ok(());
}
if let Some(path) = n.path_data() {
for (si, sp) in path.subpaths.iter().enumerate() {
for seg in 0..sp.segment_count() {
if refs.len() >= 1000 {
return Err(error("Select at most 1000 segments at a time"));
}
if sp.segment(seg).p0.distance(sp.segment(seg).p3) > 1e-6 {
refs.push(SegmentRef { node: n.id, subpath: si, segment: seg });
}
}
}
}
if let Some(children) = n.children() {
for c in children {
gather(c, refs)?;
}
}
Ok(())
}
for id in &st.selection.objects {
if !st.doc.is_editable(*id) {
continue;
}
if let Some(n) = st.doc.node(*id) {
gather(n, &mut refs)?;
}
}
refs.sort_by_key(|r| (r.node, r.subpath, r.segment));
refs.dedup();
if refs.is_empty() {
return Err(error("Select one or more paths first"));
}
let parent = st.insertion_parent();
let offsets: Vec<f64> = refs
.iter()
.map(|edge| {
st.doc
.node(edge.node)
.and_then(Node::path_data)
.and_then(|path| subpath_area(path, edge.subpath))
.map_or(offset, |area| -area.signum() * offset.abs())
})
.collect();
s.note_journal("options", serde_json::to_value(&o).map_err(error)?);
let ids = s.edit("Dimension Selected Paths", |doc, sel| {
let mut ids = Vec::new();
for (edge, edge_offset) in refs.into_iter().zip(offsets) {
let mut dim = Dimension {
mode: "linear".into(),
a: Point::ZERO,
b: Point::ZERO,
vertex: Point::ZERO,
offset: edge_offset,
length: 0.0,
options: o.clone(),
source: Some(DimensionSource::Segment { edge }),
};
dim.resolve(doc).map_err(error)?;
ids.push(insert(doc, parent, dim)?);
}
sel.set(ids.clone());
Ok(ids)
})?;
Ok(json!({"ids":ids}))
}
fn update(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<NodeId> = match p.get("ids") {
Some(v) => serde_json::from_value(v.clone()).map_err(error)?,
None => s.doc()?.selection.objects.clone(),
};
if ids.is_empty() || ids.len() > 1000 {
return Err(error("Select dimensions to update (at most 1000)"));
}
let o = if p.get("options").is_some() { Some(options(s, p)?) } else { None };
s.edit("Update Dimensions", |doc, _| {
for id in &ids {
if !doc.is_editable(*id) {
return Err(error("Dimension is locked or hidden"));
}
let mut dim =
doc.node(*id).and_then(|n| n.extras().dimension.as_deref()).cloned().ok_or_else(|| error("Select only dimension groups"))?;
if let Some(o) = &o {
dim.options = o.clone();
}
dim.offset = distance(p, dim.offset)?;
dim.resolve(doc).map_err(error)?;
replace(doc, *id, dim)?;
}
Ok(())
})?;
Ok(json!({"ids":ids}))
}
fn replace(doc: &mut Document, id: NodeId, dim: Dimension) -> Result<()> {
let old: Vec<NodeId> = doc.node(id).and_then(Node::children).map(|ch| ch.iter().map(|n| n.id).collect()).unwrap_or_default();
let children = artwork(doc, &dim, &old)?;
if let Some(n) = doc.node_mut(id) {
n.kind = NodeKind::Group { children, clip: false };
n.name = Some(format!("Dimension · {}", dim.label().map_err(error)?));
n.extras_mut().dimension = Some(Box::new(dim));
}
Ok(())
}
/// Called inside the source edit transaction, so its dimensions and undo history stay in sync.
pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<()> {
let mut dimensions = Vec::new();
doc.walk(|n| {
if let Some(d) = &n.extras().dimension {
dimensions.push((n.id, (**d).clone()));
}
});
for (id, old) in dimensions {
let mut next = old.clone();
if next.source.as_ref().is_some_and(|s| s.topology_changed(before, doc)) || next.resolve(doc).is_err() {
// A deleted/degenerate source has no valid measurement. Remove its annotation in
// this same undo step instead of keeping a silently wrong number.
doc.remove(id)?;
} else if next != old || before.node(id).and_then(|n| n.extras().dimension.as_deref()).is_some_and(|d| d != &next) {
replace(doc, id, next)?;
}
}
Ok(())
}
fn paint(c: [u8; 3]) -> Paint {
Paint::solid(Color::rgb8(c[0], c[1], c[2]))
}
fn line(a: Point, b: Point) -> BezPath {
let mut p = BezPath::new();
p.move_to(a);
p.line_to(b);
p
}
fn appearance(o: &DimensionOptions, ext: bool, arrows: bool) -> Appearance {
let (color, width, kind) =
if ext { (o.extension_color, o.extension_weight, o.extension_type.as_str()) } else { (o.line_color, o.line_weight, o.line_type.as_str()) };
let mut a = Appearance::basic(Paint::None, paint(color), width);
if let Some(st) = a.stroke_mut() {
st.dash = match kind {
"dashed" => Some(Dash { pattern: vec![width * 5.0, width * 3.0], ..Default::default() }),
"dotted" => {
st.cap = vectorcraft_doc::LineCap::Round;
Some(Dash { pattern: vec![0.0, width * 3.0], ..Default::default() })
}
_ => None,
};
if arrows {
let head = match o.arrow_style.as_str() {
"filled" => Some(Arrowhead::Triangle),
"open" => Some(Arrowhead::TriangleOpen),
"tick" => Some(Arrowhead::Slash),
_ => None,
};
st.start_arrow = head;
st.end_arrow = head;
st.arrow_scale = (o.arrow_scale, o.arrow_scale);
st.arrow_align = vectorcraft_doc::appearance::ArrowAlign::Tip;
}
}
a
}
fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result<Vec<Arc<Node>>> {
d.validate().map_err(error)?;
let o = &d.options;
let label = d.label().map_err(error)?;
let mut paths: Vec<(BezPath, Appearance)> = Vec::new();
let mut angle = 0.0;
let text_at;
let side = if d.offset < 0.0 { -1.0 } else { 1.0 };
let text_shift = match o.text_position.as_str() {
"below" => o.text_offset + o.font_size,
"center" => o.font_size * 0.35,
_ => -o.text_offset,
};
if d.mode == "angular" {
let start = (d.a - d.vertex).atan2();
let sweep = d.angle();
let r = d.offset.abs().max(12.0);
let on = |t: f64| d.vertex + Vec2::from_angle(start + sweep * t) * r;
let mut arc = BezPath::new();
arc.move_to(on(0.0));
for i in 1..=64 {
arc.line_to(on(i as f64 / 64.0));
}
paths.push((arc, appearance(o, false, true)));
if !o.hide_extensions {
for p in [d.a, d.b] {
let dir = (p - d.vertex) / (p - d.vertex).hypot();
paths.push((line(d.vertex + dir * o.extension_offset, d.vertex + dir * (r + o.extension_length)), appearance(o, true, false)));
}
}
text_at = d.vertex + Vec2::from_angle(start + sweep * 0.5) * (r + o.text_offset + o.font_size * 0.5);
} else {
let delta = d.b - d.a;
let u = delta / delta.hypot();
let n = Vec2::new(-u.y, u.x);
let (a, b) = match d.mode.as_str() {
"radius" => (d.a, d.b),
"diameter" => (d.a - delta, d.b),
_ => (d.a + n * d.offset, d.b + n * d.offset),
};
if d.mode == "linear" && !o.hide_extensions {
for (p, q) in [(d.a, a), (d.b, b)] {
paths.push((line(p + n * (o.extension_offset * side), q + n * (o.extension_length * side)), appearance(o, true, false)));
}
}
angle = u.atan2();
if !(-std::f64::consts::FRAC_PI_2..=std::f64::consts::FRAC_PI_2).contains(&angle) {
angle += std::f64::consts::PI;
}
let normal = Vec2::new(-angle.sin(), angle.cos());
text_at = a.midpoint(b) + normal * text_shift;
paths.push((line(a, b), appearance(o, false, true)));
}
if o.text_alignment == "horizontal" {
angle = 0.0;
}
let style = CharStyle {
font_family: o.font_family.clone(),
font_style: o.font_style.clone(),
size: o.font_size,
fill: paint(o.text_color),
..Default::default()
};
let mut text = TextObject::point(Point::ZERO, &label, style);
text.para.justify = vectorcraft_doc::Justify::Center;
super::typecmd::refresh_bounds(&mut text);
text.xf = Affine::translate(text_at.to_vec2()) * Affine::rotate(angle);
// Split the centre line around the label rather than paint an opaque white patch over art.
if o.text_position == "center"
&& d.mode != "angular"
&& let Some((path, app)) = paths.pop()
{
let elems = path.elements();
if let (Some(vectorcraft_geom::PathEl::MoveTo(a)), Some(vectorcraft_geom::PathEl::LineTo(b))) = (elems.first(), elems.get(1)) {
let delta = *b - *a;
let len = delta.hypot();
let u = delta / len;
let bounds = text.cached_bounds.unwrap_or_else(|| text.estimate_bounds());
let gap = (bounds.width() * 0.5 + 4.0).min(len * 0.45);
let mid = a.midpoint(*b);
let mut left = app.clone();
if let Some(st) = left.stroke_mut() {
st.end_arrow = None;
}
let mut right = app;
if let Some(st) = right.stroke_mut() {
st.start_arrow = None;
}
paths.push((line(*a, mid - u * gap), left));
paths.push((line(mid + u * gap, *b), right));
}
}
let mut children = Vec::new();
for (path, a) in paths {
let id = old.get(children.len()).copied().unwrap_or_else(|| doc.alloc_id());
children.push(Arc::new(Node::path(id, PathData::from_bezpath(&path), a)));
}
let id = old.get(children.len()).copied().unwrap_or_else(|| doc.alloc_id());
let mut node = Node::new(id, NodeKind::Text(Box::new(text)));
node.appearance = Appearance::basic(paint(o.text_color), Paint::None, 0.0).into();
children.push(Arc::new(node));
Ok(children)
}
+2
View File
@@ -12,6 +12,7 @@ pub mod colortheme;
mod create;
mod css;
mod cut;
pub(crate) mod dimension;
pub(crate) mod distortcmds;
mod docinfo;
mod docmenu;
@@ -202,6 +203,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(recolor::specs());
v.extend(fileio::specs());
v.extend(create::specs());
v.extend(dimension::specs());
v.extend(object::specs());
v.extend(path::specs());
v.extend(select::specs());
+5 -1
View File
@@ -1462,7 +1462,11 @@ impl Session {
let before = st.doc.clone();
let before_sel = st.selection.clone();
let doc = Arc::make_mut(&mut st.doc);
let result = match f(doc, &mut st.selection) {
// Dimensions follow the art they measure.
let result = match f(doc, &mut st.selection).and_then(|v| {
cmd::dimension::refresh(&before, doc)?;
Ok(v)
}) {
Ok(v) => match cmd::shaper::refresh(&before, Arc::make_mut(&mut st.doc)) {
Err(e) => Err(e),
Ok(()) => {
+11
View File
@@ -453,6 +453,17 @@ impl Session {
if let Some(k) = p.get("key").and_then(Value::as_str) {
values.insert(k.to_string(), p.get("value").cloned().unwrap_or(Value::Null));
}
if tool.unwrap_or(self.tool_id()) == "dimension" {
if let Some(style) = values.get("style") {
let options: vectorcraft_doc::dimension::DimensionOptions = serde_json::from_value(style.clone()).map_err(|e| e.to_string())?;
options.validate()?;
}
if let Some(mode) = values.get("mode")
&& !mode.as_str().is_some_and(|m| ["linear", "angular", "radius", "diameter"].contains(&m))
{
return Err("Unknown dimension mode".into());
}
}
Ok(self.set_tool_options(tool, &values))
}
+245
View File
@@ -0,0 +1,245 @@
//! Linked measurements, history, portable artwork and hostile command input.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use serde_json::{Value, json};
use vectorcraft_engine::{
Session,
doc::{Document, NodeId, NodeKind},
};
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width":600,"height":500})).unwrap();
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap()
}
fn edge(s: &mut Session) -> u64 {
run(s, "shape.line", json!({"x1":100,"y1":100,"x2":172,"y2":100}))["id"].as_u64().unwrap()
}
fn dim(s: &mut Session, node: u64) -> u64 {
run(
s,
"dimension.create",
json!({"source":{"kind":"segment","edge":{"node":node,"subpath":0,"segment":0}},"options":{"units":"in","precision":2,"scale":"1:10"}}),
)["id"]
.as_u64()
.unwrap()
}
fn text(s: &Session, id: u64) -> String {
let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap();
n.children().unwrap().iter().find_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).unwrap()
}
#[test]
fn dimension_length_scale_live_edit_undo_and_roundtrip() {
let mut s = session();
let line = edge(&mut s);
let id = dim(&mut s, line);
assert_eq!(text(&s, id), "10.00 in");
run(&mut s, "select.set", json!({"ids":[line]}));
run(&mut s, "object.scale", json!({"sx":200,"sy":100}));
assert_eq!(text(&s, id), "20.00 in");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s, id), "10.00 in");
run(&mut s, "edit.redo", json!({}));
assert_eq!(text(&s, id), "20.00 in");
let json = serde_json::to_string(&*s.doc().unwrap().doc).unwrap();
let doc: Document = serde_json::from_str(&json).unwrap();
let mut reopened = Session::new();
reopened.add_document(doc, None);
run(&mut reopened, "select.set", json!({"ids":[line]}));
run(&mut reopened, "object.scale", json!({"sx":50,"sy":100}));
assert_eq!(text(&reopened, id), "10.00 in");
run(&mut reopened, "edit.clear", json!({}));
assert!(reopened.doc().unwrap().doc.node(NodeId(id)).is_none());
run(&mut reopened, "edit.undo", json!({}));
assert_eq!(text(&reopened, id), "10.00 in");
}
#[test]
fn dimension_selected_rectangle_and_styles_are_one_undo() {
let mut s = session();
run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":72,"height":144}));
let result = run(
&mut s,
"dimension.selection",
json!({"options":{"units":"in","precision":0,"lineType":"dashed","arrowStyle":"open","hideExtensions":true,"textPosition":"center"}}),
);
let ids = result["ids"].as_array().unwrap();
assert_eq!(ids.len(), 4);
let labels: Vec<_> = ids.iter().map(|id| text(&s, id.as_u64().unwrap())).collect();
assert_eq!(labels.iter().filter(|s| s.as_str() == "1 in").count(), 2);
let id = NodeId(ids[0].as_u64().unwrap());
let node = s.doc().unwrap().doc.node(id).unwrap();
assert_eq!(node.children().unwrap().len(), 3); // Split line and text, no extension lines.
let stroke = node.children().unwrap()[0].appearance.stroke().unwrap();
assert!(stroke.dash.is_some());
assert_eq!(stroke.start_arrow, Some(vectorcraft_doc::Arrowhead::TriangleOpen));
run(&mut s, "edit.undo", json!({}));
assert!(s.doc().unwrap().doc.node(id).is_none());
run(&mut s, "edit.redo", json!({}));
assert!(s.doc().unwrap().doc.node(id).is_some());
}
#[test]
fn dimension_angles_circles_and_curved_lengths() {
let mut s = session();
let a = run(
&mut s,
"dimension.create",
json!({"mode":"angular","a":[100,0],"vertex":[0,0],"b":[0,100],"options":{"precision":1,"scale":"1:10"}}),
)["id"]
.as_u64()
.unwrap();
assert_eq!(text(&s, a), "90.0°");
let circle = run(&mut s, "shape.ellipse", json!({"x":100,"y":100,"width":144,"height":144}))["id"].as_u64().unwrap();
for (mode, want) in [("radius", "R 1.0 in"), ("diameter", "Ø 2.0 in")] {
let id = run(&mut s, "dimension.create", json!({"mode":mode,"source":{"kind":"circle","node":circle},"options":{"units":"in"}}))["id"]
.as_u64()
.unwrap();
assert_eq!(text(&s, id), want);
}
let curve = run(&mut s, "path.create", json!({"d":"M 0 0 C 0 100 100 100 100 0"}))["id"].as_u64().unwrap();
let id = dim(&mut s, curve);
assert!(text(&s, id).starts_with("Arc "));
let d = s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().dimension.as_ref().unwrap();
assert!((d.length - 200.0).abs() < 0.001);
}
#[test]
fn dimension_rejects_bad_input_without_mutation() {
let mut s = session();
let line = edge(&mut s);
let id = dim(&mut s, line);
let original = serde_json::to_value(&*s.doc().unwrap().doc).unwrap();
for p in [
json!({}),
json!({"a":[0,0],"b":[0,0]}),
json!({"a":[0,0],"b":[1e100,0]}),
json!({"a":[0,0],"b":[10,0],"options":{"precision":99999}}),
json!({"a":[0,0],"b":[10,0],"options":{"scale":"0:10"}}),
json!({"a":[0,0],"b":[10,0],"options":{"scale":"1:NaN"}}),
json!({"source":{"kind":"segment","edge":{"node":line,"subpath":0,"segment":18446744073709551615u64}}}),
json!({"mode":"angular","source":{"kind":"segment","edge":{"node":line,"subpath":0,"segment":0}}}),
] {
assert!(s.execute("dimension.create", &p).is_err(), "{p}");
assert_eq!(serde_json::to_value(&*s.doc().unwrap().doc).unwrap(), original);
}
assert!(s.execute("dimension.update", &json!({"ids":[id],"options":{"fontSize":-1}})).is_err());
assert_eq!(serde_json::to_value(&*s.doc().unwrap().doc).unwrap(), original);
}
#[test]
fn dimension_gesture_highlights_preview_commits_and_cancels() {
use vectorcraft_engine::{
ViewInfo,
tools::{Mods, PointerEvent, PointerKind, ToolKey},
};
let mut s = session();
edge(&mut s);
let view = ViewInfo::default();
s.select_tool("dimension", view).unwrap();
let before = s.doc().unwrap().doc.node_count();
s.pointer(&PointerEvent::new(PointerKind::Move, 130.0, 100.0), view).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 130.0, 100.0), view).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Move, 130.0, 60.0), view).unwrap();
assert!(s.in_interaction());
s.tool_key(ToolKey::Escape, Mods::default(), view).unwrap();
assert_eq!(s.doc().unwrap().doc.node_count(), before);
assert!(!s.in_interaction());
s.pointer(&PointerEvent::new(PointerKind::Down, 130.0, 100.0), view).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Move, 130.0, 60.0), view).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 130.0, 60.0), view).unwrap();
assert!(!s.in_interaction());
assert!(s.doc().unwrap().doc.node_count() > before);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc().unwrap().doc.node_count(), before);
}
#[test]
fn dimension_anchor_links_and_native_file_survive_source_moves() {
let mut s = session();
let first = edge(&mut s);
let second = edge(&mut s);
let id=run(&mut s,"dimension.create",json!({"a":[100,100],"b":[172,100],"source":{"kind":"points","a":{"node":first,"subpath":0,"anchor":0},"b":{"node":second,"subpath":0,"anchor":1},"vertex":null},"options":{"units":"pt","precision":0}}))["id"].as_u64().unwrap();
assert_eq!(text(&s, id), "72 pt");
let bytes = vectorcraft_format::save(&s.doc().unwrap().doc, false);
let doc = vectorcraft_format::load(&bytes).unwrap();
let mut s = Session::new();
s.add_document(doc, None);
run(&mut s, "select.set", json!({"ids":[second]}));
run(&mut s, "object.move", json!({"dx":28,"dy":0}));
assert_eq!(text(&s, id), "100 pt");
}
#[test]
fn dimension_options_are_persistent_and_invalid_updates_report_errors() {
let mut s = session();
s.set_tool_option_cmd(&json!({"tool":"dimension","values":{"style":{"scale":"10:1","units":"mm","precision":3},"mode":"diameter"}})).unwrap();
assert_eq!(s.tool_options_of("dimension")["style"]["scale"], "10:1");
assert!(s.set_tool_option_cmd(&json!({"tool":"dimension","values":{"style":{"scale":"1:0"}}})).is_err());
let o: vectorcraft_doc::dimension::DimensionOptions = serde_json::from_value(s.tool_options_of("dimension")["style"].clone()).unwrap();
assert_eq!(o.label(72.0, false).unwrap(), "2.540 mm");
let prefs = serde_json::to_value(&s.prefs).unwrap();
let mut fresh = Session::new();
fresh.prefs = serde_json::from_value(prefs).unwrap();
assert_eq!(fresh.tool_options_of("dimension")["style"], s.tool_options_of("dimension")["style"]);
}
#[test]
fn dimension_unlinked_geometry_scales_and_updates_its_label() {
let mut s = session();
let id = run(&mut s, "dimension.create", json!({"a":[100,100],"b":[172,100],"options":{"units":"in","precision":0}}))["id"].as_u64().unwrap();
run(&mut s, "object.scale", json!({"sx":200,"sy":200}));
assert_eq!(text(&s, id), "2 in");
}
#[test]
fn dimension_linked_angle_and_circle_update_and_reject_non_circles() {
let mut s = session();
let first = edge(&mut s);
let second = run(&mut s, "shape.line", json!({"x1":100,"y1":100,"x2":100,"y2":172}))["id"].as_u64().unwrap();
let id=run(&mut s,"dimension.create",json!({"mode":"angular","source":{"kind":"angle","first":{"node":first,"subpath":0,"segment":0},"second":{"node":second,"subpath":0,"segment":0}}}))["id"].as_u64().unwrap();
assert_eq!(text(&s, id), "90.0°");
run(&mut s, "select.set", json!({"ids":[second]}));
run(&mut s, "object.rotate", json!({"angle":45,"origin":[100,100]}));
assert!(["45.0°", "135.0°"].contains(&text(&s, id).as_str()));
let circle = run(&mut s, "shape.ellipse", json!({"x":100,"y":100,"width":144,"height":144}))["id"].as_u64().unwrap();
let id = run(
&mut s,
"dimension.create",
json!({"mode":"diameter","source":{"kind":"circle","node":circle},"options":{"units":"in","precision":0}}),
)["id"]
.as_u64()
.unwrap();
run(&mut s, "select.set", json!({"ids":[circle]}));
run(&mut s, "object.scale", json!({"sx":200,"sy":200}));
assert_eq!(text(&s, id), "Ø 4 in");
run(&mut s, "object.scale", json!({"sx":200,"sy":100}));
assert!(s.doc().unwrap().doc.node(NodeId(id)).is_none());
assert!(s.execute("dimension.create", &json!({"mode":"radius","source":{"kind":"circle","node":circle}})).is_err());
}
#[test]
fn dimension_style_update_keeps_links_and_can_be_undone() {
let mut s = session();
let line = edge(&mut s);
let id = dim(&mut s, line);
run(
&mut s,
"dimension.update",
json!({"ids":[id],"offset":-42,"options":{"units":"mm","precision":2,"hideUnits":true,"textPosition":"below","lineColor":[10,20,30]}}),
);
assert_eq!(text(&s, id), "25.40");
let d = s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().dimension.as_ref().unwrap();
assert!(d.source.is_some());
assert_eq!(d.offset, -42.0);
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s, id), "10.00 in");
}
#[test]
fn dimension_cannot_measure_its_own_generated_art() {
let mut s = session();
let line = edge(&mut s);
let id = dim(&mut s, line);
let child = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap()[0].id;
assert!(s.execute("dimension.create", &json!({"source":{"kind":"segment","edge":{"node":child,"subpath":0,"segment":0}}})).is_err());
}
+1
View File
@@ -102,6 +102,7 @@ pub const TOOL_GROUPS: &[&[ToolInfo]] = &[
&[t("gradient", "Gradient Tool", Some("G"), "tool-gradient")],
&[t("eyedropper", "Eyedropper Tool", Some("I"), "tool-eyedropper"), t("measure", "Measure Tool", None, "tool-measure")],
&[t("blend", "Blend Tool", Some("W"), "tool-blend")],
&[t("dimension", "Dimension Tool", None, "tool-measure")],
&[
t("symbolSprayer", "Symbol Sprayer Tool", Some("Shift+S"), "tool-symbol"),
t("symbolShifter", "Symbol Shifter Tool", None, "tool-symbol"),
+278
View File
@@ -0,0 +1,278 @@
//! Click a highlighted segment, then click to place its linked dimension. Empty-space clicks
//! measure two points (three for an angle). Escape cancels without leaving any artwork.
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
use serde_json::{Value, json};
use vectorcraft_doc::{
Node,
dimension::{Dimension, DimensionOptions, DimensionSource, SegmentRef},
};
use vectorcraft_geom::{BezPath, Point, Vec2};
#[derive(Default)]
pub struct DimensionTool {
mode: String,
style: DimensionOptions,
points: Vec<Point>,
point_refs: Vec<Option<vectorcraft_doc::dimension::PointRef>>,
first_edge: Option<SegmentRef>,
pending: Option<Dimension>,
hover: Option<SegmentRef>,
at: Point,
began: bool,
}
/// Nearest editable segment, with bounded work and no snapping to dimension artwork itself.
pub fn pick(cx: &ToolContext, p: Point) -> Option<SegmentRef> {
let mut best = None;
let mut dist = cx.tol(8.0);
let mut budget = 20_000usize;
fn visit(n: &Node, p: Point, budget: &mut usize, dist: &mut f64, best: &mut Option<SegmentRef>) {
if *budget == 0 || !n.visible || n.locked || n.extras().dimension.is_some() {
return;
}
if let Some(path) = n.path_data() {
for (si, sp) in path.subpaths.iter().enumerate() {
for i in 0..sp.segment_count() {
if *budget == 0 {
return;
}
*budget -= 1;
use vectorcraft_geom::kurbo::ParamCurveNearest;
let near = sp.segment(i).nearest(p, 1e-4);
if near.distance_sq < *dist * *dist {
*dist = near.distance_sq.sqrt();
*best = Some(SegmentRef { node: n.id, subpath: si, segment: i });
}
}
}
}
if let Some(ch) = n.children() {
for c in ch.iter().rev() {
visit(c, p, budget, dist, best);
}
}
}
for n in cx.doc.layers.iter().rev() {
visit(n, p, &mut budget, &mut dist, &mut best);
}
best
}
impl DimensionTool {
fn point_source(&self, angular: bool) -> Option<DimensionSource> {
if self.point_refs.iter().all(Option::is_none) {
return None;
}
Some(DimensionSource::Points {
a: self.point_refs.first().cloned().flatten(),
b: self.point_refs.get(if angular { 2 } else { 1 }).cloned().flatten(),
vertex: if angular { self.point_refs.get(1).cloned().flatten() } else { None },
})
}
fn mode(&self) -> &str {
if self.mode.is_empty() { "linear" } else { &self.mode }
}
fn recipe(&self, a: Point, b: Point, vertex: Point, source: Option<DimensionSource>) -> Dimension {
Dimension { mode: self.mode().into(), a, b, vertex, offset: 30.0, length: a.distance(b), options: self.style.clone(), source }
}
fn params(d: &Dimension) -> Value {
let mut p = json!({"mode":d.mode,"a":[d.a.x,d.a.y],"b":[d.b.x,d.b.y],"vertex":[d.vertex.x,d.vertex.y],"offset":d.offset,"options":d.options});
if let Some(source) = &d.source {
p["source"] = json!(source);
}
p
}
fn preview(&mut self, cx: &ToolContext, p: Point) -> Vec<Action> {
let Some(d) = &mut self.pending else { return vec![] };
if d.mode == "linear" {
let u = d.b - d.a;
d.offset = (p - d.a).dot(Vec2::new(-u.y, u.x) / u.hypot());
} else if d.mode == "angular" {
d.offset = p.distance(d.vertex).max(12.0);
} else if p.distance(d.a) > 1e-6 {
let r = d.a.distance(d.b);
let u = p - d.a;
d.b = d.a + u / u.hypot() * r;
}
if d.resolve(cx.doc).is_err() {
return vec![];
}
let mut actions = vec![];
if !self.began {
actions.push(Action::Begin("Create Dimension".into()));
self.began = true;
}
actions.push(Action::Preview("dimension.create".into(), Self::params(d)));
actions
}
fn reset(&mut self) {
self.pending = None;
self.points.clear();
self.point_refs.clear();
self.first_edge = None;
self.began = false;
}
}
impl Tool for DimensionTool {
fn id(&self) -> &'static str {
"dimension"
}
fn busy(&self) -> bool {
self.pending.is_some() || !self.points.is_empty() || self.first_edge.is_some()
}
fn cursor(&self, _: &ToolContext, _: Point, _: Mods) -> Cursor {
Cursor::Crosshair
}
fn options(&self) -> Value {
json!({"mode":self.mode(),"style":self.style})
}
fn set_option(&mut self, key: &str, value: &Value) {
if key == "mode"
&& let Some(v) = value.as_str().filter(|v| ["linear", "angular", "radius", "diameter"].contains(v))
{
self.mode = v.into();
}
if key == "style"
&& let Ok(o) = serde_json::from_value::<DimensionOptions>(value.clone())
&& o.validate().is_ok()
{
self.style = o;
}
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
self.at = ev.pos;
if matches!(ev.kind, PointerKind::Move | PointerKind::Drag) {
if self.pending.is_some() {
return self.preview(cx, ev.pos);
}
self.hover = pick(cx, ev.pos);
return vec![];
}
if ev.kind != PointerKind::Down {
return vec![];
}
if self.pending.is_some() {
let mut out = self.preview(cx, ev.pos);
if self.began {
out.push(Action::Commit);
}
self.reset();
return out;
}
let hit = pick(cx, ev.pos);
let mut candidate = None;
match self.mode() {
"linear" if self.points.is_empty() && hit.is_some() && !ev.mods.alt => {
candidate = hit.map(|edge| self.recipe(Point::ZERO, Point::new(1.0, 0.0), Point::ZERO, Some(DimensionSource::Segment { edge })));
}
"angular" if hit.is_some() && self.points.is_empty() && !ev.mods.alt => {
if let Some(second) = hit {
let straight = |edge: &SegmentRef| {
cx.doc
.node(edge.node)
.and_then(Node::path_data)
.and_then(|p| p.subpaths.get(edge.subpath))
.is_some_and(|sp| edge.segment < sp.segment_count() && sp.segment_is_line(edge.segment))
};
if !straight(&second) {
return vec![];
}
if let Some(first) = self.first_edge.clone() {
candidate = Some(self.recipe(Point::ZERO, Point::new(1.0, 0.0), Point::ZERO, Some(DimensionSource::Angle { first, second })));
} else {
self.first_edge = Some(second);
}
}
}
"radius" | "diameter" => {
if let Some(edge) = hit
&& let Some((center, _)) = vectorcraft_doc::dimension::circle(cx.doc, edge.node)
{
candidate = Some(self.recipe(center, ev.pos, Point::ZERO, Some(DimensionSource::Circle { node: edge.node })));
}
}
_ => {
let (mut p, _) = crate::guides::snap_pick(cx, ev.pos);
let reference = hit.as_ref().and_then(|edge| {
let sp = cx.doc.node(edge.node)?.path_data()?.subpaths.get(edge.subpath)?;
let next = if edge.segment + 1 < sp.anchors.len() { edge.segment + 1 } else { 0 };
[edge.segment, next]
.into_iter()
.filter_map(|i| Some((i, sp.anchors.get(i)?.p)))
.filter(|(_, point)| point.distance(ev.pos) <= cx.tol(8.0))
.min_by(|(_, a), (_, b)| a.distance(ev.pos).total_cmp(&b.distance(ev.pos)))
.map(|(anchor, point)| {
p = point;
vectorcraft_doc::dimension::PointRef { node: edge.node, subpath: edge.subpath, anchor }
})
});
if ev.mods.shift
&& let Some(a) = self.points.last()
{
p = *a + vectorcraft_geom::constrain_angle_from(p - *a, 45.0, cx.constrain_angle);
}
self.points.push(p);
self.point_refs.push(reference.filter(|r| r.position(cx.doc) == Some(p)));
match self.points.as_slice() {
[a, b] if self.mode() == "linear" => candidate = Some(self.recipe(*a, *b, Point::ZERO, self.point_source(false))),
[a, v, b] if self.mode() == "angular" => candidate = Some(self.recipe(*a, *b, *v, self.point_source(true))),
_ => {}
}
}
}
if let Some(mut d) = candidate {
if d.resolve(cx.doc).is_ok() {
self.pending = Some(d);
self.points.clear();
self.point_refs.clear();
self.first_edge = None;
} else if self.points.len() >= 2 {
self.points.clear();
self.point_refs.clear();
}
}
vec![]
}
fn key(&mut self, _: &ToolContext, key: ToolKey, _: Mods) -> Vec<Action> {
if key == ToolKey::Escape {
let cancel = self.began;
self.reset();
return if cancel { vec![Action::Cancel] } else { vec![] };
}
vec![]
}
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
self.key(cx, ToolKey::Escape, Mods::default())
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let color = [60, 150, 255];
let mut out = vec![];
if self.pending.is_none()
&& let Some(edge) = &self.hover
&& let Some(sp) = cx.doc.node(edge.node).and_then(Node::path_data).and_then(|p| p.subpaths.get(edge.subpath))
&& edge.segment < sp.segment_count()
{
let c = sp.segment(edge.segment);
let mut path = BezPath::new();
path.move_to(c.p0);
path.curve_to(c.p1, c.p2, c.p3);
out.push(Overlay::Path { path, color, width: 3.0, dashed: false });
for p in [c.p0, c.p3] {
out.push(Overlay::Anchor { p, color, filled: true, size: 6.0 });
}
if let Some((_, _, length)) = edge.endpoints(cx.doc)
&& let Ok(text) = self.style.label(length, false)
{
out.push(Overlay::Measure { p: self.at + Vec2::new(12.0, 12.0), text });
}
}
for p in &self.points {
out.push(Overlay::Anchor { p: *p, color, filled: true, size: 6.0 });
}
if let Some(p) = self.points.last() {
out.push(Overlay::Line { a: *p, b: self.at, color, dashed: true });
}
out
}
}
+2
View File
@@ -13,6 +13,7 @@ pub mod catalog;
pub mod corners;
pub mod cropimage;
pub mod cut;
pub mod dimension;
pub mod direct;
pub mod distort;
pub mod draw2;
@@ -601,6 +602,7 @@ pub fn create(id: &str) -> Box<dyn Tool> {
"directSelection" => Box::new(direct::DirectSelectionTool::new(false)),
"groupSelection" => Box::new(direct::DirectSelectionTool::new(true)),
"pen" => Box::new(pen::PenTool::default()),
"dimension" => Box::new(dimension::DimensionTool::default()),
"type" | "areaType" | "typeOnPath" | "verticalType" | "verticalAreaType" | "verticalTypeOnPath" => Box::new(text::TypeTool::new(id)),
"rectangle" | "roundedRectangle" | "ellipse" | "polygon" | "star" | "lineSegment" => Box::new(shape::ShapeTool::new(id)),
// Not in the toolbar: `file.place.queue` loads it.
+1
View File
@@ -19,6 +19,7 @@ pub const SYMBOLISM: &[&str] =
&["symbolSprayer", "symbolShifter", "symbolScruncher", "symbolSizer", "symbolSpinner", "symbolStainer", "symbolScreener", "symbolStyler"];
const ROWS: &[Row] = &[
(&["dimension"], None, &["mode", "style"]),
// Global Brush Dimensions.
(LIQUIFY, Some("liquify"), &["width", "height", "angle", "intensity", "usePressure", "showBrush"]),
(LIQUIFY, None, &["detail", "simplify", "simplifyOn", "rate", "complexity", "horizontal", "vertical", "affectAnchors", "affectIn", "affectOut"]),
+6
View File
@@ -610,6 +610,12 @@ const ANIMATED_ZOOM_HINT: &str = "zoom.animated";
fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> {
Some(match tool {
"dimension" => &[
("Click", true),
(" a highlighted edge, then click to place its dimension | ", false),
("Esc", true),
(" cancels | Double-click the tool for options", false),
],
"selection" => &[
("Click", true),
(" the object to select | ", false),
+300
View File
@@ -0,0 +1,300 @@
//! Dimension options and compact mode strip. Values are drafts until Save.
use super::DialogSpec;
use crate::{VectorcraftApp, state::Dialog, theme::Tokens, widgets};
use egui::{RichText, Ui};
use serde_json::{Value, json};
use vectorcraft_doc::dimension::DimensionOptions;
pub const KIND: &str = "dimensionOptions";
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| tl!("Dimension Tool Options").into(),
body,
confirm,
ok: Some("Save"),
min_width: 520.0,
max_width: Some(560.0),
..DialogSpec::FORM
};
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
if app.session.tool_id() == "dimension" && app.session.tool_busy() {
app.run("tool.select", json!({"tool":"selection"}))?;
app.run("tool.select", json!({"tool":"dimension"}))?;
}
let selected = app
.session
.active()
.and_then(|st| st.selection.objects.first().and_then(|id| st.doc.node(*id)))
.and_then(|n| n.extras().dimension.as_deref());
let style = if let Some(d) = selected {
serde_json::to_value(&d.options).map_err(|e| e.to_string())?
} else {
app.session.tool_options_of("dimension")["style"].clone()
};
let o: DimensionOptions = serde_json::from_value(style).unwrap_or_default();
let mut fields = serde_json::to_value(o).map_err(|e| e.to_string())?.as_object().cloned().unwrap_or_default();
fields.insert("applySelected".into(), json!(selected.is_some()));
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields });
Ok(json!({"dialog":KIND}))
}
fn read(d: &Dialog) -> Result<DimensionOptions, String> {
let mut fields = d.fields.clone();
fields.remove("applySelected");
let o: DimensionOptions = serde_json::from_value(Value::Object(fields)).map_err(|e| e.to_string())?;
o.validate()?;
Ok(o)
}
fn choose(ui: &mut Ui, d: &mut Dialog, key: &str, label: &str, choices: &[(&str, &str)]) {
widgets::label_row(ui, tl!(label), 96.0, |ui| {
let current = d.str(key);
let text = choices.iter().find(|(v, _)| *v == current).map_or(current.as_str(), |(_, label)| *label);
if let Some(index) =
widgets::dropdown_names(ui, ("dimension", key), tl!(text), &choices.iter().map(|(_, s)| tl!(*s)).collect::<Vec<_>>(), 134.0)
&& let Some((value, _)) = choices.get(index)
{
d.fields.insert(key.into(), json!(value));
}
});
}
fn number(ui: &mut Ui, d: &mut Dialog, key: &str, label: &str, range: (f64, f64), suffix: &str) {
widgets::label_row(ui, tl!(label), 96.0, |ui| {
if let Some(v) = widgets::plain_field(ui, ("dimension", key), d.f64(key, 0.0), suffix, 2, 90.0) {
d.fields.insert(key.into(), json!(v.clamp(range.0, range.1)));
}
});
}
fn check(ui: &mut Ui, d: &mut Dialog, key: &str, label: &str) {
let mut value = d.bool(key);
if ui.checkbox(&mut value, tl!(label)).changed() {
d.fields.insert(key.into(), json!(value));
}
}
fn color(ui: &mut Ui, d: &mut Dialog, key: &str) {
let mut value: [u8; 3] = serde_json::from_value(d.fields.get(key).cloned().unwrap_or(Value::Null)).unwrap_or([224, 55, 65]);
ui.push_id(key, |ui| {
if ui.color_edit_button_srgb(&mut value).changed() {
d.fields.insert(key.into(), json!(value));
}
});
}
fn section(ui: &mut Ui, title: &str, body: impl FnOnce(&mut Ui)) {
let t = Tokens::get(ui.ctx());
ui.group(|ui| {
ui.set_min_width(486.0);
ui.label(RichText::new(tl!(title)).color(t.text_dim).strong());
ui.add_space(5.0);
body(ui);
});
ui.add_space(7.0);
}
const LINE_TYPES: &[(&str, &str)] = &[("solid", "Solid"), ("dashed", "Dashed"), ("dotted", "Dotted")];
fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
section(ui, "Units & Scale", |ui| {
ui.horizontal(|ui| {
choose(
ui,
d,
"units",
"Units:",
&[
("mm", "Millimeters"),
("cm", "Centimeters"),
("m", "Meters"),
("in", "Inches"),
("ft", "Feet"),
("yd", "Yards"),
("pt", "Points"),
("px", "Pixels"),
],
);
ui.label(tl!("Precision:"));
let mut value = d.f64("precision", 1.0) as u32;
egui::ComboBox::from_id_salt("dimension-precision").selected_text(value.to_string()).width(55.0).show_ui(ui, |ui| {
for n in 0..=6 {
ui.selectable_value(&mut value, n, n.to_string());
}
});
d.fields.insert("precision".into(), json!(value));
});
ui.horizontal(|ui| {
widgets::label_row(ui, tl!("Scale:"), 96.0, |ui| {
let mut scale = d.str("scale");
if ui.add(egui::TextEdit::singleline(&mut scale).desired_width(72.0)).changed() {
d.fields.insert("scale".into(), json!(scale));
}
egui::ComboBox::from_id_salt("dimension-scales").selected_text(tl!("Presets")).width(76.0).show_ui(ui, |ui| {
for scale in ["1:1", "1:2", "1:5", "1:10", "1:20", "1:50", "1:100", "1:200", "2:1", "5:1", "10:1"] {
if ui.selectable_label(false, scale).clicked() {
d.fields.insert("scale".into(), json!(scale));
}
}
});
});
check(ui, d, "hideUnits", "Hide Units");
});
ui.label(RichText::new(tl!("Drawing : real size — 1:10 means 1 cm on the page represents 10 cm.")).small());
});
section(ui, "Dimension Lines", |ui| {
ui.horizontal(|ui| {
number(ui, d, "lineWeight", "Line Weight:", (0.1, 100.0), "pt");
choose(ui, d, "lineType", "Line Type:", LINE_TYPES);
color(ui, d, "lineColor");
});
ui.horizontal(|ui| {
choose(ui, d, "arrowStyle", "Arrow Style:", &[("filled", "Filled"), ("open", "Open"), ("tick", "Tick"), ("none", "None")]);
number(ui, d, "arrowScale", "Arrow Scale:", (1.0, 1000.0), "%");
});
});
section(ui, "Extension Lines", |ui| {
ui.horizontal(|ui| {
number(ui, d, "extensionWeight", "Line Weight:", (0.1, 100.0), "pt");
choose(ui, d, "extensionType", "Line Type:", LINE_TYPES);
color(ui, d, "extensionColor");
});
ui.horizontal(|ui| {
number(ui, d, "extensionOffset", "Offset:", (0.0, 10000.0), "pt");
number(ui, d, "extensionLength", "Extension:", (0.0, 10000.0), "pt");
});
check(ui, d, "hideExtensions", "Hide Extension Lines");
});
section(ui, "Dimension Text", |ui| {
widgets::label_row(ui, tl!("Font Family:"), 96.0, |ui| {
if let Some(crate::font_menu::FontPick::Chosen(family, style)) =
crate::font_menu::font_menu(ui, "dimension-font", &d.str("fontFamily"), 280.0, Some("123.45 cm"), crate::font_menu::MenuLook::of(app))
{
d.fields.insert("fontFamily".into(), json!(family));
d.fields.insert("fontStyle".into(), json!(style.unwrap_or_else(|| "Regular".into())));
}
});
widgets::label_row(ui, tl!("Font Style:"), 96.0, |ui| {
let styles = vectorcraft_text::FontDb::global().styles(&d.str("fontFamily"));
let names: Vec<&str> = styles.iter().map(|s| s.as_str()).collect();
if let Some(i) = widgets::dropdown_names(ui, "dimension-font-style", &d.str("fontStyle"), &names, 180.0)
&& let Some(style) = styles.get(i)
{
d.fields.insert("fontStyle".into(), json!(style));
}
});
ui.horizontal(|ui| {
number(ui, d, "fontSize", "Font Size:", (1.0, 1000.0), "pt");
color(ui, d, "textColor");
});
ui.horizontal(|ui| {
choose(ui, d, "textPosition", "Position:", &[("above", "Above"), ("center", "Centered"), ("below", "Below")]);
number(ui, d, "textOffset", "Offset:", (0.0, 10000.0), "pt");
});
choose(ui, d, "textAlignment", "Orientation:", &[("aligned", "Along dimension"), ("horizontal", "Horizontal")]);
});
match read(d) {
Ok(o) => {
let sample = o.label(72.0 / 2.54, false).unwrap_or_default();
let t = Tokens::get(ui.ctx());
ui.label(RichText::new(format!("{}: {sample}", tl!("Preview (1 cm on page)"))).color(t.text));
}
Err(e) => {
ui.colored_label(Tokens::get(ui.ctx()).text, e);
}
}
ui.horizontal(|ui| {
if ui.button(tl!("Reset")).clicked()
&& let Ok(Value::Object(fields)) = serde_json::to_value(DimensionOptions::default())
{
let apply = d.bool("applySelected");
d.fields = fields;
d.fields.insert("applySelected".into(), json!(apply));
}
check(ui, d, "applySelected", "Apply to selected dimensions");
});
false
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let o = read(d)?;
if d.bool("applySelected") {
let ids: Vec<_> = app
.session
.active()
.map(|s| s.selection.objects.iter().filter(|id| s.doc.node(**id).is_some_and(|n| n.extras().dimension.is_some())).copied().collect())
.unwrap_or_default();
if !ids.is_empty() {
app.run("dimension.update", json!({"ids":ids,"options":o}))?;
}
}
let r = app.run("tool.setOption", json!({"tool":"dimension","values":{"style":o}}))?;
app.ui.dialog = None;
Ok(r)
}
pub fn controls(app: &mut VectorcraftApp, ui: &mut Ui) {
let mode = app.session.tool_options()["mode"].as_str().unwrap_or("linear").to_owned();
for (id, label) in [("linear", "Length"), ("angular", "Angle"), ("radius", "Radius"), ("diameter", "Diameter")] {
if ui.selectable_label(mode == id, tl!(label)).clicked() {
// Switching away cancels previews and unfinished point selections.
if let Err(e) = app
.run("tool.select", json!({"tool":"selection"}))
.and_then(|_| app.run("tool.select", json!({"tool":"dimension"})))
.and_then(|_| app.run("tool.setOption", json!({"tool":"dimension","values":{"mode":id}})))
{
app.ui.status = e;
}
}
}
if ui.button(tl!("Options…")).clicked()
&& let Err(e) = open(app)
{
app.ui.status = e;
}
if ui.button(tl!("All selected edges")).clicked()
&& let Err(e) = app.run("dimension.selection", json!({}))
{
app.ui.status = e;
}
}
/// Always available while measuring, including workspaces with the main Control bar hidden.
pub fn floating(app: &mut VectorcraftApp, ctx: &egui::Context) {
if app.session.tool_id() != "dimension" || app.ui.dialog.is_some() {
return;
}
egui::Window::new(tl!("Dimensions"))
.id(egui::Id::new("dimension-strip"))
.default_pos(egui::pos2(90.0, 120.0))
.resizable(false)
.collapsible(false)
.show(ctx, |ui| {
ui.horizontal(|ui| controls(app, ui));
let mode = app.session.tool_options()["mode"].as_str().unwrap_or("linear").to_owned();
let hint = match mode.as_str() {
"angular" => "Click two straight edges, then place the angle.",
"radius" | "diameter" => "Click a circle, then click to choose the label direction.",
_ => "Click an edge, then place the dimension. Alt-click picks individual anchors.",
};
ui.label(RichText::new(tl!(hint)).small().color(Tokens::get(ctx).text_dim));
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dimension_dialog_fields_save_and_cancel() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("tool.options", json!({"tool":"dimension"})).unwrap();
let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::super::show(app, ui.ctx()));
for label in ["Dimension Tool Options", "Units & Scale", "Dimension Lines", "Extension Lines", "Dimension Text", "Save"] {
assert!(text.contains(label), "{label}: {text}");
}
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("scale".into(), json!("1:10"));
super::super::confirm(&mut app).unwrap();
assert_eq!(app.session.tool_options_of("dimension")["style"]["scale"], "1:10");
open(&mut app).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("scale".into(), json!("1:0"));
assert!(super::super::confirm(&mut app).is_err());
app.ui.dialog = None;
assert_eq!(app.session.tool_options_of("dimension")["style"]["scale"], "1:10");
}
}
+2
View File
@@ -17,6 +17,7 @@ mod color_picker;
mod command;
pub mod confirm;
pub mod corners;
pub mod dimension;
mod document_setup;
pub mod dxf_import;
pub mod dxf_options;
@@ -212,6 +213,7 @@ macro_rules! registry {
}
registry! {
DimensionOptions: [dimension::KIND] => dimension::SPEC,
NewDocument: [new_document::KIND] => new_document::SPEC,
Shape: ["rectangle", "roundedRectangle", "ellipse", "polygon", "star", "lineSegment", "rectangularGrid", "polarGrid"] => shapes::SPEC,
Transform: ["move", "rotate", "scale", "reflect", "shear"] => transform::SPEC,
+39
View File
@@ -4808,6 +4808,45 @@ mark-style Japanese Japonské
Softness Měkkost
Ambient Light Okolní světlo
Light behind the object Světlo za objektem
# Dimension tool (linked dimension annotations)
Dimension Selected Paths Okótovat vybrané cesty
Display Zobrazení
# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
a highlighted edge, then click to place its dimension | na zvýrazněnou hranu a potom kliknutím umístěte kótu |
cancels | Double-click the tool for options zruší | Dvojklikem na nástroj otevřete volby
All selected edges Všechny vybrané hrany
Create Dimension Vytvořit kótu
Dimension Tool Nástroj Kóta
Dimension Tool Options Volby nástroje Kóta
Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. Výkres : skutečná velikost — 1:10 znamená, že 1 cm na stránce odpovídá 10 cm.
Font Family: Rodina písma:
Font Style: Řez písma:
Precision: Přesnost:
Preview (1 cm on page) Náhled (1 cm na stránce)
Update Dimensions Aktualizovat kóty
@msg Angles require two straight segments Úhly vyžadují dva přímé segmenty
@msg Choose a circular path Vyberte kruhovou cestu
@msg Choose a length unit and precision from 0 to 6 Vyberte délkovou jednotku a přesnost 0 až 6
@msg Choose two different points Vyberte dva různé body
@msg Choose two non-parallel straight segments Vyberte dva nerovnoběžné přímé segmenty
@msg Choose two nonzero angle arms Vyberte dvě ramena úhlu nenulové délky
@msg Dimension point is outside the canvas Bod kóty leží mimo plátno
@msg Dimension size is outside its supported range Velikost kóty je mimo podporovaný rozsah
@msg First measured segment no longer exists První měřený segment už neexistuje
@msg Invalid dimension distance Neplatná vzdálenost kóty
@msg Invalid dimension font name Neplatný název písma kóty
@msg Invalid drawing scale Neplatné měřítko výkresu
@msg Invalid real scale Neplatné skutečné měřítko
@msg Measured anchor no longer exists Měřený kotevní bod už neexistuje
@msg Measured path no longer exists Měřená cesta už neexistuje
@msg Measured segment no longer exists Měřený segment už neexistuje
@msg Non-finite dimension Kóta není konečné číslo
@msg Scale must be drawing:real, for example 1:10 Měřítko musí mít tvar výkres:skutečnost, například 1:10
@msg Scale ratio must be between 1:1000000 and 1000000:1 Poměr měřítka musí být mezi 1:1000000 a 1000000:1
@msg Second measured segment no longer exists Druhý měřený segment už neexistuje
@msg Unknown dimension mode Neznámý režim kóty
@msg Unknown dimension style Neznámý styl kóty
@msg Unknown unit Neznámá jednotka
# Revolve cap description
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Uzavře kruhové konce otevřených profilů. Boční řezy částečného otočení zůstávají otevřené.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+40
View File
@@ -4336,5 +4336,45 @@ theme Appearance Erscheinungsbild
Ambient Light Umgebungslicht
Light behind the object Licht hinter dem Objekt
# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
a highlighted edge, then click to place its dimension | auf eine hervorgehobene Kante, dann klicken, um ihre Bemaßung zu platzieren |
cancels | Double-click the tool for options bricht ab | Doppelklick auf das Werkzeug für Optionen
All selected edges Alle ausgewählten Kanten
Create Dimension Bemaßung erstellen
Dimension Selected Paths Ausgewählte Pfade bemaßen
Dimension Tool Bemaßungswerkzeug
Dimension Tool Options Optionen für Bemaßungswerkzeug
Display Anzeige
Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. Zeichnung : Realgröße — 1:10 bedeutet, 1 cm auf der Seite entspricht 10 cm.
Font Family: Schriftfamilie:
Font Style: Schriftschnitt:
Precision: Genauigkeit:
Preview (1 cm on page) Vorschau (1 cm auf der Seite)
Update Dimensions Bemaßungen aktualisieren
X: X:
Y: Y:
@msg Angles require two straight segments Winkel erfordern zwei gerade Segmente
@msg Choose a circular path Wählen Sie einen kreisförmigen Pfad
@msg Choose a length unit and precision from 0 to 6 Wählen Sie eine Längeneinheit und eine Genauigkeit von 0 bis 6
@msg Choose two different points Wählen Sie zwei verschiedene Punkte
@msg Choose two non-parallel straight segments Wählen Sie zwei nicht parallele gerade Segmente
@msg Choose two nonzero angle arms Wählen Sie zwei Winkelschenkel mit einer Länge ungleich null
@msg Dimension point is outside the canvas Der Bemaßungspunkt liegt außerhalb der Arbeitsfläche
@msg Dimension size is outside its supported range Die Bemaßungsgröße liegt außerhalb des unterstützten Bereichs
@msg First measured segment no longer exists Das erste gemessene Segment existiert nicht mehr
@msg Invalid dimension distance Ungültiger Bemaßungsabstand
@msg Invalid dimension font name Ungültiger Schriftname für die Bemaßung
@msg Invalid drawing scale Ungültiger Zeichnungsmaßstab
@msg Invalid real scale Ungültiger realer Maßstab
@msg Measured anchor no longer exists Der gemessene Ankerpunkt existiert nicht mehr
@msg Measured path no longer exists Der gemessene Pfad existiert nicht mehr
@msg Measured segment no longer exists Das gemessene Segment existiert nicht mehr
@msg Non-finite dimension Die Bemaßung ist keine endliche Zahl
@msg Scale must be drawing:real, for example 1:10 Der Maßstab muss Zeichnung:Realität sein, zum Beispiel 1:10
@msg Scale ratio must be between 1:1000000 and 1000000:1 Das Maßstabsverhältnis muss zwischen 1:1000000 und 1000000:1 liegen
@msg Second measured segment no longer exists Das zweite gemessene Segment existiert nicht mehr
@msg Unknown dimension mode Unbekannter Bemaßungsmodus
@msg Unknown dimension style Unbekannter Bemaßungsstil
@msg Unknown unit Unbekannte Einheit
# Revolve cap description
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Schließt die kreisförmigen Enden offener Profile. Bei einer Teildrehung bleiben die Schnittseiten offen.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+39
View File
@@ -4749,6 +4749,45 @@ mark-style Japanese Japonesas
Softness Suavidad
Ambient Light Luz ambiental
Light behind the object Luz detrás del objeto
# Dimension tool (linked dimension annotations)
a highlighted edge, then click to place its dimension | en un borde resaltado y luego haga clic para colocar su cota |
cancels | Double-click the tool for options cancela | Doble clic en la herramienta para ver las opciones
All selected edges Todos los bordes seleccionados
Create Dimension Crear cota
Dimension Selected Paths Acotar trazados seleccionados
Dimension Tool Herramienta Cota
Dimension Tool Options Opciones de la herramienta Cota
Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. Dibujo : tamaño real — 1:10 significa que 1 cm en la página representa 10 cm.
Font Family: Familia de fuentes:
Font Style: Estilo de fuente:
Precision: Precisión:
Preview (1 cm on page) Vista previa (1 cm en la página)
Update Dimensions Actualizar cotas
Display Visualización
# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
@msg Angles require two straight segments Los ángulos requieren dos segmentos rectos
@msg Choose a circular path Elija un trazado circular
@msg Choose a length unit and precision from 0 to 6 Elija una unidad de longitud y una precisión de 0 a 6
@msg Choose two different points Elija dos puntos distintos
@msg Choose two non-parallel straight segments Elija dos segmentos rectos no paralelos
@msg Choose two nonzero angle arms Elija dos lados del ángulo de longitud distinta de cero
@msg Dimension point is outside the canvas El punto de la cota está fuera del lienzo
@msg Dimension size is outside its supported range El tamaño de la cota está fuera del intervalo admitido
@msg First measured segment no longer exists El primer segmento medido ya no existe
@msg Invalid dimension distance Distancia de cota no válida
@msg Invalid dimension font name Nombre de fuente de cota no válido
@msg Invalid drawing scale Escala de dibujo no válida
@msg Invalid real scale Escala real no válida
@msg Measured anchor no longer exists El punto de ancla medido ya no existe
@msg Measured path no longer exists El trazado medido ya no existe
@msg Measured segment no longer exists El segmento medido ya no existe
@msg Non-finite dimension Cota no finita
@msg Scale must be drawing:real, for example 1:10 La escala debe ser dibujo:real, por ejemplo 1:10
@msg Scale ratio must be between 1:1000000 and 1000000:1 La relación de escala debe estar entre 1:1000000 y 1000000:1
@msg Second measured segment no longer exists El segundo segmento medido ya no existe
@msg Unknown dimension mode Modo de cota desconocido
@msg Unknown dimension style Estilo de cota desconocido
@msg Unknown unit Unidad desconocida
# Revolve cap description
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Cierra los extremos circulares de los perfiles abiertos. Los giros parciales mantienen abiertos sus lados de corte.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+38
View File
@@ -4809,5 +4809,43 @@ theme Appearance Apparence
Ambient Light Lumière ambiante
Light behind the object Lumière derrière l'objet
# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
a highlighted edge, then click to place its dimension | sur un bord en surbrillance, puis cliquez pour placer sa cote |
cancels | Double-click the tool for options pour annuler | Double-cliquez sur l’outil pour ses options
All selected edges Tous les bords sélectionnés
Create Dimension Créer une cote
Dimension Selected Paths Coter les tracés sélectionnés
Dimension Tool Outil Cotation
Dimension Tool Options Options de l’outil Cotation
Display Affichage
Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. Dessin : taille réelle — 1:10 signifie que 1 cm sur la page représente 10 cm.
Font Family: Famille de polices :
Font Style: Style de police :
Precision: Précision :
Preview (1 cm on page) Aperçu (1 cm sur la page)
Update Dimensions Mettre à jour les cotes
@msg Angles require two straight segments Les angles nécessitent deux segments droits
@msg Choose a circular path Choisissez un tracé circulaire
@msg Choose a length unit and precision from 0 to 6 Choisissez une unité de longueur et une précision de 0 à 6
@msg Choose two different points Choisissez deux points différents
@msg Choose two non-parallel straight segments Choisissez deux segments droits non parallèles
@msg Choose two nonzero angle arms Choisissez deux côtés d’angle de longueur non nulle
@msg Dimension point is outside the canvas Le point de cote est en dehors de la zone de travail
@msg Dimension size is outside its supported range La taille de la cote est hors de la plage prise en charge
@msg First measured segment no longer exists Le premier segment mesuré n’existe plus
@msg Invalid dimension distance Distance de cote non valide
@msg Invalid dimension font name Nom de police de cote non valide
@msg Invalid drawing scale Échelle du dessin non valide
@msg Invalid real scale Échelle réelle non valide
@msg Measured anchor no longer exists Le point d’ancrage mesuré n’existe plus
@msg Measured path no longer exists Le tracé mesuré n’existe plus
@msg Measured segment no longer exists Le segment mesuré n’existe plus
@msg Non-finite dimension Cote non finie
@msg Scale must be drawing:real, for example 1:10 L’échelle doit être dessin:réel, par exemple 1:10
@msg Scale ratio must be between 1:1000000 and 1000000:1 Le rapport d’échelle doit être compris entre 1:1000000 et 1000000:1
@msg Second measured segment no longer exists Le second segment mesuré n’existe plus
@msg Unknown dimension mode Mode de cote inconnu
@msg Unknown dimension style Style de cote inconnu
@msg Unknown unit Unité inconnue
# Revolve cap description
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Ferme les extrémités circulaires des profils ouverts. Les rotations partielles conservent leurs faces de coupe ouvertes.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+38
View File
@@ -4776,5 +4776,43 @@ imageTrace Outline Körvonal
Ambient Light Környezeti fény
Light behind the object Fény az objektum mögött
# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
a highlighted edge, then click to place its dimension | egy kiemelt élre, majd kattintson a méretvonal elhelyezéséhez |
cancels | Double-click the tool for options – mégse | Kattintson duplán az eszközre a beállításokért
All selected edges Minden kijelölt él
Create Dimension Méretvonal létrehozása
Dimension Selected Paths Kijelölt útvonalak méretezése
Dimension Tool Méretezés eszköz
Dimension Tool Options Méretezés eszköz beállításai
Display Megjelenítés
Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. Rajz : valós méret — 1:10 azt jelenti, hogy az oldalon 1 cm 10 cm-nek felel meg.
Font Family: Betűcsalád:
Font Style: Betűstílus:
Precision: Pontosság:
Preview (1 cm on page) Előnézet (1 cm az oldalon)
Update Dimensions Méretek frissítése
@msg Angles require two straight segments A szöghöz két egyenes szakasz kell
@msg Choose a circular path Válassz kör alakú útvonalat
@msg Choose a length unit and precision from 0 to 6 Válassz hosszegységet és 0–6 közötti pontosságot
@msg Choose two different points Válassz két különböző pontot
@msg Choose two non-parallel straight segments Válassz két nem párhuzamos egyenes szakaszt
@msg Choose two nonzero angle arms Válassz két nem nulla hosszúságú szögszárat
@msg Dimension point is outside the canvas A méretvonal pontja a vásznon kívül esik
@msg Dimension size is outside its supported range A méret a támogatott tartományon kívül esik
@msg First measured segment no longer exists Az első mért szakasz már nem létezik
@msg Invalid dimension distance Érvénytelen méretezési távolság
@msg Invalid dimension font name Érvénytelen méretezési betűtípusnév
@msg Invalid drawing scale Érvénytelen rajzlépték
@msg Invalid real scale Érvénytelen valós lépték
@msg Measured anchor no longer exists A mért horgonypont már nem létezik
@msg Measured path no longer exists A mért útvonal már nem létezik
@msg Measured segment no longer exists A mért szakasz már nem létezik
@msg Non-finite dimension Nem véges méret
@msg Scale must be drawing:real, for example 1:10 A léptéknek rajz:valós alakúnak kell lennie, például 1:10
@msg Scale ratio must be between 1:1000000 and 1000000:1 A lépték aránya 1:1000000 és 1000000:1 között lehet
@msg Second measured segment no longer exists A második mért szakasz már nem létezik
@msg Unknown dimension mode Ismeretlen méretezési mód
@msg Unknown dimension style Ismeretlen méretezési stílus
@msg Unknown unit Ismeretlen mértékegység
# Revolve cap description
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Nyitott profilok kör alakú végeinek lezárása. Részleges körforgatás esetén a vágott oldalak nyitva maradnak.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+40
View File
@@ -4381,5 +4381,45 @@ lighting Light Luce
Ambient Light Luce ambientale
Light behind the object Luce dietro l'oggetto
# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
a highlighted edge, then click to place its dimension | su un bordo evidenziato, poi fai clic per posizionarne la quota |
cancels | Double-click the tool for options per annullare | Doppio clic sullo strumento per le opzioni
All selected edges Tutti i bordi selezionati
Create Dimension Crea quota
Dimension Selected Paths Quota i tracciati selezionati
Dimension Tool Strumento Quota
Dimension Tool Options Opzioni strumento Quota
Display Visualizzazione
Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. Disegno : dimensione reale — 1:10 significa che 1 cm sulla pagina rappresenta 10 cm.
Font Family: Famiglia di font:
Font Style: Stile font:
Precision: Precisione:
Preview (1 cm on page) Anteprima (1 cm sulla pagina)
Update Dimensions Aggiorna quote
X: X:
Y: Y:
@msg Angles require two straight segments Gli angoli richiedono due segmenti rettilinei
@msg Choose a circular path Scegli un tracciato circolare
@msg Choose a length unit and precision from 0 to 6 Scegli un’unità di lunghezza e una precisione da 0 a 6
@msg Choose two different points Scegli due punti diversi
@msg Choose two non-parallel straight segments Scegli due segmenti rettilinei non paralleli
@msg Choose two nonzero angle arms Scegli due lati dell’angolo di lunghezza non nulla
@msg Dimension point is outside the canvas Il punto della quota è fuori dalla tela
@msg Dimension size is outside its supported range La dimensione della quota è fuori dall’intervallo supportato
@msg First measured segment no longer exists Il primo segmento misurato non esiste più
@msg Invalid dimension distance Distanza della quota non valida
@msg Invalid dimension font name Nome del font della quota non valido
@msg Invalid drawing scale Scala del disegno non valida
@msg Invalid real scale Scala reale non valida
@msg Measured anchor no longer exists Il punto di ancoraggio misurato non esiste più
@msg Measured path no longer exists Il tracciato misurato non esiste più
@msg Measured segment no longer exists Il segmento misurato non esiste più
@msg Non-finite dimension Quota non finita
@msg Scale must be drawing:real, for example 1:10 La scala deve essere disegno:reale, ad esempio 1:10
@msg Scale ratio must be between 1:1000000 and 1000000:1 Il rapporto di scala deve essere compreso tra 1:1000000 e 1000000:1
@msg Second measured segment no longer exists Il secondo segmento misurato non esiste più
@msg Unknown dimension mode Modalità di quota sconosciuta
@msg Unknown dimension style Stile di quota sconosciuto
@msg Unknown unit Unità sconosciuta
# Revolve cap description
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Chiude le estremità circolari dei profili aperti. Le rotazioni parziali mantengono aperti i lati di taglio.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+41
View File
@@ -4374,6 +4374,47 @@ lighting Light ライト
Softness 柔らかさ
Ambient Light 環境光
Light behind the object オブジェクトの背後のライト
# Dimension tool (linked dimension annotations)
a highlighted edge, then click to place its dimension | :ハイライトされたエッジをクリックし、もう一度クリックして寸法を配置 |
cancels | Double-click the tool for options :キャンセル | ツールをダブルクリックでオプション
All selected edges 選択したすべてのエッジ
Create Dimension 寸法を作成
Dimension Selected Paths 選択したパスに寸法を記入
Dimension Tool 寸法ツール
Dimension Tool Options 寸法ツールオプション
Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. 図面 : 実寸 — 1:10 は、ページ上の 1 cm が実際の 10 cm を表します。
Font Family: フォントファミリー:
Font Style: フォントスタイル:
Precision: 精度:
Preview (1 cm on page) プレビュー (ページ上の 1 cm)
Update Dimensions 寸法を更新
Display 表示
# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
X: X:
Y: Y:
@msg Angles require two straight segments 角度には直線セグメントが 2 つ必要です
@msg Choose a circular path 円形のパスを選択してください
@msg Choose a length unit and precision from 0 to 6 長さの単位と 0〜6 の精度を選択してください
@msg Choose two different points 異なる 2 点を選択してください
@msg Choose two non-parallel straight segments 平行でない直線セグメントを 2 つ選択してください
@msg Choose two nonzero angle arms 長さが 0 でない角度の辺を 2 つ選択してください
@msg Dimension point is outside the canvas 寸法の点がカンバスの外にあります
@msg Dimension size is outside its supported range 寸法のサイズがサポート範囲外です
@msg First measured segment no longer exists 最初の測定セグメントはもう存在しません
@msg Invalid dimension distance 寸法の距離が無効です
@msg Invalid dimension font name 寸法のフォント名が無効です
@msg Invalid drawing scale 図面の縮尺が無効です
@msg Invalid real scale 実寸の縮尺が無効です
@msg Measured anchor no longer exists 測定したアンカーポイントはもう存在しません
@msg Measured path no longer exists 測定したパスはもう存在しません
@msg Measured segment no longer exists 測定したセグメントはもう存在しません
@msg Non-finite dimension 寸法が有限ではありません
@msg Scale must be drawing:real, for example 1:10 縮尺は 図面:実寸 の形式にしてください (例: 1:10)
@msg Scale ratio must be between 1:1000000 and 1000000:1 縮尺の比率は 1:1000000 から 1000000:1 の間にしてください
@msg Second measured segment no longer exists 2 番目の測定セグメントはもう存在しません
@msg Unknown dimension mode 寸法のモードが不明です
@msg Unknown dimension style 寸法のスタイルが不明です
@msg Unknown unit 単位が不明です
# Revolve cap description
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. 開いたプロファイルの円形の端を閉じます。部分的な回転では切断面は開いたままです。
Can't render this file because it contains an unexpected character in line 2 and column 73.
+40
View File
@@ -4345,5 +4345,45 @@ lighting Light 광원
Ambient Light 주변광
Light behind the object 개체 뒤의 조명
# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
a highlighted edge, then click to place its dimension | 강조 표시된 가장자리를 클릭한 다음 다시 클릭하여 치수 배치 |
cancels | Double-click the tool for options 취소 | 도구를 두 번 클릭하면 옵션
All selected edges 선택한 모든 가장자리
Create Dimension 치수 만들기
Dimension Selected Paths 선택한 패스에 치수 기입
Dimension Tool 치수 도구
Dimension Tool Options 치수 도구 옵션
Display 표시
Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. 도면 : 실제 크기 — 1:10은 페이지의 1cm가 실제 10cm임을 뜻합니다.
Font Family: 글꼴 패밀리:
Font Style: 글꼴 스타일:
Precision: 정밀도:
Preview (1 cm on page) 미리 보기(페이지의 1cm)
Update Dimensions 치수 업데이트
X: X:
Y: Y:
@msg Angles require two straight segments 각도에는 직선 세그먼트 두 개가 필요합니다
@msg Choose a circular path 원형 패스를 선택하십시오
@msg Choose a length unit and precision from 0 to 6 길이 단위와 0~6 사이의 정밀도를 선택하십시오
@msg Choose two different points 서로 다른 두 점을 선택하십시오
@msg Choose two non-parallel straight segments 평행하지 않은 직선 세그먼트 두 개를 선택하십시오
@msg Choose two nonzero angle arms 길이가 0이 아닌 각도 변 두 개를 선택하십시오
@msg Dimension point is outside the canvas 치수 점이 캔버스 밖에 있습니다
@msg Dimension size is outside its supported range 치수 크기가 지원 범위를 벗어났습니다
@msg First measured segment no longer exists 첫 번째 측정 세그먼트가 더 이상 없습니다
@msg Invalid dimension distance 잘못된 치수 거리
@msg Invalid dimension font name 잘못된 치수 글꼴 이름
@msg Invalid drawing scale 잘못된 도면 축척
@msg Invalid real scale 잘못된 실제 축척
@msg Measured anchor no longer exists 측정한 고정점이 더 이상 없습니다
@msg Measured path no longer exists 측정한 패스가 더 이상 없습니다
@msg Measured segment no longer exists 측정한 세그먼트가 더 이상 없습니다
@msg Non-finite dimension 유한하지 않은 치수
@msg Scale must be drawing:real, for example 1:10 축척은 도면:실제 형식이어야 합니다(예: 1:10)
@msg Scale ratio must be between 1:1000000 and 1000000:1 축척 비율은 1:1000000에서 1000000:1 사이여야 합니다
@msg Second measured segment no longer exists 두 번째 측정 세그먼트가 더 이상 없습니다
@msg Unknown dimension mode 알 수 없는 치수 모드
@msg Unknown dimension style 알 수 없는 치수 스타일
@msg Unknown unit 알 수 없는 단위
# Revolve cap description
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. 열린 프로파일의 원형 끝을 닫습니다. 부분 회전에서는 절단 측면이 열린 상태로 유지됩니다.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+38 -1
View File
@@ -4651,6 +4651,43 @@ mark-style Japanese Japońskie
Softness Miękkość
Ambient Light Światło otoczenia
Light behind the object Światło za obiektem
# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
a highlighted edge, then click to place its dimension | podświetloną krawędź, a następnie kliknij, aby umieścić jej wymiar |
cancels | Double-click the tool for options anuluje | Kliknij dwukrotnie narzędzie, aby wyświetlić opcje
All selected edges Wszystkie zaznaczone krawędzie
Create Dimension Utwórz wymiar
Dimension Selected Paths Zwymiaruj zaznaczone ścieżki
Dimension Tool Narzędzie Wymiar
Dimension Tool Options Opcje narzędzia Wymiar
Display Wyświetlanie
Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. Rysunek : rozmiar rzeczywisty — 1:10 oznacza, że 1 cm na stronie odpowiada 10 cm.
Font Family: Rodzina czcionek:
Font Style: Styl czcionki:
Precision: Dokładność:
Preview (1 cm on page) Podgląd (1 cm na stronie)
Update Dimensions Aktualizuj wymiary
@msg Angles require two straight segments Kąty wymagają dwóch prostych odcinków
@msg Choose a circular path Wybierz ścieżkę w kształcie okręgu
@msg Choose a length unit and precision from 0 to 6 Wybierz jednostkę długości i dokładność od 0 do 6
@msg Choose two different points Wybierz dwa różne punkty
@msg Choose two non-parallel straight segments Wybierz dwa nierównoległe proste odcinki
@msg Choose two nonzero angle arms Wybierz dwa niezerowe ramiona kąta
@msg Dimension point is outside the canvas Punkt wymiaru leży poza obszarem roboczym
@msg Dimension size is outside its supported range Rozmiar wymiaru jest poza obsługiwanym zakresem
@msg First measured segment no longer exists Pierwszy mierzony odcinek już nie istnieje
@msg Invalid dimension distance Nieprawidłowa odległość wymiaru
@msg Invalid dimension font name Nieprawidłowa nazwa czcionki wymiaru
@msg Invalid drawing scale Nieprawidłowa skala rysunku
@msg Invalid real scale Nieprawidłowa skala rzeczywista
@msg Measured anchor no longer exists Mierzony punkt kontrolny już nie istnieje
@msg Measured path no longer exists Mierzona ścieżka już nie istnieje
@msg Measured segment no longer exists Mierzony odcinek już nie istnieje
@msg Non-finite dimension Wymiar nie jest liczbą skończoną
@msg Scale must be drawing:real, for example 1:10 Skala musi mieć postać rysunek:rzeczywistość, na przykład 1:10
@msg Scale ratio must be between 1:1000000 and 1000000:1 Współczynnik skali musi mieścić się między 1:1000000 a 1000000:1
@msg Second measured segment no longer exists Drugi mierzony odcinek już nie istnieje
@msg Unknown dimension mode Nieznany tryb wymiaru
@msg Unknown dimension style Nieznany styl wymiaru
@msg Unknown unit Nieznana jednostka
# Revolve cap description
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Zamknij okrągłe końce otwartych profili. Przy częściowym obrocie boki przekroju pozostają otwarte.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5 -1
View File
@@ -3763,6 +3763,10 @@ axis Both Ambos
Softness Suavidade
Ambient Light Luz ambiente
Light behind the object Luz atrás do objeto
# Dimension tool (linked dimension annotations)
Dimension Selected Paths Cotar Caminhos Selecionados
Dimension Tool Ferramenta Cota
Create Dimension Criar Cota
Update Dimensions Atualizar Cotas
# Revolve cap description
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Fecha as extremidades circulares dos perfis abertos. Giros parciais mantêm as faces de corte abertas.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+40
View File
@@ -4377,5 +4377,45 @@ lighting Light Свет
Ambient Light Окружающий свет
Light behind the object Свет позади объекта
# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
a highlighted edge, then click to place its dimension | по выделенному краю, затем щёлкните, чтобы разместить размер |
cancels | Double-click the tool for options — отмена | Двойной щелчок по инструменту — параметры
All selected edges Все выделенные края
Create Dimension Создать размер
Dimension Selected Paths Проставить размеры выделенных контуров
Dimension Tool Инструмент «Размер»
Dimension Tool Options Параметры инструмента «Размер»
Display Отображение
Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. Чертёж : реальный размер — 1:10 означает, что 1 см на странице соответствует 10 см.
Font Family: Семейство шрифтов:
Font Style: Начертание:
Precision: Точность:
Preview (1 cm on page) Просмотр (1 см на странице)
Update Dimensions Обновить размеры
X: X:
Y: Y:
@msg Angles require two straight segments Для угла нужны два прямых сегмента
@msg Choose a circular path Выберите круглый контур
@msg Choose a length unit and precision from 0 to 6 Выберите единицу длины и точность от 0 до 6
@msg Choose two different points Выберите две разные точки
@msg Choose two non-parallel straight segments Выберите два непараллельных прямых сегмента
@msg Choose two nonzero angle arms Выберите две стороны угла ненулевой длины
@msg Dimension point is outside the canvas Точка размера находится за пределами холста
@msg Dimension size is outside its supported range Размер выходит за поддерживаемые пределы
@msg First measured segment no longer exists Первый измеряемый сегмент больше не существует
@msg Invalid dimension distance Недопустимое расстояние размера
@msg Invalid dimension font name Недопустимое имя шрифта размера
@msg Invalid drawing scale Недопустимый масштаб чертежа
@msg Invalid real scale Недопустимый реальный масштаб
@msg Measured anchor no longer exists Измеряемая опорная точка больше не существует
@msg Measured path no longer exists Измеряемый контур больше не существует
@msg Measured segment no longer exists Измеряемый сегмент больше не существует
@msg Non-finite dimension Размер не является конечным числом
@msg Scale must be drawing:real, for example 1:10 Масштаб должен быть в виде чертёж:реальность, например 1:10
@msg Scale ratio must be between 1:1000000 and 1000000:1 Соотношение масштаба должно быть от 1:1000000 до 1000000:1
@msg Second measured segment no longer exists Второй измеряемый сегмент больше не существует
@msg Unknown dimension mode Неизвестный режим размера
@msg Unknown dimension style Неизвестный стиль размера
@msg Unknown unit Неизвестная единица
# Revolve cap description
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Закрывает круглые торцы открытых профилей. При неполном вращении боковые срезы остаются открытыми.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+40
View File
@@ -4361,5 +4361,45 @@ lighting Light Світло
Ambient Light Навколишнє світло
Light behind the object Світло позаду об'єкта
# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
a highlighted edge, then click to place its dimension | по виділеному краю, потім клацніть, щоб розмістити розмір |
cancels | Double-click the tool for options — скасування | Подвійне клацання по інструменту — параметри
All selected edges Усі виділені краї
Create Dimension Створити розмір
Dimension Selected Paths Проставити розміри виділених контурів
Dimension Tool Інструмент «Розмір»
Dimension Tool Options Параметри інструмента «Розмір»
Display Відображення
Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. Креслення : реальний розмір — 1:10 означає, що 1 см на сторінці відповідає 10 см.
Font Family: Сімейство шрифтів:
Font Style: Накреслення:
Precision: Точність:
Preview (1 cm on page) Попередній перегляд (1 см на сторінці)
Update Dimensions Оновити розміри
X: X:
Y: Y:
@msg Angles require two straight segments Для кута потрібні два прямі сегменти
@msg Choose a circular path Виберіть круглий контур
@msg Choose a length unit and precision from 0 to 6 Виберіть одиницю довжини й точність від 0 до 6
@msg Choose two different points Виберіть дві різні точки
@msg Choose two non-parallel straight segments Виберіть два непаралельні прямі сегменти
@msg Choose two nonzero angle arms Виберіть дві сторони кута ненульової довжини
@msg Dimension point is outside the canvas Точка розміру знаходиться за межами полотна
@msg Dimension size is outside its supported range Розмір виходить за підтримувані межі
@msg First measured segment no longer exists Перший виміряний сегмент більше не існує
@msg Invalid dimension distance Неприпустима відстань розміру
@msg Invalid dimension font name Неприпустима назва шрифту розміру
@msg Invalid drawing scale Неприпустимий масштаб креслення
@msg Invalid real scale Неприпустимий реальний масштаб
@msg Measured anchor no longer exists Виміряна опорна точка більше не існує
@msg Measured path no longer exists Виміряний контур більше не існує
@msg Measured segment no longer exists Виміряний сегмент більше не існує
@msg Non-finite dimension Розмір не є скінченним числом
@msg Scale must be drawing:real, for example 1:10 Масштаб має бути у вигляді креслення:реальність, наприклад 1:10
@msg Scale ratio must be between 1:1000000 and 1000000:1 Співвідношення масштабу має бути від 1:1000000 до 1000000:1
@msg Second measured segment no longer exists Другий виміряний сегмент більше не існує
@msg Unknown dimension mode Невідомий режим розміру
@msg Unknown dimension style Невідомий стиль розміру
@msg Unknown unit Невідома одиниця
# Revolve cap description
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Закриває круглі торці відкритих профілів. За неповного обертання бічні зрізи залишаються відкритими.
Can't render this file because it contains an unexpected character in line 713 and column 40.
+38 -1
View File
@@ -4362,6 +4362,43 @@ theme Appearance Giao diện
Softness Độ mềm
Ambient Light Ánh sáng môi trường
Light behind the object Ánh sáng phía sau đối tượng
# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
a highlighted edge, then click to place its dimension | một cạnh được tô sáng, rồi nhấp để đặt kích thước của nó |
cancels | Double-click the tool for options để hủy | Nhấp đúp vào công cụ để mở tùy chọn
All selected edges Tất cả các cạnh đã chọn
Create Dimension Tạo kích thước
Dimension Selected Paths Ghi kích thước cho các đường đã chọn
Dimension Tool Công cụ Kích thước
Dimension Tool Options Tùy chọn công cụ Kích thước
Display Hiển thị
Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. Bản vẽ : kích thước thực — 1:10 nghĩa là 1 cm trên trang tương ứng với 10 cm.
Font Family: Họ phông chữ:
Font Style: Kiểu phông chữ:
Precision: Độ chính xác:
Preview (1 cm on page) Xem trước (1 cm trên trang)
Update Dimensions Cập nhật kích thước
@msg Angles require two straight segments Góc cần hai đoạn thẳng
@msg Choose a circular path Hãy chọn một đường tròn
@msg Choose a length unit and precision from 0 to 6 Hãy chọn đơn vị độ dài và độ chính xác từ 0 đến 6
@msg Choose two different points Hãy chọn hai điểm khác nhau
@msg Choose two non-parallel straight segments Hãy chọn hai đoạn thẳng không song song
@msg Choose two nonzero angle arms Hãy chọn hai cạnh góc có độ dài khác 0
@msg Dimension point is outside the canvas Điểm kích thước nằm ngoài vùng vẽ
@msg Dimension size is outside its supported range Kích thước nằm ngoài phạm vi được hỗ trợ
@msg First measured segment no longer exists Đoạn được đo thứ nhất không còn tồn tại
@msg Invalid dimension distance Khoảng cách kích thước không hợp lệ
@msg Invalid dimension font name Tên phông chữ của kích thước không hợp lệ
@msg Invalid drawing scale Tỷ lệ bản vẽ không hợp lệ
@msg Invalid real scale Tỷ lệ thực không hợp lệ
@msg Measured anchor no longer exists Điểm neo được đo không còn tồn tại
@msg Measured path no longer exists Đường được đo không còn tồn tại
@msg Measured segment no longer exists Đoạn được đo không còn tồn tại
@msg Non-finite dimension Kích thước không phải là số hữu hạn
@msg Scale must be drawing:real, for example 1:10 Tỷ lệ phải có dạng bản vẽ:thực tế, ví dụ 1:10
@msg Scale ratio must be between 1:1000000 and 1000000:1 Tỷ lệ phải nằm trong khoảng 1:1000000 đến 1000000:1
@msg Second measured segment no longer exists Đoạn được đo thứ hai không còn tồn tại
@msg Unknown dimension mode Chế độ kích thước không xác định
@msg Unknown dimension style Kiểu kích thước không xác định
@msg Unknown unit Đơn vị không xác định
# Revolve cap description
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Đóng các đầu tròn của biên dạng mở. Khi xoay một phần, các mặt cắt bên vẫn mở.
Can't render this file because it contains an unexpected character in line 3682 and column 10.
+16
View File
@@ -3957,6 +3957,22 @@ lighting Light 光源
Softness 柔和度
Ambient Light 环境光
Light behind the object 光源位于对象后方
# Dimension tool (linked dimension annotations)
a highlighted edge, then click to place its dimension | 高亮显示的边缘,然后单击以放置尺寸标注 |
cancels | Double-click the tool for options 以取消 | 双击工具以打开选项
All selected edges 所有选定的边缘
Create Dimension 创建尺寸标注
Dimension Selected Paths 为选定路径标注尺寸
Dimension Tool 尺寸标注工具
Dimension Tool Options 尺寸标注工具选项
Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. 图纸 : 实际尺寸 — 1:10 表示页面上的 1 cm 代表实际的 10 cm。
Font Family: 字体系列:
Font Style: 字体样式:
Precision: 精度:
Preview (1 cm on page) 预览(页面上的 1 cm)
Update Dimensions 更新尺寸标注
Display 显示
# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
# Revolve cap description
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. 封闭开放轮廓的圆形端部。部分旋转的切割侧面保持开放。
Can't render this file because it contains an unexpected character in line 2 and column 73.
+18
View File
@@ -3741,6 +3741,24 @@ lighting Light 光源
Softness 柔和度
Ambient Light 環境光
Light behind the object 光源位於物件後方
# Dimension tool (linked dimension annotations)
a highlighted edge, then click to place its dimension | 醒目提示的邊緣,然後按一下以放置尺寸標註 |
cancels | Double-click the tool for options 可取消 | 按兩下工具以開啟選項
All selected edges 所有選取的邊緣
Create Dimension 建立尺寸標註
Dimension Selected Paths 為選取的路徑標註尺寸
Dimension Tool 尺寸標註工具
Dimension Tool Options 尺寸標註工具選項
Drawing : real size — 1:10 means 1 cm on the page represents 10 cm. 圖面 : 實際尺寸 — 1:10 表示頁面上的 1 cm 代表實際的 10 cm。
Font Family: 字體系列:
Font Style: 字體樣式:
Precision: 精確度:
Preview (1 cm on page) 預覽(頁面上的 1 cm)
Update Dimensions 更新尺寸標註
Display 顯示
# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
X: X:
Y: Y:
# Revolve cap description
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. 封閉開放輪廓的圓形端部。部分旋轉的切割側面保持開放。
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -2396,6 +2396,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
c("Fit to Selected Art", "artboard.fitToSelection"),
],
),
sub("Dimensions", vec![c("Dimension Selected Paths", "dimension.selection")]),
sub(
"Graph",
vec![
+6
View File
@@ -58,6 +58,7 @@ pub const BASIC: &[(&str, &[&[&str]])] = &[
&["width", "warp", "twirl", "pucker", "bloat", "scallop", "crystallize", "wrinkle"],
&["rotate", "reflect", "scale", "shear", "reshape", "freeTransform", "puppetWarp"],
&["shapeBuilder", "livePaintBucket", "livePaintSelection", "blend"],
&["dimension"],
],
),
("Type", &[&["areaType", "typeOnPath", "verticalAreaType", "verticalTypeOnPath"], &["type", "verticalType", "touchType"]]),
@@ -129,6 +130,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
// Before the flyout: one torn off this frame floats from the next (its bar mustn't be in two
// layers in one frame).
floating(app, ui.ctx());
crate::dialogs::dimension::floating(app, ui.ctx());
flyout(app, ui.ctx());
crate::edit_toolbar::drawer(app, ui.ctx());
}
@@ -324,6 +326,7 @@ fn flyout_press(ui: &Ui, resp: &egui::Response, rect: egui::Rect) -> Option<Flyo
/// Options.
pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::Value, String> {
match tool {
"dimension" => crate::dialogs::dimension::open(app),
"hand" => app.run("view.fitArtboard", json!({})),
"zoom" => app.run("view.actualSize", json!({})),
"rotate" | "scale" | "reflect" | "shear" | "selection" | "directSelection" | "groupSelection" => {
@@ -372,6 +375,9 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::
/// Warp's mesh and pins, the Artboard tool's Move and Scale Artwork with Artboard (set through
/// `tool.setOption`).
pub fn control_bar_options(app: &mut VectorcraftApp, ui: &mut Ui) {
if app.session.tool_id() == "dimension" {
return crate::dialogs::dimension::controls(app, ui);
}
/// (value, label) of each choice.
type Choices = &'static [(&'static str, &'static str)];
const MIRROR: [(&str, &str, Choices); 2] = [
+58
View File
@@ -0,0 +1,58 @@
# Dimensions
Choose **Dimension Tool** in the Modify section of the toolbar. Its control strip offers
**Length**, **Angle**, **Radius**, **Diameter**, **Options…** and **All selected edges**.
- **Length:** hover a path to highlight the segment between two anchors. Click it, move the
pointer to place the dimension, then click again. Curved segments report their actual curve
length, prefixed **Arc**, rather than the distance between their endpoints.
- **Two points:** Alt-click an anchor to start measuring individual points, click the second
point, then click to place the dimension. Picked anchors remain linked to their original paths.
Empty-space clicks also work; these fixed points have no source object to follow. Shift
constrains a manually picked point to 45-degree increments.
- **Angle:** click two nonparallel straight segments, then click to place the angle. To choose
three points instead, Alt-click the first arm, click the vertex, then the second arm; click
once more to place the annotation. Angles use the smaller angle between the chosen arms.
- **Radius / Diameter:** click a circular path, then move and click to choose the direction of
the annotation. Ellipses are rejected because they have no single radius.
- **All selected edges:** select paths or groups, then use this button or
**Object → Dimensions → Dimension Selected Paths**. Adds up to 1,000 segment dimensions in
one undo step. Existing dimension groups, hidden art and locked art are skipped.
- **Escape** or switching tools cancels a measurement being placed.
Double-click the tool or choose **Options…** for units, precision (0–6 decimal places), scale,
line weight/type/colour, arrows and arrow size, extension-line gaps/overshoot/visibility, font
family/style/size/colour, text position/orientation/offset. **Reset** restores defaults in the
dialog; **Cancel** leaves settings unchanged. **Save** stores tool defaults, and optionally
updates selected dimension groups. These changes are undoable; defaults persist in preferences.
Scale means **drawing : real size**, independent of zoom. At **1:10**, a 1 cm line on the page
is labelled 10 cm. At **10:1**, it is labelled 0.1 cm. Presets and custom positive ratios are
supported. Scale affects lengths, not angles or the physical size of text/arrows.
Measurements track source moves, scaling and anchor/handle edits, including undo/redo.
Deleting a source or making it degenerate removes its linked annotations in the same undo
step. Changing a path's anchor count/closedness invalidates indexed references and removes
those annotations rather than attaching them to a different edge. Recreate them after such
topology edits. A circle changed into an ellipse similarly loses its radius/diameter annotation.
Native `.vectorcraft` files retain recipes and links. SVG/PDF/bitmap output contains ordinary
paths and text, so exported dimensions look the same; generic SVG/PDF round trips do not promise
editable measurement links. Ungrouping exposes the generated paths/text as ordinary artwork.
Copied/pasted dimensions retain their measured geometry but detach their original links, so
source ids cannot accidentally bind to unrelated objects in another document.
## Commands
- `dimension.create`: fixed points or linked segment/angle/circle/anchor sources, mode, offset,
and style options; returns the group id and label.
- `dimension.selection`: all selected path segments, one undo step.
- `dimension.update`: change existing groups' options and/or placement offset.
- `tool.select {"tool":"dimension"}` and
`tool.setOption {"tool":"dimension","values":{"mode":"linear","style":{"units":"cm","scale":"1:10"}}}`.
- `tool.options {"tool":"dimension"}` opens the agent-accessible `dimensionOptions` dialog.
Recipes live on normal document groups (`Node.dimension`); rendering and export use their
generated children. Commands validate options and finite bounds before mutation. Source edits
refresh annotations inside `Session::edit`, sharing history and cancellation with the source.
The icon uses the project's existing original measurement artwork; no external assets are added.