forked from 1b-agent/opencadstudio-upstream
Name clashes, DGNIMPORTMODE and prefixing as the reference names them; V7 cell copies, unshared cells and mirrored placements as blocks; justified text and text nodes placed at their justification points with the reference's MTEXT contents; B-spline curves as splines and 3D arcs and ellipses in their own planes; every V8 level as a layer; design models only; reference attachments translated, ignored or attached as underlays. The colour index palette comes from the codec fix it depends on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2539 lines
120 KiB
Rust
2539 lines
120 KiB
Rust
#[cfg(not(target_arch = "wasm32"))]
|
|
use crate::io::plot_style::PlotStyleTableExt as _;
|
|
use super::*;
|
|
use crate::scene::model::object::PropValue;
|
|
|
|
fn control_color(value: &Value) -> Result<codec::types::Color, Value> {
|
|
if let Some(index) = value.as_i64().and_then(|v| i16::try_from(v).ok()) {
|
|
return Ok(codec::types::Color::from_index(index));
|
|
}
|
|
if let Some(rgb) = value
|
|
.get("rgb")
|
|
.and_then(Value::as_array)
|
|
.filter(|v| v.len() == 3)
|
|
{
|
|
let component = |i: usize| rgb[i].as_u64().and_then(|v| u8::try_from(v).ok());
|
|
return match (component(0), component(1), component(2)) {
|
|
(Some(r), Some(g), Some(b)) => Ok(codec::types::Color::from_rgb(r, g, b)),
|
|
_ => Err(failure(
|
|
"invalid_color",
|
|
"RGB components must be 0 through 255",
|
|
)),
|
|
};
|
|
}
|
|
Err(failure(
|
|
"invalid_color",
|
|
"Use an index number or {rgb:[r,g,b]}",
|
|
))
|
|
}
|
|
|
|
fn control_lineweight(value: &Value) -> Result<codec::types::LineWeight, Value> {
|
|
let raw = value
|
|
.as_i64()
|
|
.and_then(|v| i16::try_from(v).ok())
|
|
.ok_or_else(|| failure("invalid_lineweight", "Use the raw lineweight value"))?;
|
|
let candidate = codec::types::LineWeight::from_value(raw);
|
|
crate::ui::properties::lw_options()
|
|
.iter()
|
|
.any(|v| v.0 == candidate)
|
|
.then_some(candidate)
|
|
.ok_or_else(|| failure("invalid_lineweight", "Value is not a standard lineweight"))
|
|
}
|
|
|
|
fn color_value(color: codec::types::Color) -> Value {
|
|
if let Some((r, g, b)) = color.rgb() {
|
|
json!({"rgb":[r,g,b]})
|
|
} else {
|
|
json!(color.index())
|
|
}
|
|
}
|
|
/// One Properties-panel row as the control protocol's JSON (`id`, `label`,
|
|
/// `kind`, `value`, `options`). The node graph moves values in this form too,
|
|
/// so a port's value round-trips through [`OpenCADStudio::set_property_value`].
|
|
pub(crate) fn property_json(p: &crate::scene::model::object::Property) -> Value {
|
|
let (kind, value, options) = match &p.value {
|
|
PropValue::ReadOnly(v) | PropValue::ReadOnlyWithTooltip { value: v, .. } => {
|
|
("readonly", json!(v), Value::Null)
|
|
}
|
|
PropValue::EditText(v) => ("number", json!(v), Value::Null),
|
|
PropValue::PlainText(v) => ("text", json!(v), Value::Null),
|
|
PropValue::Hyperlink(v) => ("hyperlink", json!(v), Value::Null),
|
|
PropValue::Choice { selected, options } => ("choice", json!(selected), json!(options)),
|
|
PropValue::EditChoice { value, options } => {
|
|
("editable_choice", json!(value), json!(options))
|
|
}
|
|
PropValue::LayerChoice(v) => ("layer", json!(v), Value::Null),
|
|
PropValue::LinetypeChoice(v) => ("linetype", json!(v), Value::Null),
|
|
PropValue::BoolToggle { value, .. } => ("bool", json!(value), Value::Null),
|
|
PropValue::ColorChoice(value) | PropValue::NamedColorChoice { color: value, .. } => {
|
|
("color", color_value(*value), Value::Null)
|
|
}
|
|
PropValue::ColorVaries => ("color", Value::Null, Value::Null),
|
|
PropValue::LwChoice(value) | PropValue::FieldLwChoice { value, .. } => (
|
|
"lineweight",
|
|
json!(value.value()),
|
|
json!(crate::ui::properties::lw_options()
|
|
.iter()
|
|
.map(|v| v.0.value())
|
|
.collect::<Vec<_>>()),
|
|
),
|
|
PropValue::LwVaries | PropValue::FieldLwVaries { .. } => (
|
|
"lineweight",
|
|
Value::Null,
|
|
json!(crate::ui::properties::lw_options()
|
|
.iter()
|
|
.map(|v| v.0.value())
|
|
.collect::<Vec<_>>()),
|
|
),
|
|
PropValue::AttrText { tag, value } => {
|
|
("attribute", json!({"tag":tag,"value":value}), Value::Null)
|
|
}
|
|
other => ("specialized", json!(format!("{other:?}")), Value::Null),
|
|
};
|
|
json!({"id":p.field,"label":p.label,"kind":kind,"value":value,"options":options})
|
|
}
|
|
pub(super) const NAMES: &[&str] = &[
|
|
"close_modal",
|
|
// Native-only: the "changed outside" dialog does not exist in the web
|
|
// build, so neither does its action set.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
"external_change_reload",
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
"external_change_overwrite",
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
"external_change_save_as",
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
"external_change_cancel",
|
|
"pdf_dialog_ok",
|
|
"txt2mtxt_settings",
|
|
"view_background",
|
|
"image_adjust",
|
|
"import_file_dialog",
|
|
"dgn_import_dialog",
|
|
"refedit_dialog",
|
|
"pdf_shx_settings",
|
|
"pdf_layer_toggle",
|
|
"pc_manager_toggle",
|
|
"pc_manager",
|
|
"count_palette",
|
|
"blocks_palette",
|
|
"pdf_page_select",
|
|
"pc_colormap",
|
|
"pc_section",
|
|
"ribbon_tab",
|
|
"ribbon_dropdown",
|
|
"keytips",
|
|
"dialog_ok",
|
|
"block_def_dialog",
|
|
"attdef_dialog",
|
|
"field_dialog",
|
|
"attman_dialog",
|
|
"sheet_set",
|
|
"close_document",
|
|
"toggle_properties",
|
|
"toggle_layers",
|
|
"toggle_grid",
|
|
"toggle_snap",
|
|
"toggle_ortho",
|
|
"grip_menu_pick",
|
|
"mtext_insert",
|
|
"mtext_commit",
|
|
"mtext_cancel",
|
|
"text_input",
|
|
"text_commit",
|
|
"layer_visible",
|
|
"layer_locked",
|
|
"layer_frozen",
|
|
"layer_current",
|
|
"view_home",
|
|
"zoom_extents",
|
|
"undo",
|
|
"redo",
|
|
];
|
|
|
|
/// Parse the `at` placement point for `embed_image`: `[x,y]` or `[x,y,z]`.
|
|
fn embed_point(req: &Value) -> Result<codec::types::Vector3, Value> {
|
|
let values = req["at"]
|
|
.as_array()
|
|
.filter(|v| (2..=3).contains(&v.len()))
|
|
.ok_or_else(|| failure("invalid_point", "Expected at:[x,y] or [x,y,z]"))?;
|
|
let mut p = [0.0_f64; 3];
|
|
for (i, v) in values.iter().enumerate() {
|
|
p[i] = v
|
|
.as_f64()
|
|
.filter(|v| v.is_finite())
|
|
.ok_or_else(|| failure("invalid_point", "Expected finite coordinates"))?;
|
|
}
|
|
Ok(codec::types::Vector3::new(p[0], p[1], p[2]))
|
|
}
|
|
fn parse_point_2d(val: &Value) -> Result<[f64; 2], Value> {
|
|
let arr = val
|
|
.as_array()
|
|
.filter(|v| v.len() >= 2)
|
|
.ok_or_else(|| failure("invalid_point", "Expected [x, y]"))?;
|
|
let x = arr[0]
|
|
.as_f64()
|
|
.filter(|v| v.is_finite())
|
|
.ok_or_else(|| failure("invalid_point", "Expected finite coordinates"))?;
|
|
let y = arr[1]
|
|
.as_f64()
|
|
.filter(|v| v.is_finite())
|
|
.ok_or_else(|| failure("invalid_point", "Expected finite coordinates"))?;
|
|
Ok([x, y])
|
|
}
|
|
|
|
fn parse_calibrate(
|
|
cal: &Value,
|
|
pixel_width: u32,
|
|
pixel_height: u32,
|
|
) -> Result<(codec::types::Vector3, f64, f64), Value> {
|
|
let pt_a = cal
|
|
.get("point_a")
|
|
.or_else(|| cal.get("pixel_a"))
|
|
.or_else(|| cal.get("p1"))
|
|
.or_else(|| cal.get("source_a"));
|
|
let pt_b = cal
|
|
.get("point_b")
|
|
.or_else(|| cal.get("pixel_b"))
|
|
.or_else(|| cal.get("p2"))
|
|
.or_else(|| cal.get("source_b"));
|
|
let dist = cal
|
|
.get("distance")
|
|
.or_else(|| cal.get("real_distance"))
|
|
.or_else(|| cal.get("length"))
|
|
.and_then(Value::as_f64);
|
|
|
|
if let (Some(pa), Some(pb), Some(d_real)) = (pt_a, pt_b, dist) {
|
|
if !d_real.is_finite() || d_real <= 0.0 {
|
|
return Err(failure(
|
|
"invalid_distance",
|
|
"Calibration distance must be positive",
|
|
));
|
|
}
|
|
let p1 = parse_point_2d(pa)?;
|
|
let p2 = parse_point_2d(pb)?;
|
|
let d_px = ((p2[0] - p1[0]).powi(2) + (p2[1] - p1[1]).powi(2)).sqrt();
|
|
if d_px <= 1e-6 {
|
|
return Err(failure(
|
|
"invalid_points",
|
|
"Calibration reference points must be distinct",
|
|
));
|
|
}
|
|
let scale = d_real / d_px;
|
|
let width = pixel_width as f64 * scale;
|
|
|
|
let align_to = cal
|
|
.get("align_to")
|
|
.or_else(|| cal.get("origin"))
|
|
.or_else(|| cal.get("target_a"))
|
|
.map(parse_point_2d)
|
|
.transpose()?
|
|
.unwrap_or([0.0, 0.0]);
|
|
|
|
let x_ll = align_to[0] - p1[0] * scale;
|
|
let y_ll = align_to[1] - (pixel_height as f64 - p1[1]) * scale;
|
|
return Ok((codec::types::Vector3::new(x_ll, y_ll, 0.0), width, scale));
|
|
}
|
|
|
|
if let (Some(src_pts), Some(tgt_pts)) = (
|
|
cal.get("source_points").and_then(Value::as_array),
|
|
cal.get("target_points").and_then(Value::as_array),
|
|
) {
|
|
if src_pts.len() >= 2 && tgt_pts.len() >= 2 {
|
|
let p_s1 = parse_point_2d(&src_pts[0])?;
|
|
let p_s2 = parse_point_2d(&src_pts[1])?;
|
|
let p_t1 = parse_point_2d(&tgt_pts[0])?;
|
|
let p_t2 = parse_point_2d(&tgt_pts[1])?;
|
|
let d_px = ((p_s2[0] - p_s1[0]).powi(2) + (p_s2[1] - p_s1[1]).powi(2)).sqrt();
|
|
let d_cad = ((p_t2[0] - p_t1[0]).powi(2) + (p_t2[1] - p_t1[1]).powi(2)).sqrt();
|
|
if d_px <= 1e-6 {
|
|
return Err(failure(
|
|
"invalid_points",
|
|
"Source calibration points must be distinct",
|
|
));
|
|
}
|
|
let scale = d_cad / d_px;
|
|
let width = pixel_width as f64 * scale;
|
|
let x_ll = p_t1[0] - p_s1[0] * scale;
|
|
let y_ll = p_t1[1] - (pixel_height as f64 - p_s1[1]) * scale;
|
|
return Ok((codec::types::Vector3::new(x_ll, y_ll, 0.0), width, scale));
|
|
}
|
|
}
|
|
|
|
Err(failure(
|
|
"invalid_calibrate",
|
|
"calibrate requires point_a:[x,y], point_b:[x,y], distance:number, and optional align_to:[x,y]",
|
|
))
|
|
}
|
|
|
|
impl OpenCADStudio {
|
|
/// `embed_image` — pack the picture at `path` into an OLE2FRAME or linked RasterImage
|
|
/// placed with its lower-left corner at `at` (default width ≈ pixel_width/100).
|
|
/// Supports automatic reference scaling and calibration via `calibrate`:
|
|
/// `{"point_a": [px1, py1], "point_b": [px2, py2], "distance": 6500.0, "align_to": [0, 0]}`.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
pub(super) fn control_embed_image(&mut self, req: &Value) -> Result<Task<Message>, Value> {
|
|
let i = self.active_tab;
|
|
let path = string(req, "path")?;
|
|
let image = crate::io::ole_embed::EmbeddedImage::from_file(std::path::Path::new(&path))
|
|
.map_err(|e| failure("embed_failed", e))?;
|
|
|
|
let (at, width, scale) = if let Some(cal) = req.get("calibrate") {
|
|
parse_calibrate(cal, image.pixel_width, image.pixel_height)?
|
|
} else if let (Some(src_pts), Some(tgt_pts)) = (
|
|
req.get("source_points").and_then(Value::as_array),
|
|
req.get("target_points").and_then(Value::as_array),
|
|
) {
|
|
let dummy = json!({"source_points": src_pts, "target_points": tgt_pts});
|
|
parse_calibrate(&dummy, image.pixel_width, image.pixel_height)?
|
|
} else {
|
|
let default_width = (image.pixel_width as f64 / 100.0).max(1.0);
|
|
let width = req["width"]
|
|
.as_f64()
|
|
.filter(|w| *w > 0.0)
|
|
.unwrap_or(default_width);
|
|
let scale = width / image.pixel_width as f64;
|
|
(embed_point(req)?, width, scale)
|
|
};
|
|
|
|
let final_at = if req.get("calibrate").is_none() && req.get("source_points").is_none() {
|
|
at
|
|
} else {
|
|
embed_point(req).unwrap_or(at)
|
|
};
|
|
let final_width = req["width"].as_f64().filter(|w| *w > 0.0).unwrap_or(width);
|
|
let height = final_width * image.pixel_height as f64 / image.pixel_width as f64;
|
|
|
|
let layer_name = req["layer"].as_str().filter(|l| !l.is_empty());
|
|
let lock_layer = req["lock_layer"].as_bool().unwrap_or(false);
|
|
|
|
if req["linked"].as_bool().unwrap_or(false) {
|
|
self.push_undo_snapshot(i, "IMAGEATTACH");
|
|
let handle = {
|
|
let document = &mut self.tabs[i].scene.document;
|
|
let definition_handle = document.allocate_handle();
|
|
let mut definition = codec::objects::ImageDefinition::with_dimensions(
|
|
path,
|
|
image.pixel_width,
|
|
image.pixel_height,
|
|
);
|
|
definition.handle = definition_handle;
|
|
document.objects.insert(
|
|
definition_handle,
|
|
codec::objects::ObjectType::ImageDefinition(definition),
|
|
);
|
|
let mut entity = codec::entities::RasterImage::with_size(
|
|
path,
|
|
final_at,
|
|
image.pixel_width as f64,
|
|
image.pixel_height as f64,
|
|
final_width,
|
|
height,
|
|
);
|
|
entity.definition_handle = Some(definition_handle);
|
|
if let Some(l_name) = layer_name {
|
|
entity.common.layer = l_name.to_string();
|
|
if !document.layers.contains(l_name) {
|
|
let mut layer_record = codec::tables::Layer::new(l_name.to_string());
|
|
layer_record.handle = document.allocate_handle();
|
|
if lock_layer {
|
|
layer_record.flags.locked = true;
|
|
}
|
|
let _ = document.layers.add(layer_record);
|
|
} else if lock_layer {
|
|
if let Some(l) = document.layers.get_mut(l_name) {
|
|
l.flags.locked = true;
|
|
}
|
|
}
|
|
}
|
|
document
|
|
.add_entity(codec::EntityType::RasterImage(entity))
|
|
.map_err(|e| failure("embed_failed", e))?
|
|
};
|
|
self.tabs[i].scene.populate_images_from_document();
|
|
if layer_name.is_some() {
|
|
self.refresh_layer_panel();
|
|
}
|
|
self.post_ref_op(i);
|
|
self.set_control_result(json!({
|
|
"handle": format!("{:X}", handle.value()),
|
|
"kind": "RasterImage",
|
|
"path": path,
|
|
"at": [final_at.x, final_at.y, final_at.z],
|
|
"width": final_width,
|
|
"height": height,
|
|
"scale": scale,
|
|
"linked": true,
|
|
"layer": layer_name.unwrap_or("0"),
|
|
}));
|
|
return Ok(Task::none());
|
|
}
|
|
|
|
self.push_undo_snapshot(i, "IMAGEEMBED");
|
|
let handle = crate::io::ole_embed::add_embedded_image(
|
|
&mut self.tabs[i].scene.document,
|
|
&image,
|
|
final_at,
|
|
final_width,
|
|
)
|
|
.map_err(|e| failure("embed_failed", e))?;
|
|
|
|
if let Some(l_name) = layer_name {
|
|
let document = &mut self.tabs[i].scene.document;
|
|
if !document.layers.contains(l_name) {
|
|
let mut layer_record = codec::tables::Layer::new(l_name.to_string());
|
|
layer_record.handle = document.allocate_handle();
|
|
if lock_layer {
|
|
layer_record.flags.locked = true;
|
|
}
|
|
let _ = document.layers.add(layer_record);
|
|
} else if lock_layer {
|
|
if let Some(l) = document.layers.get_mut(l_name) {
|
|
l.flags.locked = true;
|
|
}
|
|
}
|
|
if let Some(ent) = document.get_entity_mut(handle) {
|
|
ent.common_mut().layer = l_name.to_string();
|
|
}
|
|
self.refresh_layer_panel();
|
|
}
|
|
|
|
self.post_ref_op(i);
|
|
self.set_control_result(json!({
|
|
"handle": format!("{:X}", handle.value()),
|
|
"kind": "Ole2Frame",
|
|
"path": path,
|
|
"at": [final_at.x, final_at.y, final_at.z],
|
|
"width": final_width,
|
|
"height": height,
|
|
"scale": scale,
|
|
"linked": false,
|
|
"layer": layer_name.unwrap_or("0"),
|
|
}));
|
|
Ok(Task::none())
|
|
}
|
|
|
|
/// `wblock` — write the named block's entities or the listed entity handles
|
|
/// out to a standalone DWG/DXF file. The open document is not modified, so
|
|
/// this needs no undo snapshot. Format follows the path extension.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
pub(super) fn control_wblock(&mut self, req: &Value) -> Result<Task<Message>, Value> {
|
|
if self.tabs[self.active_tab].block_test.is_some() {
|
|
return Err(failure(
|
|
"temporary_document",
|
|
"Test Block is temporary and cannot be saved",
|
|
));
|
|
}
|
|
let path = string(req, "path")?;
|
|
let document = &self.tabs[self.active_tab].scene.document;
|
|
// A template base keeps its tables/styles in the exported file
|
|
// (Catalog §2.1: clone from a .dwt/.dwg template).
|
|
let template_base = match req["template"].as_str().filter(|t| !t.is_empty()) {
|
|
Some(template) => Some(
|
|
crate::io::load_file(std::path::Path::new(template))
|
|
.map_err(|e| failure("template_missing", e))?,
|
|
),
|
|
None => None,
|
|
};
|
|
let extracted = if let Some(block) = req["block"].as_str() {
|
|
match &template_base {
|
|
Some(base) => {
|
|
let mut base = base.clone();
|
|
crate::modules::insert::wblock::extract_block_into(document, block, &mut base)?;
|
|
Ok(base)
|
|
}
|
|
None => crate::modules::insert::wblock::extract_block_to_doc(document, block),
|
|
}
|
|
} else if let Some(listed) = req["handles"].as_array() {
|
|
let handles: Result<Vec<codec::Handle>, Value> = listed
|
|
.iter()
|
|
.map(|v| {
|
|
u64::from_str_radix(
|
|
v.as_str()
|
|
.unwrap_or("")
|
|
.trim_start_matches("0x")
|
|
.trim_start_matches("0X"),
|
|
16,
|
|
)
|
|
.map(codec::Handle::new)
|
|
.map_err(|_| {
|
|
failure(
|
|
"invalid_handle",
|
|
format!("Expected a hexadecimal handle, got {v}"),
|
|
)
|
|
})
|
|
})
|
|
.collect();
|
|
match &template_base {
|
|
Some(base) => {
|
|
let mut base = base.clone();
|
|
crate::modules::insert::wblock::extract_entities_into(
|
|
document, &handles?, &mut base,
|
|
)?;
|
|
Ok(base)
|
|
}
|
|
None => {
|
|
crate::modules::insert::wblock::extract_entities_to_doc(document, &handles?)
|
|
}
|
|
}
|
|
} else {
|
|
return Err(failure(
|
|
"selection_required",
|
|
"Supply \"handles\" or \"block\"",
|
|
));
|
|
};
|
|
let mut extracted = extracted.map_err(|e| failure("wblock_failed", e))?;
|
|
// CF-01.2: optionally shift the export so its overall bounds minimum
|
|
// lands on the origin (SPM.ACAD normalizes cloned drawings to 0,0,0).
|
|
let normalized = req["normalize"].as_bool().unwrap_or(false);
|
|
if normalized {
|
|
crate::modules::insert::wblock::normalize_to_origin(&mut extracted);
|
|
}
|
|
let entities = extracted.entities().count();
|
|
// A .dwt target is written as DWG bytes under a hidden scratch name,
|
|
// then renamed (DWT is a DWG-family file).
|
|
let mut target = std::path::PathBuf::from(path);
|
|
let mut scratch = None;
|
|
if target
|
|
.extension()
|
|
.is_some_and(|e| e.eq_ignore_ascii_case("dwt"))
|
|
{
|
|
scratch = Some(target.clone());
|
|
target = target.with_file_name(format!(
|
|
".{}.tmp.dwg",
|
|
target.file_name().unwrap().to_string_lossy()
|
|
));
|
|
}
|
|
crate::io::save(&extracted, &target).map_err(|e| failure("save_failed", e))?;
|
|
if let Some(final_path) = scratch {
|
|
std::fs::rename(&target, &final_path)
|
|
.map_err(|e| failure("save_failed", format!("rename to .dwt failed: {e}")))?;
|
|
}
|
|
self.set_control_result(json!({
|
|
"path": path,
|
|
"entities": entities,
|
|
"normalized": normalized,
|
|
}));
|
|
Ok(Task::none())
|
|
}
|
|
|
|
/// `plot` — render the current drawing to a PDF with every choice taken
|
|
/// from the request instead of the Plot dialog. Reuses the GUI pipeline
|
|
/// (area jobs over `plot_scene_content`, saved page setups for layouts);
|
|
/// dialog/layout/camera state is snapshotted and restored around the
|
|
/// build, exactly like the print-all path. Explicitly supplied request
|
|
/// fields win over a layout's stored page setup; unspecified fields keep
|
|
/// the stored setup (layouts) or the app's plot defaults (Model).
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
pub(super) fn control_plot(&mut self, req: &Value) -> Result<Task<Message>, Value> {
|
|
use crate::io::pdf_export::PdfPageInput;
|
|
|
|
let path = string(req, "path")?;
|
|
let lower = path.to_ascii_lowercase();
|
|
// SVG shares the plot page with PDF: the writer follows the extension.
|
|
let is_svg = lower.ends_with(".svg");
|
|
if !is_svg && !lower.ends_with(".pdf") {
|
|
return Err(failure("invalid_path", "plot writes .pdf or .svg files"));
|
|
}
|
|
self.stamp_plot_fields();
|
|
let i = self.active_tab;
|
|
let names = self.tabs[i].scene.layout_names();
|
|
let requested = req["layout"].as_str().unwrap_or("Model").to_owned();
|
|
let targets: Vec<String> = if requested.eq_ignore_ascii_case("all") {
|
|
names
|
|
} else {
|
|
vec![names
|
|
.iter()
|
|
.find(|name| name.eq_ignore_ascii_case(&requested))
|
|
.cloned()
|
|
.ok_or_else(|| {
|
|
failure(
|
|
"layout_missing",
|
|
format!("Layout '{requested}' does not exist"),
|
|
)
|
|
})?]
|
|
};
|
|
|
|
// Optional plot style table: a CTB path or a folder-discovered name.
|
|
let style = req["plot_style"].as_str().filter(|s| !s.is_empty());
|
|
let loaded_style = match style {
|
|
Some(name) => Some(
|
|
crate::io::plot_style::PlotStyleTable::load(std::path::Path::new(name))
|
|
.or_else(|_| crate::io::plot_style::PlotStyleTable::load_named(name))
|
|
.map_err(|e| failure("plot_style_missing", e))?,
|
|
),
|
|
None => None,
|
|
};
|
|
|
|
// Request → dialog vocabulary, before any stored setup is loaded so
|
|
// the explicit choices can be re-applied on top of it.
|
|
let mut request = self.plot_dialog.clone();
|
|
if let Some(paper) = req["paper"].as_str().filter(|p| !p.is_empty()) {
|
|
request.paper = crate::io::paper_catalog::resolve(paper)
|
|
.ok_or_else(|| failure("invalid_paper", format!("Unknown paper '{paper}'")))?
|
|
.canonical
|
|
.to_string();
|
|
request.paper_width_mm = 0.0;
|
|
request.paper_height_mm = 0.0;
|
|
}
|
|
if let Some(orientation) = req["orientation"].as_str() {
|
|
request.orientation = if orientation.eq_ignore_ascii_case("Portrait") {
|
|
"Portrait".into()
|
|
} else {
|
|
"Landscape".into()
|
|
};
|
|
}
|
|
let explicit_scale = req.get("scale").is_some();
|
|
if let Some(scale) = req["scale"].as_str().filter(|s| !s.is_empty()) {
|
|
request.scale = scale.to_owned();
|
|
request.fit_to_paper = false;
|
|
} else if req.get("fit").is_some() {
|
|
request.fit_to_paper = req["fit"].as_bool().unwrap_or(true);
|
|
}
|
|
let explicit_center = req.get("center").is_some();
|
|
request.center = req["center"].as_bool().unwrap_or(true);
|
|
if let Some(offset) = req["offset_x"].as_f64() {
|
|
request.offset_x = offset.to_string();
|
|
}
|
|
if let Some(offset) = req["offset_y"].as_f64() {
|
|
request.offset_y = offset.to_string();
|
|
}
|
|
if req.get("upside_down").is_some() {
|
|
request.upside_down = req["upside_down"].as_bool().unwrap_or(false);
|
|
}
|
|
for (key, field) in [
|
|
("transparency", 0),
|
|
("lineweights", 1),
|
|
("merge_lines", 2),
|
|
("stamp", 3),
|
|
] {
|
|
if let Some(flag) = req[key].as_bool() {
|
|
match field {
|
|
0 => request.transparency = flag,
|
|
1 => request.lineweights = flag,
|
|
2 => request.merge_lines = flag,
|
|
_ => request.stamp = flag,
|
|
}
|
|
}
|
|
}
|
|
if let Some(table) = &loaded_style {
|
|
self.active_plot_style = Some(table.clone());
|
|
request.style_name = table.name.clone();
|
|
request.apply_plot_styles = true;
|
|
request.style_missing = false;
|
|
}
|
|
let area_explicit = req.get("area").is_some();
|
|
let area = req["area"].as_str().unwrap_or("").to_ascii_lowercase();
|
|
match area.as_str() {
|
|
"" => request.area = "Extents".into(),
|
|
"extents" => request.area = "Extents".into(),
|
|
"display" => request.area = "Display".into(),
|
|
"limits" => request.area = "Limits".into(),
|
|
"layout" => request.area = "Layout".into(),
|
|
"window" => {
|
|
let values = req["window"]
|
|
.as_array()
|
|
.filter(|v| v.len() == 4)
|
|
.ok_or_else(|| {
|
|
failure(
|
|
"window_required",
|
|
"area \"window\" needs window:[x0,y0,x1,y1]",
|
|
)
|
|
})?;
|
|
let coord = |k: usize| {
|
|
values[k].as_f64().filter(|v| v.is_finite()).ok_or_else(|| {
|
|
failure("invalid_window", "window must be four finite numbers")
|
|
})
|
|
};
|
|
request.area = "Window".into();
|
|
request.window = Some((coord(0)?, coord(1)?, coord(2)?, coord(3)?));
|
|
}
|
|
other => return Err(failure("invalid_area", format!("Unknown area '{other}'"))),
|
|
}
|
|
|
|
// Snapshot everything the pipeline reads or mutates, like print-all.
|
|
let original_layout = self.tabs[i].scene.current_layout.clone();
|
|
let original_viewport = self.tabs[i].scene.active_viewport;
|
|
let original_psltscale = self.tabs[i]
|
|
.scene
|
|
.document
|
|
.header
|
|
.paper_space_linetype_scaling;
|
|
let original_plimcheck = self.tabs[i].scene.document.header.paper_space_limit_check;
|
|
let original_dialog = self.plot_dialog.clone();
|
|
let original_window = self.plot_window;
|
|
let original_style = self.active_plot_style.clone();
|
|
let original_camera = self.tabs[i].scene.camera.borrow().clone();
|
|
let original_camera_generation = self.tabs[i].scene.camera_generation;
|
|
|
|
let build = |app: &mut Self| -> Result<Vec<PdfPageInput>, String> {
|
|
let mut pages = Vec::with_capacity(targets.len());
|
|
for name in &targets {
|
|
let is_model = name.eq_ignore_ascii_case("Model");
|
|
{
|
|
let scene = &mut app.tabs[i].scene;
|
|
scene.current_layout = name.clone();
|
|
scene.active_viewport = None;
|
|
scene.load_current_layout_state();
|
|
}
|
|
let mut page_dialog = request.clone();
|
|
page_dialog.paper_space = !is_model;
|
|
if is_model {
|
|
if page_dialog.area == "Layout" {
|
|
page_dialog.area = "Extents".into();
|
|
}
|
|
} else if let Some(page_setup) = app.tabs[i].scene.plot_settings_for(name) {
|
|
// Stored page setup first, then the explicit request
|
|
// fields on top (a layout plot's behaviour).
|
|
app.plot_dialog.paper_space = true;
|
|
app.plot_dialog.window = None;
|
|
app.load_plotsettings_into_dialog(&page_setup);
|
|
page_dialog = app.plot_dialog.clone();
|
|
page_dialog.paper_space = true;
|
|
if area_explicit {
|
|
page_dialog.area = request.area.clone();
|
|
}
|
|
if request.paper_width_mm == 0.0 && !request.paper.is_empty() {
|
|
page_dialog.paper = request.paper.clone();
|
|
page_dialog.paper_width_mm = 0.0;
|
|
page_dialog.paper_height_mm = 0.0;
|
|
}
|
|
if req.get("orientation").is_some() {
|
|
page_dialog.orientation = request.orientation.clone();
|
|
}
|
|
if explicit_scale {
|
|
page_dialog.scale = request.scale.clone();
|
|
page_dialog.fit_to_paper = request.fit_to_paper;
|
|
}
|
|
if explicit_center {
|
|
page_dialog.center = request.center;
|
|
}
|
|
if req.get("offset_x").is_some() {
|
|
page_dialog.offset_x = request.offset_x.clone();
|
|
}
|
|
if req.get("offset_y").is_some() {
|
|
page_dialog.offset_y = request.offset_y.clone();
|
|
}
|
|
if req.get("upside_down").is_some() {
|
|
page_dialog.upside_down = request.upside_down;
|
|
}
|
|
// The requested table wins over the layout's own, which
|
|
// the stored page setup just loaded (or failed to find).
|
|
if let Some(table) = &loaded_style {
|
|
app.active_plot_style = Some(table.clone());
|
|
page_dialog.style_name = table.name.clone();
|
|
page_dialog.apply_plot_styles = true;
|
|
page_dialog.style_missing = false;
|
|
page_dialog.style_error = None;
|
|
}
|
|
}
|
|
app.plot_dialog = page_dialog;
|
|
if app.plot_dialog.area == "Display" {
|
|
app.tabs[i].scene.restore_saved_camera();
|
|
}
|
|
if app.plot_dialog.style_missing && app.plot_dialog.apply_plot_styles {
|
|
return Err(format!(
|
|
"Plot style table '{}' is not loaded.",
|
|
app.plot_dialog.style_name
|
|
));
|
|
}
|
|
let page = match app.plot_dialog.area.as_str() {
|
|
"Layout" => Some(app.layout_plot_page_for("Layout")),
|
|
"Display" => app.display_plot_job(),
|
|
"Extents" => app.extents_plot_job(),
|
|
"Limits" => app.limits_plot_job(),
|
|
"Window" => {
|
|
if let Some((x0, y0, x1, y1)) = app.plot_dialog.window {
|
|
app.plot_window = Some((x0, y0, x1, y1));
|
|
}
|
|
app.window_plot_job()
|
|
}
|
|
_ => None,
|
|
};
|
|
let page = page.ok_or_else(|| format!("Layout '{name}' plot area is empty."))?;
|
|
pages.push(page);
|
|
}
|
|
Ok(pages)
|
|
};
|
|
let built = build(self);
|
|
self.tabs[i].scene.current_layout = original_layout;
|
|
self.tabs[i].scene.active_viewport = original_viewport;
|
|
self.tabs[i]
|
|
.scene
|
|
.document
|
|
.header
|
|
.paper_space_linetype_scaling = original_psltscale;
|
|
self.tabs[i].scene.document.header.paper_space_limit_check = original_plimcheck;
|
|
self.plot_dialog = original_dialog;
|
|
self.plot_window = original_window;
|
|
self.active_plot_style = original_style;
|
|
*self.tabs[i].scene.camera.borrow_mut() = original_camera;
|
|
self.tabs[i].scene.camera_generation = original_camera_generation;
|
|
let pages = built.map_err(|e| failure("plot_failed", e))?;
|
|
if req["per_page"].as_bool().unwrap_or(false) {
|
|
// One file per layout: <stem>-<Layout>.pdf/.svg beside the requested
|
|
// path (sheet-by-sheet delivery).
|
|
let stem = path
|
|
.strip_suffix(".pdf")
|
|
.or_else(|| path.strip_suffix(".PDF"))
|
|
.or_else(|| path.strip_suffix(".svg"))
|
|
.or_else(|| path.strip_suffix(".SVG"))
|
|
.unwrap_or(path)
|
|
.to_owned();
|
|
let ext = if is_svg { "svg" } else { "pdf" };
|
|
let mut files = Vec::with_capacity(pages.len());
|
|
for (name, page) in targets.iter().zip(&pages) {
|
|
let safe: String = name
|
|
.chars()
|
|
.map(|c| {
|
|
if c.is_alphanumeric() || c == '-' || c == '_' {
|
|
c
|
|
} else {
|
|
'_'
|
|
}
|
|
})
|
|
.collect();
|
|
let file = format!("{stem}-{safe}.{ext}");
|
|
let result = if is_svg {
|
|
crate::io::svg_export::export_svg_pages(
|
|
std::slice::from_ref(page),
|
|
std::path::Path::new(&file),
|
|
loaded_style.as_ref(),
|
|
)
|
|
} else {
|
|
crate::io::pdf_export::export_pdf_pages(
|
|
std::slice::from_ref(page),
|
|
std::path::Path::new(&file),
|
|
loaded_style.as_ref(),
|
|
)
|
|
};
|
|
result.map_err(|e| failure("plot_failed", e))?;
|
|
files.push(json!({"layout": name, "path": file}));
|
|
}
|
|
self.set_control_result(json!({ "files": files, "pages": pages.len() }));
|
|
return Ok(Task::none());
|
|
}
|
|
// Pages may drop the style when a stored page setup overwrites the
|
|
// dialog's style fields, so the explicit request style rides along as
|
|
// the export-level fallback.
|
|
let result = if is_svg {
|
|
crate::io::svg_export::export_svg_pages(
|
|
&pages,
|
|
std::path::Path::new(path),
|
|
loaded_style.as_ref(),
|
|
)
|
|
} else {
|
|
crate::io::pdf_export::export_pdf_pages(
|
|
&pages,
|
|
std::path::Path::new(path),
|
|
loaded_style.as_ref(),
|
|
)
|
|
};
|
|
result.map_err(|e| failure("plot_failed", e))?;
|
|
self.set_control_result(json!({
|
|
"path": path,
|
|
"pages": pages.len(),
|
|
"page_sizes": pages
|
|
.iter()
|
|
.map(|page| json!([page.paper_w, page.paper_h]))
|
|
.collect::<Vec<_>>(),
|
|
}));
|
|
Ok(Task::none())
|
|
}
|
|
|
|
pub(super) fn control_properties(&mut self) -> Value {
|
|
self.refresh_properties();
|
|
json!({"ok":true,"sections":self.tabs[self.active_tab].properties.sections.iter().map(|s|json!({"title":s.title,"properties":s.props.iter().map(property_json).collect::<Vec<_>>()})).collect::<Vec<_>>()})
|
|
}
|
|
/// The grips of the current selection, as the viewport shows them.
|
|
pub(super) fn control_grips(&self) -> Value {
|
|
let tab = &self.tabs[self.active_tab];
|
|
let grips = tab
|
|
.selected_grip_handles
|
|
.iter()
|
|
.zip(&tab.selected_grips)
|
|
.map(|(owner, grip)| {
|
|
json!({
|
|
"handle": format!("{:X}", owner.value()),
|
|
"grip_id": grip.id,
|
|
"point": [grip.world.x, grip.world.y, grip.world.z],
|
|
"shape": format!("{:?}", grip.shape),
|
|
"moves_entity": grip.is_midpoint,
|
|
"axis": grip.axis.map(|a| [a.x, a.y, a.z]),
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
json!({"ok": true, "grips": grips})
|
|
}
|
|
|
|
/// Drag one grip of an entity to a world point through the viewport's
|
|
/// own grip gesture: select the entity, engage the grip, move, place.
|
|
/// Object snaps, grid, ortho, polar and tracking are off for the move so
|
|
/// the grip lands on `point`.
|
|
pub(super) fn control_grip_drag(&mut self, req: &Value) -> Result<Task<Message>, Value> {
|
|
let i = self.active_tab;
|
|
let target = handle(req)?;
|
|
let grip_id = req["grip_id"]
|
|
.as_u64()
|
|
.ok_or_else(|| failure("invalid_request", "Missing grip_id"))?
|
|
as usize;
|
|
let to = point(req)?;
|
|
self.tabs[i].scene.deselect_all();
|
|
self.tabs[i].scene.select_entity(target, false);
|
|
self.refresh_properties();
|
|
let (world, moves_entity) = self.tabs[i]
|
|
.selected_grip_handles
|
|
.iter()
|
|
.zip(&self.tabs[i].selected_grips)
|
|
.find(|(owner, grip)| **owner == target && grip.id == grip_id)
|
|
.map(|(_, grip)| (grip.world, grip.is_midpoint))
|
|
.ok_or_else(|| failure("unknown_grip", "Read grips for the entity's grip ids"))?;
|
|
let edit = self.grip_edit_for_hit(i, target, grip_id, moves_entity, world);
|
|
self.tabs[i].active_grip = Some(edit);
|
|
|
|
let (vw, vh) = self.tabs[i].scene.selection.borrow().view.vp_size;
|
|
let tile = self.tabs[i].scene.active_model_tile_bounds(vw, vh);
|
|
let local = iced::Rectangle {
|
|
width: tile.width,
|
|
height: tile.height,
|
|
..iced::Rectangle::default()
|
|
};
|
|
let Some(screen) = self.tabs[i].scene.camera.borrow().project(to, local) else {
|
|
self.cancel_active_grip_edit();
|
|
return Err(failure("invalid_point", "The point is not in view"));
|
|
};
|
|
let saved = (
|
|
self.snapper.snap_enabled,
|
|
self.snapper.snap3d_enabled,
|
|
self.snapper.grid_snap_on,
|
|
self.snapper.otrack_enabled,
|
|
self.ortho_mode,
|
|
self.polar_mode,
|
|
);
|
|
self.snapper.snap_enabled = false;
|
|
self.snapper.snap3d_enabled = false;
|
|
self.snapper.grid_snap_on = false;
|
|
self.snapper.otrack_enabled = false;
|
|
self.ortho_mode = false;
|
|
self.polar_mode = false;
|
|
let _ = self.on_viewport_move(iced::Point::new(screen.x + tile.x, screen.y + tile.y));
|
|
(
|
|
self.snapper.snap_enabled,
|
|
self.snapper.snap3d_enabled,
|
|
self.snapper.grid_snap_on,
|
|
self.snapper.otrack_enabled,
|
|
self.ortho_mode,
|
|
self.polar_mode,
|
|
) = saved;
|
|
Ok(self.commit_active_grip_edit())
|
|
}
|
|
|
|
/// Click the viewport where `point` (world) shows, as the left button
|
|
/// would, holding Ctrl and/or Shift when `modifiers` names them.
|
|
/// Where a world point shows on the drawing canvas, through the camera
|
|
/// the cursor edits with: an entered viewport's, else the active tile's.
|
|
fn control_world_to_canvas(&self, at: glam::DVec3) -> Result<iced::Point, Value> {
|
|
let scene = &self.tabs[self.active_tab].scene;
|
|
let (vw, vh) = scene.selection.borrow().view.vp_size;
|
|
let (camera, bounds) = scene.viewport_edit_frame((vw, vh)).unwrap_or_else(|| {
|
|
(
|
|
scene.camera.borrow().clone(),
|
|
scene.active_model_tile_bounds(vw, vh),
|
|
)
|
|
});
|
|
let local = iced::Rectangle {
|
|
width: bounds.width,
|
|
height: bounds.height,
|
|
..iced::Rectangle::default()
|
|
};
|
|
camera
|
|
.project(at, local)
|
|
.filter(|p| p.x.is_finite() && p.y.is_finite())
|
|
.map(|p| iced::Point::new(p.x + bounds.x, p.y + bounds.y))
|
|
.ok_or_else(|| failure("invalid_point", "The point is not in view"))
|
|
}
|
|
|
|
pub(super) fn control_click(&mut self, req: &Value) -> Result<Task<Message>, Value> {
|
|
let at = point(req)?;
|
|
let modifiers = req["modifiers"].as_str().unwrap_or("").to_ascii_lowercase();
|
|
let cursor = self.control_world_to_canvas(at)?;
|
|
let saved = (self.ctrl_down, self.shift_down);
|
|
self.ctrl_down = modifiers.contains("ctrl");
|
|
self.shift_down = modifiers.contains("shift");
|
|
let tasks = [
|
|
self.update(Message::ViewportMove(cursor)),
|
|
self.update(Message::ViewportLeftPress),
|
|
self.update(Message::ViewportLeftRelease),
|
|
];
|
|
(self.ctrl_down, self.shift_down) = saved;
|
|
Ok(Task::batch(tasks))
|
|
}
|
|
|
|
/// Replay pointer events on the drawing canvas in order, as the mouse
|
|
/// sends them: moves (canvas pixels, or a world point projected through
|
|
/// the editing camera), button presses and releases, and wheel steps.
|
|
/// Each step reports where the cursor landed in the world, so a drag
|
|
/// shows the points the interaction actually received.
|
|
pub(super) fn control_pointer(&mut self, req: &Value) -> Result<Task<Message>, Value> {
|
|
let i = self.active_tab;
|
|
let events = req["events"]
|
|
.as_array()
|
|
.filter(|events| !events.is_empty())
|
|
.ok_or_else(|| failure("invalid_request", "Supply a non-empty events array"))?;
|
|
let modifiers = req["modifiers"].as_str().unwrap_or("").to_ascii_lowercase();
|
|
let coords = |value: &Value, n: usize| -> Option<Vec<f64>> {
|
|
let values: Vec<f64> = value.as_array()?.iter().filter_map(Value::as_f64).collect();
|
|
(values.len() == n && values.iter().all(|v| v.is_finite())).then_some(values)
|
|
};
|
|
let saved = (self.ctrl_down, self.shift_down);
|
|
self.ctrl_down = modifiers.contains("ctrl");
|
|
self.shift_down = modifiers.contains("shift");
|
|
let mut tasks = Vec::new();
|
|
let mut steps = Vec::new();
|
|
let mut outcome = Ok(());
|
|
for event in events {
|
|
let message = if let Some(xy) = event.get("move").and_then(|v| coords(v, 2)) {
|
|
Message::ViewportMove(iced::Point::new(xy[0] as f32, xy[1] as f32))
|
|
} else if let Some(at) = event.get("move_world").and_then(|v| coords(v, 3)) {
|
|
match self.control_world_to_canvas(glam::DVec3::new(at[0], at[1], at[2])) {
|
|
Ok(cursor) => Message::ViewportMove(cursor),
|
|
Err(error) => {
|
|
outcome = Err(error);
|
|
break;
|
|
}
|
|
}
|
|
} else if let Some(button) = event.get("press").and_then(Value::as_str) {
|
|
match button {
|
|
"left" => Message::ViewportLeftPress,
|
|
"right" => Message::ViewportRightPress,
|
|
"middle" => Message::ViewportMiddlePress,
|
|
_ => {
|
|
outcome = Err(failure("invalid_request", "press is left, right or middle"));
|
|
break;
|
|
}
|
|
}
|
|
} else if let Some(button) = event.get("release").and_then(Value::as_str) {
|
|
match button {
|
|
"left" => Message::ViewportLeftRelease,
|
|
"right" => Message::ViewportRightRelease,
|
|
"middle" => Message::ViewportMiddleRelease,
|
|
_ => {
|
|
outcome = Err(failure(
|
|
"invalid_request",
|
|
"release is left, right or middle",
|
|
));
|
|
break;
|
|
}
|
|
}
|
|
} else if let Some(lines) = event.get("wheel").and_then(Value::as_f64) {
|
|
Message::ViewportScroll(iced::mouse::ScrollDelta::Lines {
|
|
x: 0.0,
|
|
y: lines as f32,
|
|
})
|
|
} else {
|
|
outcome = Err(failure(
|
|
"invalid_request",
|
|
"Each event is move, move_world, press, release or wheel",
|
|
));
|
|
break;
|
|
};
|
|
tasks.push(self.update(message));
|
|
let tab = &self.tabs[i];
|
|
let world = tab.last_cursor_world;
|
|
steps.push(json!({
|
|
"screen":[tab.last_cursor_screen.x, tab.last_cursor_screen.y],
|
|
"world":[world.x, world.y, world.z],
|
|
"dragging_grip":tab.active_grip.is_some(),
|
|
}));
|
|
}
|
|
(self.ctrl_down, self.shift_down) = saved;
|
|
outcome?;
|
|
self.set_control_result(json!({"steps":steps}));
|
|
Ok(Task::batch(tasks))
|
|
}
|
|
|
|
pub(super) fn control_set_property(&mut self, req: &Value) -> Result<Task<Message>, Value> {
|
|
self.refresh_properties();
|
|
let field = string(req, "field")?;
|
|
let p = self.tabs[self.active_tab]
|
|
.properties
|
|
.sections
|
|
.iter()
|
|
.flat_map(|s| &s.props)
|
|
.find(|p| p.field == field)
|
|
.cloned()
|
|
.ok_or_else(|| {
|
|
failure(
|
|
"unknown_property",
|
|
"Read properties for the current selection",
|
|
)
|
|
})?;
|
|
self.set_property_value(p, &req["value"])
|
|
}
|
|
|
|
/// Write `raw` (protocol JSON, see [`property_json`]) into one Properties
|
|
/// row through the panel's own messages, on the current property targets.
|
|
pub(crate) fn set_property_value(
|
|
&mut self,
|
|
p: crate::scene::model::object::Property,
|
|
raw: &Value,
|
|
) -> Result<Task<Message>, Value> {
|
|
let value = raw
|
|
.as_str()
|
|
.map(str::to_owned)
|
|
.unwrap_or_else(|| raw.to_string());
|
|
Ok(match p.value {
|
|
PropValue::EditText(_)
|
|
| PropValue::PlainText(_)
|
|
| PropValue::Hyperlink(_)
|
|
| PropValue::EditChoice { .. } => {
|
|
let input = self.update(Message::PropGeomInput {
|
|
field: p.field,
|
|
value,
|
|
});
|
|
let commit = self.update(Message::PropGeomCommit(p.field));
|
|
Task::batch([input, commit])
|
|
}
|
|
PropValue::Choice { options, .. } => {
|
|
if !options.contains(&value) {
|
|
return Err(failure(
|
|
"invalid_choice",
|
|
"Value is not an available option",
|
|
));
|
|
}
|
|
self.update(Message::PropGeomChoiceChanged {
|
|
field: p.field,
|
|
value,
|
|
})
|
|
}
|
|
PropValue::LayerChoice(_) => self.update(Message::PropLayerChanged(value)),
|
|
PropValue::LinetypeChoice(_) => self.update(Message::PropLinetypeChanged(value)),
|
|
PropValue::HatchPatternChoice(_) => {
|
|
self.update(Message::PropHatchPatternChanged(value))
|
|
}
|
|
PropValue::BoolToggle { field, value: old } => {
|
|
let v = raw
|
|
.as_bool()
|
|
.ok_or_else(|| failure("invalid_value", "Expected boolean"))?;
|
|
if old != v {
|
|
self.update(Message::PropBoolToggle(field))
|
|
} else {
|
|
Task::none()
|
|
}
|
|
}
|
|
PropValue::AttrText { tag, .. } => {
|
|
let input = self.update(Message::PropAttrInput {
|
|
tag: tag.clone(),
|
|
value,
|
|
});
|
|
let commit = self.update(Message::PropAttrCommit(tag));
|
|
Task::batch([input, commit])
|
|
}
|
|
PropValue::ColorChoice(_)
|
|
| PropValue::NamedColorChoice { .. }
|
|
| PropValue::ColorVaries => {
|
|
let color = control_color(raw)?;
|
|
if p.field == "background_color" {
|
|
self.update(Message::PropBgColorChanged(color))
|
|
} else if matches!(
|
|
p.field,
|
|
"gradient_color_1"
|
|
| "gradient_color_2"
|
|
| "dim_line_color"
|
|
| "dim_ext_line_color"
|
|
| "dim_text_color"
|
|
| "dim_text_fill_color"
|
|
| "line_color"
|
|
| "text_color"
|
|
| "block_content_color"
|
|
| "background_fill_color"
|
|
| "indicator_fill_color"
|
|
) {
|
|
self.update(Message::PropColorFieldChanged {
|
|
field: p.field.into(),
|
|
color,
|
|
})
|
|
} else {
|
|
self.update(Message::PropColorChanged(color))
|
|
}
|
|
}
|
|
PropValue::LwChoice(_) | PropValue::LwVaries => {
|
|
self.update(Message::PropLwChanged(control_lineweight(raw)?))
|
|
}
|
|
PropValue::FieldLwChoice { field, .. } | PropValue::FieldLwVaries { field } => self
|
|
.update(Message::PropFieldLwChanged {
|
|
field,
|
|
value: control_lineweight(raw)?,
|
|
}),
|
|
PropValue::ReadOnly(_) | PropValue::ReadOnlyWithTooltip { .. } => {
|
|
return Err(failure("readonly_property", "Property cannot be edited"))
|
|
}
|
|
_ => {
|
|
return Err(failure(
|
|
"unsupported_property",
|
|
"Use the corresponding command for this property type",
|
|
))
|
|
}
|
|
})
|
|
}
|
|
pub(super) fn control_ui_action(&mut self, req: &Value) -> Result<Task<Message>, Value> {
|
|
let name = string(req, "name")?;
|
|
let msg = match name {
|
|
"close_modal" => Message::CloseModal,
|
|
// Show a layout (or "Model"), as clicking its tab does.
|
|
"layout_switch" => Message::LayoutSwitch(string(req, "value")?.into()),
|
|
// The "changed outside" modal's suggested actions (D6): reload
|
|
// the disk copy, keep the in-memory one and overwrite, save as,
|
|
// or dismiss. Native only — the web build has no such dialog.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
"external_change_reload" => Message::ExternalChangeReload,
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
"external_change_overwrite" => Message::ExternalChangeOverwrite,
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
"external_change_save_as" => Message::ExternalChangeSaveAs,
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
"external_change_cancel" => Message::ExternalChangeCancel,
|
|
// The open PDF dialog's OK button.
|
|
// Underlay Layers: switch a layer of the shown underlay.
|
|
"pdf_layer_toggle" => {
|
|
Message::PdfDialog(crate::ui::window::pdf_dialogs::PdfDialogMsg::LayersToggle(
|
|
string(req, "value")?.into(),
|
|
))
|
|
}
|
|
// Point Cloud Color Map: one edit, "tab=elevation", "count=3",
|
|
// "even", "reverse", "scheme=Earth", "gradient=0", "max=90",
|
|
// "min=10", "interval=0.1", "extents=0", "range=2", "current=0".
|
|
"pc_colormap" => {
|
|
use crate::ui::window::pdf_dialogs::{OutOfRange, PdfDialogMsg as M};
|
|
let value = string(req, "value")?;
|
|
let (key, arg) = value.split_once('=').unwrap_or((value, ""));
|
|
let on = arg != "0";
|
|
Message::PdfDialog(match key {
|
|
"tab" => M::MapTab(arg == "elevation"),
|
|
"scheme" => M::MapScheme(arg.into()),
|
|
"count" => {
|
|
M::MapCount(arg.parse().map_err(|_| failure("invalid_value", "count"))?)
|
|
}
|
|
"even" => M::MapEven,
|
|
"reverse" => M::MapReverse,
|
|
"gradient" => M::MapGradient(on),
|
|
"max" => M::MapMax(arg.into()),
|
|
"min" => M::MapMin(arg.into()),
|
|
"interval" => M::MapInterval(arg.into()),
|
|
"extents" => M::MapExtents(on),
|
|
"range" => M::MapOutOfRange(OutOfRange(arg.parse().unwrap_or(1))),
|
|
"current" => M::MapCurrent(on),
|
|
"new" => M::MapNew,
|
|
"rename" => M::MapRename,
|
|
"name" => M::MapNameInput(arg.into()),
|
|
"name_ok" => M::MapNameOk,
|
|
"delete" => M::MapDelete,
|
|
"apply" => M::MapApply,
|
|
_ => return Err(failure("invalid_value", "Unknown color map edit")),
|
|
})
|
|
}
|
|
// Section extraction dialog: "min=0.01", "connect=0.02", "angle=5",
|
|
// "points=18000", "lines=1", "perimeter=1", "preview=0", "width=0".
|
|
"pc_section" => {
|
|
use crate::ui::window::pdf_dialogs::PdfDialogMsg as M;
|
|
let value = string(req, "value")?;
|
|
let (key, arg) = value.split_once('=').unwrap_or((value, ""));
|
|
Message::PdfDialog(match key {
|
|
"min" => M::SecMinLength(arg.into()),
|
|
"connect" => M::SecConnect(arg.into()),
|
|
"angle" => M::SecAngle(arg.into()),
|
|
"points" => M::SecMaxPoints(arg.into()),
|
|
"width" => M::SecWidth(arg.into()),
|
|
"lines" => M::SecPolylines(arg == "0"),
|
|
"perimeter" => M::SecPerimeter(arg != "0"),
|
|
"preview" => M::SecPreview(arg != "0"),
|
|
_ => return Err(failure("invalid_value", "Unknown section setting")),
|
|
})
|
|
}
|
|
// Point Cloud Manager: flip a row's switch, "<handle>:<row>" with
|
|
// row cloud, unassigned, scans or scan:<name>.
|
|
"pc_manager_toggle" => {
|
|
use crate::ui::window::pc_manager::{PcManagerMsg, Row};
|
|
let value = string(req, "value")?;
|
|
let parsed = value.split_once(':').and_then(|(handle, row)| {
|
|
Some((
|
|
codec::Handle::new(u64::from_str_radix(handle, 16).ok()?),
|
|
Row::parse(row)?,
|
|
))
|
|
});
|
|
let Some((handle, row)) = parsed else {
|
|
return Err(failure("bad_value", "Expected <handle>:<row>"));
|
|
};
|
|
Message::PcManager(PcManagerMsg::Toggle(handle, row))
|
|
}
|
|
// Count palette and count mode: "search=<text>", "sort=name|count",
|
|
// "open=NAME", "child=NAME:<n>" (an expanded row), "menu=<review|field|
|
|
// layer|scale|mirror>:NAME", "create_table", "check=NAME:1",
|
|
// "all=1", "table_cancel", "table_insert", "back", "match=<0|1|2>:1",
|
|
// "details", "errors", "error=<n>", "area", "prev", "next", "select",
|
|
// "field", "close", "invalid_undo", "invalid_continue", "invalid_always=1".
|
|
"count_palette" => {
|
|
use crate::ui::window::count_palette::{CountMsg as M, RowAction};
|
|
let value = string(req, "value")?;
|
|
let (key, arg) = value.split_once('=').unwrap_or((value, ""));
|
|
let flag = |s: &str| s == "1" || s.eq_ignore_ascii_case("true");
|
|
let bad = || failure("invalid_value", "Unknown count palette edit");
|
|
Message::Count(match key {
|
|
"search" => M::Search(arg.into()),
|
|
"sort" => M::Sort(arg == "count"),
|
|
"open" => M::Open(arg.into(), codec::count::CountKey::default()),
|
|
"child" => {
|
|
let (name, n) = arg.rsplit_once(':').ok_or_else(bad)?;
|
|
let n: usize = n.parse().map_err(|_| bad())?;
|
|
let [l, s, m] = self.count_palette.expansion(name).ok_or_else(bad)?;
|
|
let i = self.active_tab;
|
|
let area = self.tabs[i].count.as_ref().and_then(|c| c.area.clone());
|
|
let instances = codec::count::block_instances(
|
|
&self.tabs[i].scene.document,
|
|
area.as_deref(),
|
|
);
|
|
let (k, _) = codec::count::expanded_counts(&instances, name, l, s, m)
|
|
.into_iter()
|
|
.nth(n)
|
|
.ok_or_else(bad)?;
|
|
M::Open(name.into(), k)
|
|
}
|
|
"menu" => {
|
|
let (action, name) = arg.split_once(':').ok_or_else(bad)?;
|
|
let action = match action {
|
|
"review" => RowAction::Review,
|
|
"field" => RowAction::Field,
|
|
"layer" => RowAction::Expand(0),
|
|
"scale" => RowAction::Expand(1),
|
|
"mirror" => RowAction::Expand(2),
|
|
_ => return Err(bad()),
|
|
};
|
|
M::Menu(name.into(), codec::count::CountKey::default(), action)
|
|
}
|
|
"create_table" => M::CreateTable,
|
|
"check" => {
|
|
let (name, on) = arg.rsplit_once(':').unwrap_or((arg, "1"));
|
|
M::TableCheck(name.into(), flag(on))
|
|
}
|
|
"all" => M::TableAll(flag(arg)),
|
|
"table_cancel" => M::TableCancel,
|
|
"table_insert" => M::TableInsert,
|
|
"back" => M::Back,
|
|
"match" => {
|
|
let (k, on) = arg.split_once(':').unwrap_or((arg, "1"));
|
|
M::Match(
|
|
k.parse::<usize>().ok().filter(|k| *k < 3).ok_or_else(bad)?,
|
|
flag(on),
|
|
)
|
|
}
|
|
"details" => M::ToggleDetails,
|
|
"errors" => M::ToggleErrors,
|
|
"error" => {
|
|
let i = self.active_tab;
|
|
let n: usize = arg.parse().map_err(|_| bad())?;
|
|
let mode = self.tabs[i].count.as_ref().ok_or_else(bad)?;
|
|
M::ShowError(
|
|
*mode
|
|
.result(&self.tabs[i].scene.document)
|
|
.errors
|
|
.get(n)
|
|
.ok_or_else(bad)?,
|
|
)
|
|
}
|
|
"area" => M::Area,
|
|
"prev" => M::Prev,
|
|
"next" => M::Next,
|
|
"select" => M::Select,
|
|
"field" => M::Field,
|
|
"close" => M::Close,
|
|
// The Invalid Area dialog.
|
|
"invalid_undo" => M::InvalidUndo,
|
|
"invalid_continue" => M::InvalidContinue,
|
|
"invalid_always" => M::InvalidAlways(arg == "1"),
|
|
_ => return Err(bad()),
|
|
})
|
|
}
|
|
// Point Cloud Manager tree: "search=<text>", "collapse", "expand",
|
|
// "toggle_node=<key>" or "select=<key>".
|
|
"pc_manager" => {
|
|
use crate::ui::window::pc_manager::PcManagerMsg as M;
|
|
let value = string(req, "value")?;
|
|
let (key, arg) = value.split_once('=').unwrap_or((value, ""));
|
|
Message::PcManager(match key {
|
|
"search" => M::Search(arg.into()),
|
|
"collapse" => M::CollapseAll,
|
|
"expand" => M::ExpandAll,
|
|
"toggle_node" => M::Expand(arg.into()),
|
|
"select" => M::Select(arg.into()),
|
|
_ => return Err(failure("invalid_value", "Unknown point cloud manager edit")),
|
|
})
|
|
}
|
|
// Blocks palette: "tab=recent", "search=D*", "view=2",
|
|
// "option=x:2", "options", "library=<dir>", "insert=<item>",
|
|
// "drop=<item>@x,y", "menu=<action>:<item>". An item is
|
|
// "current:NAME", "recent:N", "fav:N", "lib:N" or "file:<path>".
|
|
"blocks_palette" => {
|
|
use crate::ui::window::block_palette::{
|
|
BlockPaletteMsg as M, Item, MenuAction, OptionMsg, Tab, ViewMode,
|
|
};
|
|
let value = string(req, "value")?;
|
|
let (key, arg) = value.split_once('=').unwrap_or((value, ""));
|
|
let item = |app: &Self, spec: &str| -> Result<Item, Value> {
|
|
let (kind, rest) = spec.split_once(':').unwrap_or((spec, ""));
|
|
let index = || {
|
|
rest.parse::<usize>()
|
|
.map_err(|_| failure("invalid_value", "Bad item index"))
|
|
};
|
|
let missing = || failure("invalid_value", "No such palette item");
|
|
Ok(match kind {
|
|
"current" => Item::Current(rest.to_string()),
|
|
"recent" => Item::Ref(
|
|
app.block_palette
|
|
.recent
|
|
.get(index()?)
|
|
.cloned()
|
|
.ok_or_else(missing)?,
|
|
),
|
|
"fav" => Item::Ref(
|
|
app.block_palette
|
|
.favorites
|
|
.get(index()?)
|
|
.cloned()
|
|
.ok_or_else(missing)?,
|
|
),
|
|
"lib" => {
|
|
let e = app
|
|
.block_palette
|
|
.library_entries
|
|
.get(index()?)
|
|
.ok_or_else(missing)?;
|
|
if e.folder {
|
|
Item::Folder(e.path.clone())
|
|
} else {
|
|
Item::File(e.path.clone())
|
|
}
|
|
}
|
|
"file" => Item::File(std::path::PathBuf::from(rest)),
|
|
_ => return Err(missing()),
|
|
})
|
|
};
|
|
match key {
|
|
"tab" => Message::BlockPalette(M::Tab(match arg {
|
|
"recent" => Tab::Recent,
|
|
"favorites" => Tab::Favorites,
|
|
"libraries" => Tab::Libraries,
|
|
_ => Tab::Current,
|
|
})),
|
|
"search" => Message::BlockPalette(M::Search(arg.into())),
|
|
"view" => {
|
|
Message::BlockPalette(M::View(ViewMode::from_u8(arg.parse().unwrap_or(1))))
|
|
}
|
|
"options" => Message::BlockPalette(M::ToggleOptions),
|
|
"library" => Message::BlockPalette(M::LibraryPicked(Ok(arg.into()))),
|
|
"up" => Message::BlockPalette(M::LibraryUp),
|
|
"option" => {
|
|
let (name, v) = arg.split_once(':').unwrap_or((arg, "1"));
|
|
let on = v == "1";
|
|
Message::BlockPalette(M::Option(match name {
|
|
"insertion_point" => OptionMsg::InsertionPoint(on),
|
|
"scale" => OptionMsg::Scale(on),
|
|
"uniform" => OptionMsg::Uniform(on),
|
|
"rotation" => OptionMsg::Rotation(on),
|
|
"auto" => OptionMsg::AutoPlacement(on),
|
|
"repeat" => OptionMsg::Repeat(on),
|
|
"explode" => OptionMsg::Explode(on),
|
|
"x" => OptionMsg::X(v.into()),
|
|
"y" => OptionMsg::Y(v.into()),
|
|
"z" => OptionMsg::Z(v.into()),
|
|
"angle" => OptionMsg::Angle(v.into()),
|
|
_ => return Err(failure("invalid_value", "Unknown palette option")),
|
|
}))
|
|
}
|
|
"insert" => {
|
|
let it = item(self, arg)?;
|
|
self.block_palette.pressed = Some(it.clone());
|
|
Message::BlockPalette(M::Release(it))
|
|
}
|
|
"drop" => {
|
|
let (spec, at) = arg
|
|
.rsplit_once('@')
|
|
.ok_or_else(|| failure("invalid_value", "drop needs @x,y"))?;
|
|
let xy: Vec<f64> = at
|
|
.split(',')
|
|
.filter_map(|v| v.trim().parse().ok())
|
|
.collect();
|
|
if xy.len() < 2 {
|
|
return Err(failure("invalid_value", "drop needs @x,y"));
|
|
}
|
|
let it = item(self, spec)?;
|
|
let i = self.active_tab;
|
|
self.tabs[i].last_cursor_world =
|
|
glam::DVec3::new(xy[0], xy[1], xy.get(2).copied().unwrap_or(0.0));
|
|
self.block_palette.pressed = Some(it);
|
|
Message::ViewportLeftRelease
|
|
}
|
|
"menu" => {
|
|
let (action, spec) = arg
|
|
.split_once(':')
|
|
.ok_or_else(|| failure("invalid_value", "menu needs action:item"))?;
|
|
let action = match action {
|
|
"insert" => MenuAction::Insert,
|
|
"redefine" => MenuAction::Redefine,
|
|
"favorite" => MenuAction::Favorite,
|
|
"unfavorite" => MenuAction::Unfavorite,
|
|
"edit" => MenuAction::Edit,
|
|
"remove" => MenuAction::Remove,
|
|
_ => return Err(failure("invalid_value", "Unknown menu action")),
|
|
};
|
|
Message::BlockPalette(M::Menu(item(self, spec)?, action))
|
|
}
|
|
_ => return Err(failure("invalid_value", "Unknown blocks palette edit")),
|
|
}
|
|
}
|
|
// Attach dialog: choose pages by index ("0,2").
|
|
"pdf_page_select" => {
|
|
let pages: Vec<usize> = string(req, "value")?
|
|
.split(',')
|
|
.filter_map(|p| p.trim().parse().ok())
|
|
.collect();
|
|
let Some(state) = self.pdf_attach.as_mut() else {
|
|
return Err(failure("no_dialog", "The Attach dialog is not open"));
|
|
};
|
|
state.selected = pages;
|
|
return Ok(Task::none());
|
|
}
|
|
// Bring a ribbon tab forward by module id.
|
|
"ribbon_tab" => {
|
|
let id = string(req, "value")?;
|
|
if !self.ribbon.select_by_id(id) {
|
|
return Err(failure("no_tab", "No such ribbon tab"));
|
|
}
|
|
return Ok(Task::none());
|
|
}
|
|
// The open Block Definition dialog: `name=…`, `open_in_editor=0|1`,
|
|
// `base_on_screen=0|1`, `objects_on_screen=0|1`, `create_empty`,
|
|
// `select_objects`.
|
|
"block_def_dialog" => {
|
|
if self.block_definition.is_none() {
|
|
return Err(failure("no_dialog", "The Block Definition dialog is not open"));
|
|
}
|
|
let value = string(req, "value")?;
|
|
let (key, v) = value.split_once('=').unwrap_or((value, ""));
|
|
let on = v == "1";
|
|
match key {
|
|
"name" => Message::BlockDefNameSelect(v.to_string()),
|
|
"open_in_editor" => Message::BlockDefOpenInEditor(on),
|
|
"base_on_screen" => Message::BlockDefBaseOnScreen(on),
|
|
"objects_on_screen" => Message::BlockDefObjectsOnScreen(on),
|
|
"create_empty" => Message::BlockDefCreateEmpty,
|
|
"select_objects" => Message::BlockDefSelectObjects,
|
|
_ => return Err(failure("invalid_value", "Unknown block definition field")),
|
|
}
|
|
}
|
|
// One field of the open attribute definition dialog: `tag=…`,
|
|
// `prompt=…`, `default=…`, `justify=MC`, `style=…`, `height=…`,
|
|
// `rotation=…`, `width=…`, `on_screen=0|1`, `x=…` / `y=…` / `z=…`,
|
|
// `mode=<bit>:0|1`, `annotative=0|1`, `align_below=0|1`, `edit_value`, `dismiss`.
|
|
"attdef_dialog" => {
|
|
use crate::ui::window::attdef_dialog::{AttdefDialogMsg as M, JustifyChoice};
|
|
let value = string(req, "value")?;
|
|
let (key, v) = value.split_once('=').unwrap_or((value, ""));
|
|
let on = v == "1";
|
|
let editing = self.active_modal == Some(crate::app::ModalKind::AttDefEdit);
|
|
Message::AttdefDialog(match key {
|
|
"tag" if editing => M::EditTag(v.into()),
|
|
"prompt" if editing => M::EditPrompt(v.into()),
|
|
"default" if editing => M::EditDefault(v.into()),
|
|
"tag" => M::Tag(v.into()),
|
|
"prompt" => M::Prompt(v.into()),
|
|
"default" => M::Default(v.into()),
|
|
"style" => M::Style(v.into()),
|
|
"height" => M::Height(v.into()),
|
|
"rotation" => M::Rotation(v.into()),
|
|
"width" => M::Width(v.into()),
|
|
"on_screen" => M::OnScreen(on),
|
|
"annotative" => M::Annotative(on),
|
|
"align_below" => M::AlignBelow(on),
|
|
"edit_value" => M::EditValue,
|
|
"dismiss" => M::DismissError,
|
|
"insert_field" if editing => M::EditInsertField,
|
|
"insert_field" => M::InsertField,
|
|
"x" => M::Coord(0, v.into()),
|
|
"y" => M::Coord(1, v.into()),
|
|
"z" => M::Coord(2, v.into()),
|
|
"justify" => M::Justify(JustifyChoice(
|
|
crate::modules::draw::draw::attdef::Justify::from_keyword(v)
|
|
.ok_or_else(|| failure("invalid_value", "Unknown justification"))?,
|
|
)),
|
|
"mode" => {
|
|
let (bit, state) = v.split_once(':').unwrap_or((v, "1"));
|
|
let bit = bit
|
|
.parse::<u8>()
|
|
.map_err(|_| failure("invalid_value", "mode=<bit>:0|1"))?;
|
|
M::Mode(bit, state == "1")
|
|
}
|
|
_ => return Err(failure("invalid_value", "Unknown attdef dialog field")),
|
|
})
|
|
}
|
|
// The open Block Attribute Manager: `block=<name>`, `select=<row>`,
|
|
// `up`, `down`, `remove`, `edit`, `tab=attribute|text|properties`,
|
|
// `tag=…`, `prompt=…`, `default=…`, `mode=<invisible|verify|preset|multiline>:0|1`,
|
|
// `style=…`, `justify=MC`, `height=…`, `rotation=…`, `width=…`, `oblique=…`,
|
|
// `boundary=…`, `annotative=0|1`, `backwards=0|1`, `upside_down=0|1`,
|
|
// `layer=…`, `linetype=…`, `color=…`, `lineweight=…`, `column=<name>:0|1`,
|
|
// `more_columns`, `apply_existing=0|1`, `emphasize=0|1`, `live=0|1`,
|
|
// `pick`, `sync`, `apply`, `ok`, `cancel`.
|
|
"attman_dialog" => {
|
|
use crate::ui::window::attman_dialog::{AttManMsg as A, Column, EditTab};
|
|
let Some(state) = self.attman.as_ref() else {
|
|
return Err(failure("no_dialog", "The attribute manager is not open"));
|
|
};
|
|
let value = string(req, "value")?;
|
|
let (key, v) = value.split_once('=').unwrap_or((value, ""));
|
|
let on = v == "1";
|
|
Message::AttMan(match key {
|
|
"block" => A::Block(
|
|
state
|
|
.blocks
|
|
.iter()
|
|
.position(|b| b.name.eq_ignore_ascii_case(v))
|
|
.ok_or_else(|| failure("invalid_value", "No such attributed block"))?,
|
|
),
|
|
"select" => A::Select(
|
|
v.parse()
|
|
.ok()
|
|
.filter(|r: &usize| *r < state.rows.len())
|
|
.ok_or_else(|| failure("invalid_value", "No such row"))?,
|
|
),
|
|
"up" => A::MoveUp,
|
|
"down" => A::MoveDown,
|
|
"remove" => A::Remove,
|
|
"edit" => A::ToggleEdit,
|
|
"tab" => A::Tab(match v {
|
|
"text" => EditTab::Text,
|
|
"properties" => EditTab::Properties,
|
|
_ => EditTab::Attribute,
|
|
}),
|
|
"tag" => A::Tag(v.into()),
|
|
"prompt" => A::Prompt(v.into()),
|
|
"default" => A::Default(v.into()),
|
|
"mode" => {
|
|
let (mode, state) = v.split_once(':').unwrap_or((v, "1"));
|
|
let mode = match mode {
|
|
"invisible" => "invisible",
|
|
"verify" => "verify",
|
|
"preset" => "preset",
|
|
"multiline" => "multiline",
|
|
_ => return Err(failure("invalid_value", "Unknown mode")),
|
|
};
|
|
A::Mode(mode, state == "1")
|
|
}
|
|
"style" => A::Style(v.into()),
|
|
"justify" => A::Justify(
|
|
crate::modules::draw::draw::attdef::Justify::from_keyword(v)
|
|
.ok_or_else(|| failure("invalid_value", "Unknown justification"))?,
|
|
),
|
|
"height" => A::Height(v.into()),
|
|
"rotation" => A::Rotation(v.into()),
|
|
"width" => A::Width(v.into()),
|
|
"oblique" => A::Oblique(v.into()),
|
|
"boundary" => A::Boundary(v.into()),
|
|
"annotative" => A::Annotative(on),
|
|
"backwards" => A::Backwards(on),
|
|
"upside_down" => A::UpsideDown(on),
|
|
"layer" => A::Layer(v.into()),
|
|
"linetype" => A::Linetype(v.into()),
|
|
"color" => A::Color(v.into()),
|
|
"lineweight" => A::Lineweight(v.into()),
|
|
"column" => {
|
|
let (name, state) = v.split_once(':').unwrap_or((v, "1"));
|
|
A::Column(
|
|
Column::from_name(name)
|
|
.ok_or_else(|| failure("invalid_value", "Unknown column"))?,
|
|
state == "1",
|
|
)
|
|
}
|
|
"more_columns" => A::MoreColumns,
|
|
"apply_existing" => A::ApplyExisting(on),
|
|
"emphasize" => A::Emphasize(on),
|
|
"live" => A::Live(on),
|
|
"pick" => A::PickBlock,
|
|
"sync" => A::Sync,
|
|
"apply" => A::Apply,
|
|
"ok" => A::Ok,
|
|
"cancel" => A::Cancel,
|
|
_ => return Err(failure("invalid_value", "Unknown attribute manager field")),
|
|
})
|
|
}
|
|
// One field of the open Field dialog: `category=<index>`, `name=Date`,
|
|
// `date=yyyy-MM-dd`, `case=0..4`, `file=1|2|3`, `ext=0|1`, `size=0..2`,
|
|
// `sysvar=dimscale`, `diesel=…`, `named_type=<index>`, `named=<index>`,
|
|
// `prop=Area`, `select_object`, `formula=(1+2)*3`, `formula_format=<index>`,
|
|
// `precision=<index>`, `link_text=…`, `link_url=…`, `plot_scale=<index>`.
|
|
"field_dialog" => {
|
|
use crate::ui::window::field_dialog::{self as fd, FieldDialogMsg as F};
|
|
let value = string(req, "value")?;
|
|
let (key, v) = value.split_once('=').unwrap_or((value, ""));
|
|
// The dialog indexes its lists with these, so one out of range
|
|
// would panic while it draws.
|
|
let index = |len: usize| {
|
|
v.parse::<usize>()
|
|
.ok()
|
|
.filter(|i| *i < len)
|
|
.ok_or_else(|| failure("invalid_value", "index out of range"))
|
|
};
|
|
let named_len = self
|
|
.field_dialog
|
|
.as_ref()
|
|
.map_or(0, |d| d.named_names.len());
|
|
let known = |list: &[&'static str]| {
|
|
list.iter()
|
|
.copied()
|
|
.find(|n| n.eq_ignore_ascii_case(v))
|
|
.ok_or_else(|| failure("invalid_value", "unknown name"))
|
|
};
|
|
Message::FieldDialog(match key {
|
|
"category" => F::Category(index(fd::CATEGORIES.len())?),
|
|
"name" => F::Name(known(&fd::fields_of(0))?),
|
|
"date" => F::DateFormat(v.into()),
|
|
"case" => F::TextCase(index(fd::TEXT_CASES.len())?),
|
|
"file" => F::FileParts(
|
|
v.parse::<u8>()
|
|
.map_err(|_| failure("invalid_value", "1|2|3"))?,
|
|
),
|
|
"ext" => F::FileExtension(v == "1"),
|
|
"size" => F::SizeUnit(index(fd::SIZE_UNITS.len())?),
|
|
"sysvar" => F::SysVar(v.into()),
|
|
"diesel" => F::Diesel(v.into()),
|
|
"named_type" => F::NamedType(index(fd::NAMED_TYPES.len())?),
|
|
"named" => F::Named(index(named_len)?),
|
|
"prop" => F::ObjectProp(known(&[
|
|
"Area",
|
|
"Center",
|
|
"Circumference",
|
|
"Diameter",
|
|
"EndPoint",
|
|
"Length",
|
|
"Radius",
|
|
"StartPoint",
|
|
])?),
|
|
"select_object" => F::SelectObject,
|
|
"formula" => F::Formula(v.into()),
|
|
"formula_format" => {
|
|
F::FormulaFormat(index(codec::fields::FORMULA_FORMATS.len())?)
|
|
}
|
|
"precision" => F::FormulaPrecision(index(fd::FORMULA_PRECISIONS.len())?),
|
|
"link_text" => F::HyperlinkText(v.into()),
|
|
"link_url" => F::HyperlinkUrl(v.into()),
|
|
"plot_scale" => F::PlotScale(index(codec::fields::PLOT_SCALE_FORMATS.len())?),
|
|
"count" => F::CountExpression(v.into()),
|
|
"show_instances" => F::ShowCountInstances,
|
|
"ss_custom" => F::SsCustom(v.into()),
|
|
"ss_set" => F::SsSet(v.parse().map_err(|_| failure("invalid_value", "index"))?),
|
|
"ss_dst" => F::SsPicked(Some(std::path::PathBuf::from(v))),
|
|
"ss_node" => {
|
|
let state = self
|
|
.field_dialog
|
|
.as_ref()
|
|
.ok_or_else(|| failure("invalid_state", "No Field dialog"))?;
|
|
let db = state
|
|
.ss_sets
|
|
.get(state.ss_set)
|
|
.ok_or_else(|| failure("invalid_state", "No sheet set"))?;
|
|
let views = state.name == "SheetView";
|
|
let id = fd::nav_nodes(db, views)
|
|
.into_iter()
|
|
.find(|(_, id, label, _)| label == v || id == v)
|
|
.map(|(_, id, _, _)| id)
|
|
.ok_or_else(|| failure("invalid_value", "No such node"))?;
|
|
F::SsNode(id)
|
|
}
|
|
"ss_prop" => F::SsProp(v.into()),
|
|
"ss_href" => F::SsHref(v == "1"),
|
|
"ss_placeholder" => F::SsPlaceholder(index(fd::PLACEHOLDER_TYPES.len())?),
|
|
"ss_scale" => F::SsScaleFormat(index(codec::fields::PLOT_SCALE_FORMATS.len())?),
|
|
"placeholder" => F::PlaceholderProperty(index(
|
|
codec::fields::BLOCK_PLACEHOLDER_PROPERTIES.len(),
|
|
)?),
|
|
"table_function" => F::TableFunction(
|
|
["Average", "Sum", "Count", "Cell"]
|
|
.into_iter()
|
|
.find(|f| f.eq_ignore_ascii_case(v))
|
|
.ok_or_else(|| failure("invalid_value", "Average|Sum|Count|Cell"))?,
|
|
),
|
|
_ => return Err(failure("invalid_value", "Unknown field dialog key")),
|
|
})
|
|
}
|
|
// Sheet Set Manager and its dialogs: "open=<dst>", "tab=list|views|model",
|
|
// "select=<target>", "activate=<target>", "menu=<action>:<target>"
|
|
// (open, new_sheet, new_subset, import, rename, remove, properties,
|
|
// close; a target is a sheet number or title, a subset name or
|
|
// "set"), "import_file=<dwg>", "new" (wizard), "existing=0|1",
|
|
// "step=<n>", "name=", "description=", "folder=", "hierarchy=0|1",
|
|
// "add_folder=<dir>", "layout=<i>:0|1", "wizard_props",
|
|
// "row=<i>:<value>", "prompt=0|1:<row>", "template=<file>",
|
|
// "custom=<i>:<value>", "add_custom", "custom_name=", "custom_default=",
|
|
// "custom_owner=0|1", "add_custom_ok", "number=", "title=",
|
|
// "file_name=", "pick_layout=<i>", "ok", "cancel".
|
|
"sheet_set" => {
|
|
use crate::ui::window::sheet_set::{
|
|
FieldId as F, MenuAction as A, SheetSetMsg as M, SsmTab, WizardMsg as W,
|
|
};
|
|
let value = string(req, "value")?;
|
|
let (key, arg) = value.split_once('=').unwrap_or((value, ""));
|
|
let target = |app: &Self, spec: &str| {
|
|
app.sheet_set_target(spec)
|
|
.ok_or_else(|| failure("invalid_value", "No such sheet set component"))
|
|
};
|
|
let index_value = |spec: &str| -> Result<(usize, String), Value> {
|
|
let (i, v) = spec.split_once(':').unwrap_or((spec, ""));
|
|
Ok((
|
|
i.parse()
|
|
.map_err(|_| failure("invalid_value", "Expected <index>:<value>"))?,
|
|
v.to_string(),
|
|
))
|
|
};
|
|
Message::SheetSet(match key {
|
|
"open" => M::OpenPicked(Some(std::path::PathBuf::from(arg))),
|
|
"import_file" => M::ImportPicked(Some(std::path::PathBuf::from(arg))),
|
|
"tab" => M::Tab(match arg {
|
|
"views" => SsmTab::SheetViews,
|
|
"model" => SsmTab::ModelViews,
|
|
_ => SsmTab::SheetList,
|
|
}),
|
|
"select" => M::Select(target(self, arg)?),
|
|
"activate" => M::Activate(target(self, arg)?),
|
|
"menu" => {
|
|
let (action, spec) = arg.split_once(':').unwrap_or((arg, "set"));
|
|
let action = match action {
|
|
"open" => A::Open,
|
|
"new_sheet" => A::NewSheet,
|
|
"new_subset" => A::NewSubset,
|
|
"import" => A::ImportLayout,
|
|
"rename" => A::Rename,
|
|
"remove" => A::Remove,
|
|
"properties" => A::Properties,
|
|
"close" => A::Close,
|
|
_ => {
|
|
return Err(failure(
|
|
"invalid_value",
|
|
"Unknown sheet set menu action",
|
|
))
|
|
}
|
|
};
|
|
if action == A::ImportLayout {
|
|
self.sheet_set_import_parent = Some(target(self, spec)?);
|
|
return Ok(Task::none());
|
|
}
|
|
M::Menu(target(self, spec)?, action)
|
|
}
|
|
"new" => return Ok(self.dispatch_command("NEWSHEETSET")),
|
|
"existing" => M::Wizard(W::Existing(arg == "1")),
|
|
"step" => M::Wizard(W::Step(
|
|
arg.parse().map_err(|_| failure("invalid_value", "step"))?,
|
|
)),
|
|
"layout" => {
|
|
let (i, v) = index_value(arg)?;
|
|
M::Wizard(W::LayoutOn(i, v == "1"))
|
|
}
|
|
"name" => M::Input(F::Name, arg.into()),
|
|
"description" => M::Input(F::Description, arg.into()),
|
|
"folder" => M::Input(F::Folder, arg.into()),
|
|
"hierarchy" => M::Toggle(F::Hierarchy, arg == "1"),
|
|
"add_folder" => {
|
|
M::FolderPicked(F::AddFolder, Some(std::path::PathBuf::from(arg)))
|
|
}
|
|
"wizard_props" => M::WizardProperties,
|
|
"row" => {
|
|
let (i, v) = index_value(arg)?;
|
|
M::Input(F::Row(i), v)
|
|
}
|
|
"prompt" => {
|
|
let (i, v) = index_value(arg)?;
|
|
M::Choose(F::Row(i), usize::from(v != "1"))
|
|
}
|
|
"template" => M::TemplatePicked(Some(std::path::PathBuf::from(arg))),
|
|
"custom" => {
|
|
let (i, v) = index_value(arg)?;
|
|
M::CustomValue(i, v)
|
|
}
|
|
"add_custom" => M::AddCustom,
|
|
"custom_name" => M::Input(F::CustomName, arg.into()),
|
|
"custom_default" => M::Input(F::CustomDefault, arg.into()),
|
|
"custom_owner" => M::Choose(F::CustomOwner, usize::from(arg == "1")),
|
|
"add_custom_ok" => M::AddCustomOk,
|
|
"number" => M::Input(F::Number, arg.into()),
|
|
"title" => M::Input(F::Title, arg.into()),
|
|
"file_name" => M::Input(F::FileName, arg.into()),
|
|
"pick_layout" => M::Choose(
|
|
F::Layout,
|
|
arg.parse().map_err(|_| failure("invalid_value", "index"))?,
|
|
),
|
|
"refresh" => M::Refresh,
|
|
"open_after" => M::Toggle(F::OpenAfter, arg == "1"),
|
|
"row_toggle" => {
|
|
let (i, v) = index_value(arg)?;
|
|
M::Toggle(F::Row(i), v == "1")
|
|
}
|
|
"publish" => M::Toggle(F::Publish, arg == "1"),
|
|
"layout_name" => M::Input(F::LayoutName, arg.into()),
|
|
"rename_opt" => {
|
|
let (i, v) = index_value(arg)?;
|
|
M::Toggle(F::RenameOption(i.min(3)), v == "1")
|
|
}
|
|
"import_add" => {
|
|
M::ImportAdd(arg.split('|').map(std::path::PathBuf::from).collect())
|
|
}
|
|
"import_row" => {
|
|
let (i, v) = index_value(arg)?;
|
|
M::Toggle(F::ImportRow(i), v == "1")
|
|
}
|
|
"prefix" => M::Toggle(F::ImportPrefix, arg == "1"),
|
|
"previous" => M::Previous,
|
|
"next" => M::Next,
|
|
"views_by_category" => M::ViewsByCategory(arg == "1"),
|
|
"new_category" => M::NewCategory,
|
|
"category" => {
|
|
let id = self
|
|
.sheet_set
|
|
.db()
|
|
.and_then(|db| {
|
|
db.view_categories()
|
|
.iter()
|
|
.find(|c| c.prop("Name") == Some(arg))
|
|
.map(|c| c.id().to_string())
|
|
})
|
|
.ok_or_else(|| failure("invalid_value", "No such view category"))?;
|
|
M::CategoryProperties(id)
|
|
}
|
|
"remove_category" => {
|
|
let id = self
|
|
.sheet_set
|
|
.db()
|
|
.and_then(|db| {
|
|
db.view_categories()
|
|
.iter()
|
|
.find(|c| c.prop("Name") == Some(arg))
|
|
.map(|c| c.id().to_string())
|
|
})
|
|
.ok_or_else(|| failure("invalid_value", "No such view category"))?;
|
|
M::CategoryRemove(id)
|
|
}
|
|
"category_block" => {
|
|
let (i, v) = index_value(arg)?;
|
|
M::Toggle(F::CategoryBlock(i), v == "1")
|
|
}
|
|
"add_location" => M::LocationPicked(Some(std::path::PathBuf::from(arg))),
|
|
"remove_location" => {
|
|
let id = self
|
|
.sheet_set
|
|
.locations
|
|
.iter()
|
|
.find(|(_, folder, _)| {
|
|
codec::sheet_set::path_key(folder)
|
|
== codec::sheet_set::path_key(arg)
|
|
})
|
|
.map(|(id, _, _)| id.clone())
|
|
.ok_or_else(|| failure("invalid_value", "No such location"))?;
|
|
M::RemoveLocation(id)
|
|
}
|
|
"open_drawing" => M::OpenDrawing(arg.into()),
|
|
"toggle_drawing" => M::ToggleDrawing(arg.into()),
|
|
"see_views" => M::SeeViews(arg.into()),
|
|
// place=<drawing>[|<view>]
|
|
"place" => {
|
|
let (drawing, view) = arg
|
|
.split_once('|')
|
|
.map_or((arg, None), |(d, v)| (d, Some(v.to_string())));
|
|
M::PlaceOnSheet(drawing.into(), view)
|
|
}
|
|
// view=<view title or number - title>:<display|rename|label|category=<name>|callout=<block>>
|
|
"view" => {
|
|
use crate::ui::window::sheet_set::ViewAction as VA;
|
|
let (spec, action) = arg
|
|
.rsplit_once(':')
|
|
.ok_or_else(|| failure("invalid_value", "view=<view>:<action>"))?;
|
|
let db = self
|
|
.sheet_set
|
|
.db()
|
|
.ok_or_else(|| failure("invalid_state", "No sheet set"))?;
|
|
let view = db
|
|
.sheets()
|
|
.into_iter()
|
|
.flat_map(|s| db.sheet_views(s))
|
|
.find(|v| {
|
|
codec::sheet_set::number_and_title(v) == spec
|
|
|| v.prop("Title") == Some(spec)
|
|
})
|
|
.map(|v| v.id().to_string())
|
|
.ok_or_else(|| failure("invalid_value", "No such sheet view"))?;
|
|
let (verb, name) = action.split_once('=').unwrap_or((action, ""));
|
|
let a = match verb {
|
|
"display" => VA::Display,
|
|
"rename" => VA::Rename,
|
|
"label" => VA::Label,
|
|
"category" => VA::Category(
|
|
db.view_categories()
|
|
.iter()
|
|
.find(|c| c.prop("Name") == Some(name))
|
|
.map(|c| c.id().to_string())
|
|
.ok_or_else(|| {
|
|
failure("invalid_value", "No such view category")
|
|
})?,
|
|
),
|
|
"callout" => VA::Callout(
|
|
db.callout_blocks()
|
|
.iter()
|
|
.find(|b| b.prop("Name") == Some(name))
|
|
.map(|b| b.id().to_string())
|
|
.ok_or_else(|| {
|
|
failure("invalid_value", "No such callout block")
|
|
})?,
|
|
),
|
|
_ => {
|
|
return Err(failure(
|
|
"invalid_value",
|
|
"display|rename|label|category=|callout=",
|
|
))
|
|
}
|
|
};
|
|
M::ViewMenu(view, a)
|
|
}
|
|
"add_blocks" => M::AddBlocks,
|
|
"block_list_select" => M::BlockListSelect(
|
|
arg.parse().map_err(|_| failure("invalid_value", "index"))?,
|
|
),
|
|
"block_list_add" => M::BlockListAdd,
|
|
"block_list_delete" => M::BlockListDelete,
|
|
"select_block_file" => {
|
|
M::SelectBlockPicked(Some(std::path::PathBuf::from(arg)))
|
|
}
|
|
"select_block_whole" => M::SelectBlockWhole(arg == "1"),
|
|
"select_block_check" => {
|
|
let (i, v) = index_value(arg)?;
|
|
M::SelectBlockCheck(i, v == "1")
|
|
}
|
|
"ok" => M::Ok,
|
|
"cancel" => M::Cancel,
|
|
_ => return Err(failure("invalid_value", "Unknown sheet set action")),
|
|
})
|
|
}
|
|
"ribbon_dropdown" => Message::ToggleRibbonDropdown(string(req, "value")?.into()),
|
|
// The Edit Reference dialog, one field per call: "level=<n>" (0 =
|
|
// the picked reference), "nested=0|1" (pick the working set in the
|
|
// drawing), "attributes=0|1", "lock=0|1", "unique=0|1", "ok" or
|
|
// "cancel".
|
|
"refedit_dialog" => {
|
|
use crate::ui::window::refedit_dialog::RefEditDialogMsg as R;
|
|
if self.active_modal != Some(crate::app::ModalKind::RefEdit) {
|
|
return Err(failure(
|
|
"no_dialog",
|
|
"The Reference Edit dialog is not open",
|
|
));
|
|
}
|
|
let spec = string(req, "value")?;
|
|
let (key, value) = spec.split_once('=').unwrap_or((spec, ""));
|
|
let flag = || match value {
|
|
"1" => Ok(true),
|
|
"0" => Ok(false),
|
|
_ => Err(failure("invalid_value", spec)),
|
|
};
|
|
if key == "cancel" {
|
|
Message::CloseModal
|
|
} else {
|
|
Message::RefEditDialog(match key {
|
|
"ok" => R::Ok,
|
|
"level" => {
|
|
R::Level(value.parse().map_err(|_| failure("invalid_value", spec))?)
|
|
}
|
|
"nested" => R::Nested(flag()?),
|
|
"attributes" => R::Attributes(flag()?),
|
|
"lock" => R::Lock(flag()?),
|
|
"unique" => R::Unique(flag()?),
|
|
_ => return Err(failure("invalid_value", "Unknown Reference Edit field")),
|
|
})
|
|
}
|
|
}
|
|
// Ribbon key tips: `show`, `alt` (a lone Alt), `type:<codes>`, `enter`,
|
|
// `esc` or `hide`. Read them back with the `keytips` read operation.
|
|
"keytips" => {
|
|
use crate::ui::ribbon::KeyTipInput as K;
|
|
let value = string(req, "value")?;
|
|
let inputs: Vec<K> = match value {
|
|
"show" => vec![K::Open { refocus: false }],
|
|
"alt" => vec![K::AltTap],
|
|
"esc" => vec![K::Escape],
|
|
"hide" => vec![K::Hide],
|
|
"enter" => vec![K::Enter],
|
|
_ => match value.strip_prefix("type:") {
|
|
Some(codes) if !codes.is_empty() => codes
|
|
.chars()
|
|
.map(|ch| K::Char(ch.to_uppercase().collect()))
|
|
.collect(),
|
|
_ => {
|
|
return Err(failure(
|
|
"invalid_value",
|
|
"Use show, alt, type:<codes>, enter, esc or hide",
|
|
))
|
|
}
|
|
},
|
|
};
|
|
let tasks: Vec<_> = inputs
|
|
.into_iter()
|
|
.map(|input| self.on_keytip(input))
|
|
.collect();
|
|
return Ok(Task::batch(tasks));
|
|
}
|
|
// The -VIEW Background dialog, one field per call: "type=gradient",
|
|
// "color|top|middle|bottom=r,g,b", "three=1", "rotation=30",
|
|
// "image=<path>", "position=center|stretch|tile", "aspect=1", "offset=x,y" (pixels), "scale=x,y",
|
|
// "step=offset_x|offset_y|scale_x|scale_y,+1|-1", "reset",
|
|
// "apply" or "cancel".
|
|
"view_background" => {
|
|
use crate::ui::window::pdf_dialogs::{BgKind, BgPosition, PdfDialogMsg as P};
|
|
if self.active_modal != Some(crate::app::ModalKind::ViewBackground) {
|
|
return Err(failure("no_dialog", "The Background dialog is not open"));
|
|
}
|
|
let spec = string(req, "value")?;
|
|
let (key, value) = spec.split_once('=').unwrap_or((spec, ""));
|
|
let bad = || failure("invalid_value", spec);
|
|
let rgb = || -> Result<[u8; 3], Value> {
|
|
let parts: Vec<u8> = value
|
|
.split(',')
|
|
.map(|part| part.trim().parse::<u8>())
|
|
.collect::<Result<_, _>>()
|
|
.map_err(|_| bad())?;
|
|
<[u8; 3]>::try_from(parts).map_err(|_| bad())
|
|
};
|
|
match key {
|
|
"cancel" => Message::CloseModal,
|
|
"apply" => Message::PdfDialog(P::BgApply),
|
|
"type" => Message::PdfDialog(P::BgKind(match value {
|
|
"none" => BgKind::None,
|
|
"solid" => BgKind::Solid,
|
|
"gradient" => BgKind::Gradient,
|
|
"image" => BgKind::Image,
|
|
_ => return Err(bad()),
|
|
})),
|
|
"color" => Message::PdfDialog(P::BgColor(0, rgb()?)),
|
|
"top" => Message::PdfDialog(P::BgColor(1, rgb()?)),
|
|
"middle" => Message::PdfDialog(P::BgColor(2, rgb()?)),
|
|
"bottom" => Message::PdfDialog(P::BgColor(3, rgb()?)),
|
|
"three" => Message::PdfDialog(P::BgThree(value == "1")),
|
|
"rotation" => Message::PdfDialog(P::BgRotation(value.to_string())),
|
|
"image" => Message::PdfDialog(P::BgImage(value.to_string())),
|
|
"position" => Message::PdfDialog(P::BgPosition(match value {
|
|
"center" => BgPosition::Center,
|
|
"stretch" => BgPosition::Stretch,
|
|
"tile" => BgPosition::Tile,
|
|
_ => return Err(bad()),
|
|
})),
|
|
"aspect" => Message::PdfDialog(P::BgAspect(value == "1")),
|
|
"offset" | "scale" => {
|
|
let pair: Vec<String> = value
|
|
.split(',')
|
|
.map(|part| part.trim().to_string())
|
|
.collect();
|
|
let pair = <[String; 2]>::try_from(pair).map_err(|_| bad())?;
|
|
if pair
|
|
.iter()
|
|
.any(|v| v.parse::<f64>().map_or(true, |v| !v.is_finite()))
|
|
{
|
|
return Err(bad());
|
|
}
|
|
if key == "offset" {
|
|
Message::PdfDialog(P::BgOffset(pair))
|
|
} else {
|
|
Message::PdfDialog(P::BgScale(pair))
|
|
}
|
|
}
|
|
"step" => {
|
|
// step=offset_x|offset_y|scale_x|scale_y,+1|-1
|
|
let (field, dir) = value.split_once(',').ok_or_else(bad)?;
|
|
let field = match field {
|
|
"offset_x" => 0,
|
|
"offset_y" => 1,
|
|
"scale_x" => 2,
|
|
"scale_y" => 3,
|
|
_ => return Err(bad()),
|
|
};
|
|
let dir: i8 = dir.trim().parse().map_err(|_| bad())?;
|
|
Message::PdfDialog(P::BgStep(field, dir))
|
|
}
|
|
"reset" => Message::PdfDialog(P::BgReset),
|
|
_ => return Err(bad()),
|
|
}
|
|
}
|
|
// The Import File window, one field per call: "path=<file>",
|
|
// "format=<index or name>", "search=<text>", "filter=all|supported",
|
|
// "recent=<n>", "ok" or "cancel".
|
|
"import_file_dialog" => {
|
|
use crate::ui::window::import_dialogs::{ImportDialogMsg as I, FORMATS};
|
|
if self.active_modal != Some(crate::app::ModalKind::ImportFile) {
|
|
return Err(failure("no_dialog", "The Import File window is not open"));
|
|
}
|
|
let spec = string(req, "value")?;
|
|
let (key, value) = spec.split_once('=').unwrap_or((spec, ""));
|
|
let bad = || failure("invalid_value", spec);
|
|
if key == "cancel" {
|
|
return Ok(Task::done(Message::CloseModal));
|
|
}
|
|
Message::ImportDialog(match key {
|
|
"ok" => I::ImportOk,
|
|
"path" => I::Path(value.to_string()),
|
|
"search" => I::Search(value.to_string()),
|
|
"filter" => I::OnlySupported(match value {
|
|
"all" => false,
|
|
"supported" => true,
|
|
_ => return Err(bad()),
|
|
}),
|
|
"recent" => I::Recent(value.trim().parse().map_err(|_| bad())?),
|
|
"format" => I::Format(match value.trim().parse::<usize>() {
|
|
Ok(k) if k < FORMATS.len() => k,
|
|
_ => FORMATS
|
|
.iter()
|
|
.position(|f| f.name.eq_ignore_ascii_case(value.trim()))
|
|
.ok_or_else(bad)?,
|
|
}),
|
|
_ => return Err(bad()),
|
|
})
|
|
}
|
|
// The DGN Import window, one field per call: "path=<file>" (as
|
|
// Change… picks it), "model=<n>", "target=new|current",
|
|
// "conflict=prefix|ignore", "units=master|sub", "explode=on|off",
|
|
// "references=translate|ignore|underlay", "existing=prompt|overwrite|keep",
|
|
// "mapping=layer|linetype|lineweight|color", "ok" or "cancel".
|
|
"dgn_import_dialog" => {
|
|
use crate::ui::window::import_dialogs::ImportDialogMsg as I;
|
|
if self.active_modal != Some(crate::app::ModalKind::DgnImport) {
|
|
return Err(failure("no_dialog", "The DGN Import window is not open"));
|
|
}
|
|
let spec = string(req, "value")?;
|
|
let (key, value) = spec.split_once('=').unwrap_or((spec, ""));
|
|
let bad = || failure("invalid_value", spec);
|
|
let on = |v: &str| match v {
|
|
"on" | "1" | "true" => Ok(true),
|
|
"off" | "0" | "false" => Ok(false),
|
|
_ => Err(bad()),
|
|
};
|
|
if key == "cancel" {
|
|
return Ok(Task::done(Message::CloseModal));
|
|
}
|
|
Message::ImportDialog(match key {
|
|
"ok" => I::DgnOk,
|
|
"path" => I::DgnPicked(Some(value.to_string())),
|
|
"model" => I::DgnModel(value.trim().parse().map_err(|_| bad())?),
|
|
"target" => I::DgnCurrent(match value {
|
|
"new" => false,
|
|
"current" => true,
|
|
_ => return Err(bad()),
|
|
}),
|
|
"conflict" => I::DgnPrefix(match value {
|
|
"prefix" => true,
|
|
"ignore" => false,
|
|
_ => return Err(bad()),
|
|
}),
|
|
"units" => I::DgnSub(match value {
|
|
"master" => false,
|
|
"sub" => true,
|
|
_ => return Err(bad()),
|
|
}),
|
|
"explode" => I::DgnExplode(on(value)?),
|
|
"references" => I::DgnReferences(match value {
|
|
"translate" => crate::modules::insert::dgn_import::ReferenceMode::Translate,
|
|
"ignore" => crate::modules::insert::dgn_import::ReferenceMode::Ignore,
|
|
"underlay" => crate::modules::insert::dgn_import::ReferenceMode::Underlay,
|
|
_ => return Err(bad()),
|
|
}),
|
|
"existing" => I::DgnExisting(match value {
|
|
"prompt" => crate::modules::insert::dgn_import::ExistingReference::Prompt,
|
|
"overwrite" => crate::modules::insert::dgn_import::ExistingReference::Overwrite,
|
|
"keep" => crate::modules::insert::dgn_import::ExistingReference::Keep,
|
|
_ => return Err(bad()),
|
|
}),
|
|
"mapping" => I::DgnMappingTab(match value {
|
|
"layer" => 0,
|
|
"linetype" => 1,
|
|
"lineweight" => 2,
|
|
"color" => 3,
|
|
_ => return Err(bad()),
|
|
}),
|
|
_ => return Err(bad()),
|
|
})
|
|
}
|
|
// The Image Adjust dialog, one field per call: "brightness=70",
|
|
// "contrast=40", "fade=20" (the bar), "type=fade,abc" (typed into
|
|
// the box), "step=brightness,+1|-1", "reset", "apply" or "cancel".
|
|
"image_adjust" => {
|
|
use crate::ui::window::pdf_dialogs::PdfDialogMsg as P;
|
|
if self.active_modal != Some(crate::app::ModalKind::ImageAdjust) {
|
|
return Err(failure("no_dialog", "The Image Adjust dialog is not open"));
|
|
}
|
|
let spec = string(req, "value")?;
|
|
let (key, value) = spec.split_once('=').unwrap_or((spec, ""));
|
|
let bad = || failure("invalid_value", spec);
|
|
let row = |name: &str| match name.trim() {
|
|
"brightness" => Ok(0u8),
|
|
"contrast" => Ok(1),
|
|
"fade" => Ok(2),
|
|
_ => Err(bad()),
|
|
};
|
|
match key {
|
|
"cancel" => Message::CloseModal,
|
|
"apply" => Message::PdfDialog(P::IaApply),
|
|
"reset" => Message::PdfDialog(P::IaReset),
|
|
"brightness" | "contrast" | "fade" => {
|
|
let v: u8 = value
|
|
.trim()
|
|
.parse()
|
|
.ok()
|
|
.filter(|v| *v <= 100)
|
|
.ok_or_else(bad)?;
|
|
Message::PdfDialog(P::IaValue(row(key)?, v))
|
|
}
|
|
"type" => {
|
|
let (name, typed) = value.split_once(',').ok_or_else(bad)?;
|
|
Message::PdfDialog(P::IaText(row(name)?, typed.to_string()))
|
|
}
|
|
"step" => {
|
|
let (name, dir) = value.split_once(',').ok_or_else(bad)?;
|
|
let dir: i8 = dir.trim().parse().map_err(|_| bad())?;
|
|
Message::PdfDialog(P::IaStep(row(name)?, dir))
|
|
}
|
|
_ => return Err(bad()),
|
|
}
|
|
}
|
|
// Text to MText Settings (TXT2MTXT SEttings): "combine=0",
|
|
// "order=select" / "order=top", "wrap=0", "uniform=1", "ok",
|
|
// "cancel".
|
|
"txt2mtxt_settings" => {
|
|
use crate::ui::window::pdf_dialogs::PdfDialogMsg as M;
|
|
if self.active_modal != Some(crate::app::ModalKind::Txt2MtxtSettings) {
|
|
return Err(failure("no_dialog", "Text to MText Settings is not open"));
|
|
}
|
|
let value = string(req, "value")?;
|
|
let (key, arg) = value.split_once('=').unwrap_or((value, ""));
|
|
let on = arg != "0";
|
|
match key {
|
|
"combine" => Message::PdfDialog(M::T2mCombine(on)),
|
|
"order" => Message::PdfDialog(M::T2mSelectOrder(arg == "select")),
|
|
"wrap" => Message::PdfDialog(M::T2mWrap(on)),
|
|
"uniform" => Message::PdfDialog(M::T2mUniform(on)),
|
|
"ok" => Message::PdfDialog(M::T2mOk),
|
|
"cancel" => Message::CloseModal,
|
|
_ => return Err(failure("invalid_value", "Unknown setting")),
|
|
}
|
|
}
|
|
// The PDF Text Recognition Settings dialog, one edit per call:
|
|
// "select=<n>", "toggle=<n>,0|1", "up", "down", "remove",
|
|
// "add=<path to .shx>", "move=<from>,<to>" (dragging a row),
|
|
// "threshold=<1-100>", "threshold_text=<typed>", "step=+1|-1",
|
|
// "layer=current|geometry", "best=0|1", "sample=<text>",
|
|
// "defaults", "apply" or "cancel" (rows count from 0).
|
|
"pdf_shx_settings" => {
|
|
use crate::ui::window::pdf_shx_dialog::ShxDialogMsg as S;
|
|
if self.active_modal != Some(crate::app::ModalKind::PdfShxSettings) {
|
|
return Err(failure(
|
|
"no_dialog",
|
|
"The PDF Text Recognition Settings dialog is not open",
|
|
));
|
|
}
|
|
let spec = string(req, "value")?;
|
|
let (key, value) = spec.split_once('=').unwrap_or((spec, ""));
|
|
let bad = || failure("invalid_value", spec);
|
|
let index = |v: &str| v.trim().parse::<usize>().map_err(|_| bad());
|
|
let flag = |v: &str| match v.trim() {
|
|
"1" => Ok(true),
|
|
"0" => Ok(false),
|
|
_ => Err(bad()),
|
|
};
|
|
match key {
|
|
"cancel" => Message::CloseModal,
|
|
"move" => {
|
|
let (from, to) = value.split_once(',').ok_or_else(bad)?;
|
|
let (from, to) = (index(from)?, index(to)?);
|
|
return Ok(Task::batch([
|
|
self.update(Message::PdfShxDialog(S::DragStart(from))),
|
|
self.update(Message::PdfShxDialog(S::DragDrop(to))),
|
|
]));
|
|
}
|
|
_ => Message::PdfShxDialog(match key {
|
|
"apply" => S::Apply,
|
|
"defaults" => S::Defaults,
|
|
"up" => S::Up,
|
|
"down" => S::Down,
|
|
"remove" => S::Remove,
|
|
"select" => S::Select(index(value)?),
|
|
"toggle" => {
|
|
let (row, on) = value.split_once(',').ok_or_else(bad)?;
|
|
S::Toggle(index(row)?, flag(on)?)
|
|
}
|
|
"add" => S::Added(Some(std::path::PathBuf::from(value.trim()))),
|
|
"threshold" => S::Threshold(
|
|
value
|
|
.trim()
|
|
.parse::<u8>()
|
|
.ok()
|
|
.filter(|v| (1..=100).contains(v))
|
|
.ok_or_else(bad)?,
|
|
),
|
|
"threshold_text" => S::ThresholdText(value.to_string()),
|
|
"step" => S::Step(value.trim().parse::<i8>().map_err(|_| bad())?),
|
|
"layer" => match value.trim() {
|
|
"current" => S::CurrentLayer(true),
|
|
"geometry" => S::CurrentLayer(false),
|
|
_ => return Err(bad()),
|
|
},
|
|
"best" => S::BestFont(flag(value)?),
|
|
"sample" => S::Sample(value.to_string()),
|
|
_ => return Err(bad()),
|
|
}),
|
|
}
|
|
}
|
|
"pdf_dialog_ok" => {
|
|
use crate::ui::window::pdf_dialogs::PdfDialogMsg;
|
|
Message::PdfDialog(match self.active_modal {
|
|
Some(crate::app::ModalKind::PdfAttach) => PdfDialogMsg::AttachOk,
|
|
Some(crate::app::ModalKind::PointCloudAttach) => PdfDialogMsg::CloudOk,
|
|
Some(crate::app::ModalKind::PointCloudColorMap) => PdfDialogMsg::MapOk,
|
|
Some(crate::app::ModalKind::PcSection) => PdfDialogMsg::SecCreate,
|
|
Some(crate::app::ModalKind::UnderlayLayers) => PdfDialogMsg::LayersOk,
|
|
Some(crate::app::ModalKind::PdfImportSettings) => PdfDialogMsg::SettingsOk,
|
|
Some(crate::app::ModalKind::PdfImportFile) => PdfDialogMsg::ImportOk,
|
|
_ => return Err(failure("no_dialog", "No PDF dialog is open")),
|
|
})
|
|
}
|
|
// The open dialog's OK button.
|
|
"dialog_ok" => match self.active_modal {
|
|
Some(crate::app::ModalKind::XrefAttach) => {
|
|
Message::XrefAttach(crate::ui::window::xref_attach::XrefAttachMsg::Apply)
|
|
}
|
|
Some(crate::app::ModalKind::BlockDefinition) => Message::BlockDefApply,
|
|
Some(crate::app::ModalKind::AttDef) => {
|
|
Message::AttdefDialog(crate::ui::window::attdef_dialog::AttdefDialogMsg::Ok)
|
|
}
|
|
Some(crate::app::ModalKind::AttDefEdit) => {
|
|
Message::AttdefDialog(crate::ui::window::attdef_dialog::AttdefDialogMsg::EditOk)
|
|
}
|
|
Some(crate::app::ModalKind::SheetSet) => {
|
|
Message::SheetSet(crate::ui::window::sheet_set::SheetSetMsg::Ok)
|
|
}
|
|
Some(crate::app::ModalKind::Field) => {
|
|
Message::FieldDialog(crate::ui::window::field_dialog::FieldDialogMsg::Ok)
|
|
}
|
|
Some(crate::app::ModalKind::AttMan) => {
|
|
Message::AttMan(crate::ui::window::attman_dialog::AttManMsg::Ok)
|
|
}
|
|
_ => return Err(failure("no_dialog", "No dialog with an OK button is open")),
|
|
},
|
|
"close_document" => Message::TabClose(self.tabs[self.active_tab].id),
|
|
"toggle_properties" => Message::ToggleProperties,
|
|
"toggle_layers" => Message::ToggleLayers,
|
|
"toggle_grid" => Message::ToggleGrid,
|
|
"toggle_snap" => Message::ToggleSnapEnabled,
|
|
"toggle_ortho" => Message::ToggleOrtho,
|
|
// Choose an entry of the open multi-functional grip menu by its
|
|
// position (0 = first), as a click on it does.
|
|
"grip_menu_pick" => {
|
|
let entries = self
|
|
.grip_popup
|
|
.as_ref()
|
|
.map_or(0, |popup| popup.items.len());
|
|
if entries == 0 {
|
|
return Err(failure("menu_closed", "No grip menu is open"));
|
|
}
|
|
let index = string(req, "value")?
|
|
.parse::<usize>()
|
|
.ok()
|
|
.filter(|index| *index < entries)
|
|
.ok_or_else(|| failure("invalid_value", "value is a menu entry index"))?;
|
|
Message::GripMenuPick(index)
|
|
}
|
|
"mtext_insert" => {
|
|
if self.mtext_editor.is_none() {
|
|
return Err(failure("editor_closed", "MText editor is closed"));
|
|
}
|
|
Message::MTextInsert(string(req, "value")?.into())
|
|
}
|
|
"mtext_commit" => Message::MTextOk,
|
|
"mtext_cancel" => Message::MTextCancel,
|
|
"text_input" => Message::TextInlineInput(string(req, "value")?.into()),
|
|
"text_commit" => Message::TextInlineOk,
|
|
"pointer_move"
|
|
| "pointer_press"
|
|
| "pointer_release"
|
|
| "pointer_right_press"
|
|
| "pointer_right_release" => {
|
|
let x = req["x"]
|
|
.as_f64()
|
|
.filter(|v| v.is_finite())
|
|
.ok_or_else(|| failure("invalid_point", "Missing finite x"))?
|
|
as f32;
|
|
let y = req["y"]
|
|
.as_f64()
|
|
.filter(|v| v.is_finite())
|
|
.ok_or_else(|| failure("invalid_point", "Missing finite y"))?
|
|
as f32;
|
|
let size = self.tabs[self.active_tab]
|
|
.scene
|
|
.selection
|
|
.borrow()
|
|
.view
|
|
.vp_size;
|
|
if x < 0. || y < 0. || x > size.0 || y > size.1 {
|
|
return Err(failure(
|
|
"outside_viewport",
|
|
"Pointer coordinates are outside viewport_size",
|
|
));
|
|
}
|
|
let move_task = self.update(Message::ViewportMove(iced::Point::new(x, y)));
|
|
let event = match name {
|
|
"pointer_press" => self.update(Message::ViewportLeftPress),
|
|
"pointer_release" => self.update(Message::ViewportLeftRelease),
|
|
// Right button: the context menu / Enter behaviour chosen in
|
|
// Options (see `Message::ViewportRightRelease`).
|
|
"pointer_right_press" => self.update(Message::ViewportRightPress),
|
|
"pointer_right_release" => self.update(Message::ViewportRightRelease),
|
|
_ => Task::none(),
|
|
};
|
|
return Ok(Task::batch([move_task, event]));
|
|
}
|
|
"view_home" => Message::ViewCubeHome,
|
|
"zoom_extents" => return Ok(self.dispatch_command("ZOOM EXTENTS")),
|
|
"undo" => Message::Undo,
|
|
"redo" => Message::Redo,
|
|
"layer_visible" | "layer_locked" | "layer_frozen" | "layer_current" => {
|
|
let layer = string(req, "layer")?.to_string();
|
|
// The Layer* messages carry the layer name and resolve it to
|
|
// the current panel row at dispatch time (#22) — no index
|
|
// lookup here, so panel sort order cannot mistarget the CLI.
|
|
if !self.tabs[self.active_tab]
|
|
.scene
|
|
.document
|
|
.layers
|
|
.contains(&layer)
|
|
{
|
|
return Err(failure("unknown_layer", "Layer does not exist"));
|
|
}
|
|
match name {
|
|
"layer_visible" => Message::LayerToggleVisible(layer),
|
|
"layer_locked" => Message::LayerToggleLock(layer),
|
|
"layer_frozen" => Message::LayerToggleFreeze(layer),
|
|
_ => {
|
|
let select = self.update(Message::LayerSelect(layer));
|
|
let current = self.update(Message::LayerSetCurrent);
|
|
return Ok(Task::batch([select, current]));
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
return Err(failure(
|
|
"unknown_action",
|
|
"Read commands.actions for supported action IDs",
|
|
))
|
|
}
|
|
};
|
|
Ok(self.update(msg))
|
|
}
|
|
}
|