feat(layers): add LAYTRANS to translate layers onto a standard

Drawings arrive from elsewhere carrying someone else's layer names, and
folding them into a set of house layers meant renaming and merging by
hand, one layer at a time.

LAYTRANS reads a layer set out of another drawing and moves this one onto
it. Given a file it maps every name the two already share and translates
at once, which is all most incoming drawings need. Bare, it opens a
translator: the drawing's layers on one side, the loaded set on the
other, mappings built up between them and applied only on Translate, so a
mapping can be reconsidered and the whole translation is a single undo
step. Mappings can be saved and read back, objects can be forced to
ByLayer so the target layer's properties are what actually shows, and the
result can be written out as a log.

The move itself is the one LAYMRG already made for a single pair, so
LAYMRG now goes through it too rather than keeping its own copy.

Three things fell out for free or nearly so. A standards file is a
drawing, so .dws and .dwt need nothing but a place in the file filter.
The entity list is flat and holds block definitions, so objects inside
blocks travel with everything else instead of being stranded on a layer
that no longer exists. And a target layer that this drawing already has
is left alone: the drawing has an opinion about it, and restyling it
would reach past what was asked for -- only newly created targets take
the standard's properties.

Mappings are saved beside the drawing rather than inside a DWS. That is
where AutoCAD keeps them, but putting them there would mean inventing a
place in the drawing that nothing else reads back.

Closes #624

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 5 committed 2026-08-07 16:51:15 +03:00
1 parent f7590b1aa8
commit 36e328f7d7
10 files changed
+835 -8

No files matched your search

+171 -8
View File
@@ -78,6 +78,18 @@ impl OpenCADStudio {
}
}
// LAYTRANS — translate this drawing's layers onto a set taken from
// another drawing. Bare opens the mapping dialog; with a file it
// maps every name the two drawings share and translates at once,
// which is what most drawings coming from outside need. (#624)
"LAYTRANS" => {
return Some(self.open_layer_translator(None));
}
cmd if cmd.starts_with("LAYTRANS ") => {
let path = std::path::PathBuf::from(cmd.trim_start_matches("LAYTRANS").trim());
return Some(self.layer_translate_same(&path));
}
// LAYDEL <name> — delete a layer and erase the objects on it.
"LAYDEL" => {
use crate::command::ValuePromptCommand;
@@ -192,14 +204,13 @@ impl OpenCADStudio {
return Some(Task::none());
}
self.push_undo_snapshot(i, "LAYMRG");
let mut moved = 0usize;
for e in self.tabs[i].scene.document.entities_mut() {
if e.common().layer == src {
e.common_mut().layer = dst.clone();
moved += 1;
}
}
self.tabs[i].scene.document.layers.remove(&src);
// The same move a translation makes for each of its pairs.
let moved = crate::modules::draw::layers::laytrans::merge_layer(
&mut self.tabs[i].scene,
&src,
&dst,
false,
);
self.tabs[i].scene.invalidate_dependency_index();
self.tabs[i]
.scene
@@ -666,3 +677,155 @@ impl OpenCADStudio {
Some(self.finish_dispatch(cmd))
}
}
impl OpenCADStudio {
/// Translate every layer the two drawings name alike, in one step.
///
/// The common case for a drawing arriving from outside: the names already
/// line up and only the properties are wrong, so there is nothing to sit in
/// a dialog and decide.
pub(in crate::app) fn layer_translate_same(&mut self, path: &std::path::Path) -> Task<Message> {
use crate::modules::draw::layers::laytrans;
let i = self.active_tab;
let targets = match laytrans::load_targets(path) {
Ok(targets) => targets,
Err(why) => {
self.command_line
.push_error(crate::tf!("LAYTRANS: {why}.").as_ref());
return Task::none();
}
};
let current = self.tabs[i].active_layer.clone();
let sources = laytrans::source_layers(&self.tabs[i].scene, &current);
let mappings = laytrans::map_same(&sources, &targets);
if mappings.is_empty() {
self.command_line.push_info(
crate::t!("LAYTRANS: no layer names in common — use LAYTRANS with no file to map them.")
.as_ref(),
);
return Task::none();
}
self.push_undo_snapshot(i, "LAYTRANS");
let report = laytrans::translate(
&mut self.tabs[i].scene,
&mappings,
&targets,
&current,
laytrans::Options::default(),
);
self.finish_layer_translation(i, report)
}
/// Open the translator, optionally with a standard already loaded.
pub(in crate::app) fn open_layer_translator(
&mut self,
preload: Option<std::path::PathBuf>,
) -> Task<Message> {
self.layer_translator
.get_or_insert_with(Default::default);
self.active_modal = Some(crate::app::ModalKind::LayerTranslator);
match preload {
Some(path) => Task::done(Message::LayerTranslatorLoaded(path)),
None => Task::none(),
}
}
/// Read or write the mapping list as plain `from -> to` lines.
///
/// A drawing standards file is where AutoCAD keeps these, but writing them
/// there would mean inventing a place inside the DWG for something no other
/// application reads back. A small text file beside the drawing carries the
/// same information without touching the drawing's own structure.
pub(in crate::app) fn layer_translator_mappings_file(&mut self, path: &std::path::Path, save: bool) {
use crate::modules::draw::layers::laytrans::Mapping;
if save {
let Some(state) = self.layer_translator.as_ref() else {
return;
};
let body: String = state
.mappings
.iter()
.map(|m| format!("{} -> {}\n", m.from, m.to))
.collect();
match std::fs::write(path, body) {
Ok(()) => self.command_line.push_output(
crate::tf!("LAYTRANS: saved {n} mapping(s).", n = state.mappings.len())
.as_ref(),
),
Err(why) => self
.command_line
.push_error(crate::tf!("LAYTRANS: could not save — {why}.").as_ref()),
}
return;
}
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(why) => {
self.command_line
.push_error(crate::tf!("LAYTRANS: could not read — {why}.").as_ref());
return;
}
};
let parsed: Vec<Mapping> = text
.lines()
.filter_map(|line| {
let (from, to) = line.split_once("->")?;
let (from, to) = (from.trim(), to.trim());
(!from.is_empty() && !to.is_empty()).then(|| Mapping {
from: from.to_string(),
to: to.to_string(),
})
})
.collect();
let count = parsed.len();
if let Some(state) = self.layer_translator.as_mut() {
state.mappings = parsed;
}
self.command_line
.push_output(crate::tf!("LAYTRANS: loaded {count} mapping(s).").as_ref());
}
/// Write what the last translation did beside the drawing.
pub(in crate::app) fn write_layer_translation_log(&mut self, i: usize) {
let Some(report) = self.last_layer_translation.as_ref() else {
return;
};
let base = self.tabs[i]
.current_path
.clone()
.unwrap_or_else(|| std::path::PathBuf::from(&self.tabs[i].tab_title));
let path = base.with_extension("laytrans.log");
match std::fs::write(&path, report.to_log()) {
Ok(()) => self
.command_line
.push_output(crate::tf!("LAYTRANS: log written to {}.", path.display()).as_ref()),
Err(why) => self
.command_line
.push_error(crate::tf!("LAYTRANS: could not write log — {why}.").as_ref()),
}
}
/// Apply a translation's result: mark the drawing, refresh the panel and
/// say what moved. Shared by the command and the dialog so both report the
/// same way.
pub(in crate::app) fn finish_layer_translation(
&mut self,
i: usize,
report: crate::modules::draw::layers::laytrans::Report,
) -> Task<Message> {
let layers = report.translated.len();
let objects = report.objects();
self.tabs[i].dirty = true;
self.refresh_layer_panel();
self.refresh_properties();
for (layer, reason) in &report.skipped {
self.command_line
.push_info(crate::tf!("LAYTRANS: skipped \"{layer}\" — {reason}.").as_ref());
}
self.command_line.push_output(
crate::tf!("LAYTRANS: translated {layers} layer(s), {objects} object(s).").as_ref(),
);
self.last_layer_translation = Some(report);
Task::none()
}
}
+1
View File
@@ -297,6 +297,7 @@ inventory::submit!(crate::command::CommandRegistration {
"DBLIST",
// Layer management.
"LAYDEL",
"LAYTRANS",
"LAYMRG",
"LAYERSTATE",
"LAS",
+29
View File
@@ -403,6 +403,11 @@ pub(super) struct OpenCADStudio {
/// a pick *replaces* the set, so borrowing it here would wipe everything
/// gathered so far rather than subtract one thing. (#596)
select_remove_mode: bool,
/// What the last layer translation did, kept so the log can be written
/// after the fact rather than forcing a path out of the user up front.
last_layer_translation: Option<crate::modules::draw::layers::laytrans::Report>,
/// The layer-translator dialog's working state while it is open.
layer_translator: Option<crate::ui::window::layer_translator::State>,
/// PICKDRAG (#226): false (default) = press-drag lassoes; true =
/// press-drag draws a rectangle marquee.
pick_drag_rect: bool,
@@ -1489,6 +1494,7 @@ pub enum ModalKind {
UpdateNotice,
Layers,
LayerStateManager,
LayerTranslator,
LayerStateEditor,
Plot,
PrintAll,
@@ -1765,6 +1771,27 @@ pub enum Message {
LanguageChanged(crate::i18n::Language),
/// Drop every entity from the active drawing.
ClearScene,
// ── Layer Translator (#624) ──────────────────────────────────────────
/// Pick the drawing whose layers become the translation targets.
LayerTranslatorLoad,
/// A target set was read from `path`.
LayerTranslatorLoaded(std::path::PathBuf),
LayerTranslatorSelectFrom(String),
LayerTranslatorSelectTo(String),
/// Pair the two selected layers.
LayerTranslatorMap,
/// Pair every layer the two drawings name alike.
LayerTranslatorMapSame,
/// Drop the mapping that starts at this layer.
LayerTranslatorUnmap(String),
LayerTranslatorForceByLayer(bool),
LayerTranslatorWriteLog(bool),
LayerTranslatorSaveMappings,
LayerTranslatorLoadMappings,
/// Path chosen for saving or loading mappings.
LayerTranslatorMappingsPath(std::path::PathBuf, bool),
/// Apply every mapping.
LayerTranslatorTranslate,
/// Set the active tab's render mode — one of the seven visual styles, and
/// the only way a style is ever set.
SetRenderMode(acadrust::entities::ViewportRenderMode),
@@ -2977,6 +3004,8 @@ impl OpenCADStudio {
selection_cycling: false,
pick_add: true,
select_remove_mode: false,
last_layer_translation: None,
layer_translator: None,
pick_drag_rect: false,
perf_hud: false,
cycle_candidates: None,
+136
View File
@@ -1858,6 +1858,142 @@ impl OpenCADStudio {
self.active_modal = Some(super::ModalKind::LayerStateManager);
Task::none()
}
// ── Layer Translator (#624) ──────────────────────────────────
Message::LayerTranslatorLoad => Task::perform(
crate::io::pick_layer_standard_path(),
|path| match path {
Some(path) => Message::LayerTranslatorLoaded(path),
None => Message::Noop,
},
),
Message::LayerTranslatorLoaded(path) => {
use crate::modules::draw::layers::laytrans;
match laytrans::load_targets(&path) {
Ok(targets) => {
let state = self.layer_translator.get_or_insert_with(Default::default);
state.source_file = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
state.targets = targets;
// A target set that no longer contains a mapped name
// would translate onto nothing.
let names: Vec<String> =
state.targets.iter().map(|t| t.name.clone()).collect();
state
.mappings
.retain(|m| names.iter().any(|n| n.eq_ignore_ascii_case(&m.to)));
state.selected_to = None;
}
Err(why) => self
.command_line
.push_error(crate::tf!("LAYTRANS: {why}.").as_ref()),
}
Task::none()
}
Message::LayerTranslatorSelectFrom(name) => {
if let Some(state) = self.layer_translator.as_mut() {
state.selected_from = Some(name);
}
Task::none()
}
Message::LayerTranslatorSelectTo(name) => {
if let Some(state) = self.layer_translator.as_mut() {
state.selected_to = Some(name);
}
Task::none()
}
Message::LayerTranslatorMap => {
use crate::modules::draw::layers::laytrans::Mapping;
if let Some(state) = self.layer_translator.as_mut() {
if let (Some(from), Some(to)) =
(state.selected_from.take(), state.selected_to.clone())
{
state.mappings.retain(|m| !m.from.eq_ignore_ascii_case(&from));
state.mappings.push(Mapping { from, to });
}
}
Task::none()
}
Message::LayerTranslatorMapSame => {
use crate::modules::draw::layers::laytrans;
let i = self.active_tab;
let current = self.tabs[i].active_layer.clone();
let sources = laytrans::source_layers(&self.tabs[i].scene, &current);
if let Some(state) = self.layer_translator.as_mut() {
for mapping in laytrans::map_same(&sources, &state.targets) {
if !state
.mappings
.iter()
.any(|m| m.from.eq_ignore_ascii_case(&mapping.from))
{
state.mappings.push(mapping);
}
}
}
Task::none()
}
Message::LayerTranslatorUnmap(from) => {
if let Some(state) = self.layer_translator.as_mut() {
state.mappings.retain(|m| m.from != from);
}
Task::none()
}
Message::LayerTranslatorForceByLayer(value) => {
if let Some(state) = self.layer_translator.as_mut() {
state.force_bylayer = value;
}
Task::none()
}
Message::LayerTranslatorWriteLog(value) => {
if let Some(state) = self.layer_translator.as_mut() {
state.write_log = value;
}
Task::none()
}
Message::LayerTranslatorSaveMappings => Task::perform(
crate::io::pick_layer_mapping_path(true),
|path| match path {
Some(path) => Message::LayerTranslatorMappingsPath(path, true),
None => Message::Noop,
},
),
Message::LayerTranslatorLoadMappings => Task::perform(
crate::io::pick_layer_mapping_path(false),
|path| match path {
Some(path) => Message::LayerTranslatorMappingsPath(path, false),
None => Message::Noop,
},
),
Message::LayerTranslatorMappingsPath(path, save) => {
self.layer_translator_mappings_file(&path, save);
Task::none()
}
Message::LayerTranslatorTranslate => {
use crate::modules::draw::layers::laytrans;
let Some(state) = self.layer_translator.take() else {
return Task::none();
};
self.active_modal = None;
let i = self.active_tab;
let current = self.tabs[i].active_layer.clone();
self.push_undo_snapshot(i, "LAYTRANS");
let report = laytrans::translate(
&mut self.tabs[i].scene,
&state.mappings,
&state.targets,
&current,
laytrans::Options {
force_bylayer: state.force_bylayer,
},
);
let write_log = state.write_log;
let task = self.finish_layer_translation(i, report);
if write_log {
self.write_layer_translation_log(i);
}
task
}
Message::LayerStateManagerSelect(name) => {
self.load_layer_state_editor(Some(name));
Task::none()
+15
View File
@@ -19,6 +19,7 @@ impl OpenCADStudio {
Some(K::UpdateNotice) => crate::tr!("modal-update-available"),
Some(K::Layers) => crate::tr!("modal-layer-manager"),
Some(K::LayerStateManager) => crate::tr!("modal-layer-state-manager"),
Some(K::LayerTranslator) => crate::t!("Layer Translator").into_owned(),
Some(K::LayerStateEditor) => crate::tr!("modal-edit-layer-state"),
Some(K::Plot) => crate::tr!("modal-plot"),
Some(K::PrintAll) => t!("Print All").into_owned(),
@@ -169,6 +170,20 @@ impl OpenCADStudio {
|flow| tab.layers.view_window(self.layer_name_col_w, flow),
)
}
super::super::ModalKind::LayerTranslator => {
use crate::modules::draw::layers::laytrans;
let i = self.active_tab;
let current = self.tabs[i].active_layer.clone();
let sources = laytrans::source_layers(&self.tabs[i].scene, &current);
let state = self.layer_translator.as_ref()?;
sized_flow(ex, 760, 460, |flow| {
crate::ui::window::layer_translator::view_window(
state,
sources.clone(),
flow,
)
})
}
super::super::ModalKind::LayerStateManager => {
let states = self.tabs[self.active_tab].scene.document.layer_states();
sized_flow(
+34
View File
@@ -175,6 +175,40 @@ pub async fn pick_open_path() -> Option<(PathBuf, u64)> {
Some((path, size))
}
/// Pick the drawing whose layers become translation targets. A standards file
/// is a drawing too, so `.dws` and `.dwt` sit alongside the ordinary formats
/// rather than needing anything of their own. (#624)
pub async fn pick_layer_standard_path() -> Option<PathBuf> {
let handle = crate::sys::file_dialog()
.set_title("Load layer standard")
.add_filter(
"Drawings and standards",
&["dwg", "dws", "dwt", "dxf", "DWG", "DWS", "DWT", "DXF"],
)
.add_filter("All Files", &["*"])
.pick_file()
.await?;
Some(crate::sys::handle_path(&handle))
}
/// Pick where a set of layer mappings is written, or read back from.
pub async fn pick_layer_mapping_path(save: bool) -> Option<PathBuf> {
let dialog = crate::sys::file_dialog()
.set_title(if save {
"Save layer mappings"
} else {
"Load layer mappings"
})
.add_filter("Layer mappings", &["ocslmap"])
.add_filter("All Files", &["*"]);
let handle = if save {
dialog.set_file_name("layers.ocslmap").save_file().await?
} else {
dialog.pick_file().await?
};
Some(crate::sys::handle_path(&handle))
}
/// Load a CAD file from a known path. Parsing and cache building run on a
/// dedicated OS thread so the async executor stays free for rendering during
/// the load. Writes phase markers into `phase` so the UI can show
+226
View File
@@ -0,0 +1,226 @@
// Layer translation — map this drawing's layers onto a set taken from another
// drawing, then move everything across.
//
// Drawings arrive from outside with someone else's layer names. Translating
// them is three separate jobs that the command, the dialog and the saved
// mappings all share: reading a target set out of another file, deciding which
// source layer becomes which target, and performing the move. Only the third
// touches the drawing, and it is the same move LAYMRG makes for a single pair,
// so both go through `merge_layer`. (#624)
use std::collections::BTreeMap;
use std::path::Path;
use acadrust::tables::Layer;
use crate::scene::Scene;
/// A target layer read out of another drawing, with the properties the
/// translated layer should end up carrying.
#[derive(Clone, Debug)]
pub struct TargetLayer {
pub name: String,
pub layer: Layer,
}
/// One source layer and the target it becomes.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Mapping {
pub from: String,
pub to: String,
}
/// What a translation should do beyond moving the objects.
#[derive(Clone, Copy, Debug, Default)]
pub struct Options {
/// Reset each moved object's own colour and linetype to ByLayer, so the
/// target layer's properties are what actually shows. Objects that override
/// their layer would otherwise survive the translation looking unchanged.
pub force_bylayer: bool,
}
/// What a translation did, for the command line and the log.
#[derive(Clone, Debug, Default)]
pub struct Report {
/// Source layer, target layer, objects moved.
pub translated: Vec<(String, String, usize)>,
/// Mappings that could not run, with the reason.
pub skipped: Vec<(String, String)>,
}
impl Report {
pub fn objects(&self) -> usize {
self.translated.iter().map(|(_, _, n)| n).sum()
}
/// The transaction log AutoCAD writes beside the drawing, as text.
pub fn to_log(&self) -> String {
let mut out = String::new();
for (from, to, moved) in &self.translated {
out.push_str(&format!("{from} -> {to} ({moved} object(s))\n"));
}
for (layer, reason) in &self.skipped {
out.push_str(&format!("{layer}: skipped — {reason}\n"));
}
out
}
}
/// The layers another drawing defines, as translation targets.
///
/// Any drawing serves: a standards file, a template, or an already-correct
/// drawing. They are all the same format, so nothing here cares which.
pub fn load_targets(path: &Path) -> Result<Vec<TargetLayer>, String> {
let document = crate::io::load_file(path)?;
let mut targets: Vec<TargetLayer> = document
.layers
.iter()
// A layer the source drawing itself borrowed from a reference is not
// part of the standard it is offering.
.filter(|layer| !layer.name.contains('|') && !layer.flags.xref_dependent)
.map(|layer| TargetLayer {
name: layer.name.clone(),
layer: layer.clone(),
})
.collect();
targets.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
if targets.is_empty() {
return Err("no layers found in that file".into());
}
Ok(targets)
}
/// Layers of `scene` that a translation could move, current layer excluded —
/// the drawing has to keep drawing somewhere.
pub fn source_layers(scene: &Scene, current: &str) -> Vec<String> {
let mut names: Vec<String> = scene
.document
.layers
.names()
.filter(|name| *name != "0" && !name.eq_ignore_ascii_case(current))
.map(|name| name.to_string())
.collect();
names.sort_by_key(|name| name.to_lowercase());
names
}
/// Pair up every source layer that a target names as well.
///
/// This is the mapping most translations want and all of them start from: the
/// two drawings already agree on those names, so the only thing to settle is
/// the properties.
pub fn map_same(sources: &[String], targets: &[TargetLayer]) -> Vec<Mapping> {
let by_name: BTreeMap<String, &TargetLayer> = targets
.iter()
.map(|target| (target.name.to_lowercase(), target))
.collect();
sources
.iter()
.filter_map(|source| {
by_name.get(&source.to_lowercase()).map(|target| Mapping {
from: source.clone(),
to: target.name.clone(),
})
})
.collect()
}
/// Move everything on each mapped layer onto its target and drop the layer it
/// left. The target is created, with the properties the target drawing gave it,
/// when this drawing has no layer by that name.
pub fn translate(
scene: &mut Scene,
mappings: &[Mapping],
targets: &[TargetLayer],
current_layer: &str,
options: Options,
) -> Report {
let mut report = Report::default();
for mapping in mappings {
if mapping.from.eq_ignore_ascii_case(&mapping.to) {
continue;
}
if mapping.from == "0" {
report
.skipped
.push((mapping.from.clone(), "layer \"0\" cannot be translated".into()));
continue;
}
if mapping.from.eq_ignore_ascii_case(current_layer) {
report.skipped.push((
mapping.from.clone(),
"it is the current layer".into(),
));
continue;
}
if !scene.document.layers.contains(&mapping.from) {
report
.skipped
.push((mapping.from.clone(), "no such layer in this drawing".into()));
continue;
}
// Bring the target in with the standard's own properties. An existing
// layer of that name is left as it is: the drawing already has an
// opinion about it, and silently restyling it would reach past what the
// translation was asked to do.
if !scene.document.layers.contains(&mapping.to) {
let Some(target) = targets
.iter()
.find(|target| target.name.eq_ignore_ascii_case(&mapping.to))
else {
report
.skipped
.push((mapping.from.clone(), "target layer is not in the loaded set".into()));
continue;
};
let mut layer = target.layer.clone();
layer.name = target.name.clone();
layer.handle = scene.document.allocate_handle();
// Whatever reference the target drawing held it through does not
// come with it.
layer.flags.xref_dependent = false;
layer.xref_block_record_handle = acadrust::Handle::NULL;
let _ = scene.document.layers.add(layer);
}
let moved = merge_layer(scene, &mapping.from, &mapping.to, options.force_bylayer);
report
.translated
.push((mapping.from.clone(), mapping.to.clone(), moved));
}
if !report.translated.is_empty() {
let touched: Vec<String> = report
.translated
.iter()
.map(|(_, to, _)| to.clone())
.collect();
scene.invalidate_dependency_index();
scene.invalidate_layer_dependencies(&touched);
}
report
}
/// Move every object off `from` onto `to` and drop `from`. Returns how many
/// objects moved.
///
/// The entity list is flat and holds block definitions too, so objects inside
/// blocks travel with the rest rather than being left behind on a layer that no
/// longer exists.
pub fn merge_layer(scene: &mut Scene, from: &str, to: &str, force_bylayer: bool) -> usize {
let mut moved = 0usize;
for entity in scene.document.entities_mut() {
if entity.common().layer != from {
continue;
}
let common = entity.common_mut();
common.layer = to.to_string();
if force_bylayer {
common.color = acadrust::Color::ByLayer;
common.linetype = String::new();
common.linetype_handle = None;
common.line_weight = acadrust::LineWeight::ByLayer;
}
moved += 1;
}
scene.document.layers.remove(from);
moved
}
+1
View File
@@ -8,5 +8,6 @@ pub(super) mod laythw;
pub(super) mod layuniso;
pub(super) mod layulk;
pub(super) mod make_current;
pub mod laytrans;
pub mod match_layer;
pub(super) mod panel;
+221
View File
@@ -0,0 +1,221 @@
//! Layer Translator — map this drawing's layers onto a set loaded from another
//! drawing, then translate.
//!
//! Two lists side by side: what the drawing has, and what the loaded standard
//! offers. Selecting one of each and mapping them builds up the list of moves,
//! which is only applied when Translate is pressed — so a mapping can be
//! reconsidered, and a translation is one undo step rather than many.
use iced::widget::{button, checkbox, column, container, row, scrollable, text, Space};
use iced::{Background, Border, Element, Fill, Length, Theme};
use crate::app::Message;
use crate::modules::draw::layers::laytrans::{Mapping, TargetLayer};
use crate::t;
/// The dialog's working state: what was loaded, what is selected, and the
/// mappings built so far. None of it touches the drawing until Translate.
#[derive(Default)]
pub struct State {
/// Where the target set came from, shown so it is clear what is being
/// translated to.
pub source_file: String,
pub targets: Vec<TargetLayer>,
pub mappings: Vec<Mapping>,
pub selected_from: Option<String>,
pub selected_to: Option<String>,
pub force_bylayer: bool,
pub write_log: bool,
}
fn muted(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().background.base.text.scale_alpha(0.6)),
}
}
fn list_row<'a>(label: String, selected: bool, message: Message) -> Element<'a, Message> {
button(text(label).size(12).width(Fill))
.on_press(message)
.style(move |theme: &Theme, status| {
let palette = theme.palette();
let mut style = button::text(theme, status);
if selected {
style.background = Some(Background::Color(palette.primary.weak.color));
style.text_color = palette.primary.weak.text;
}
style.border = Border {
radius: 3.0.into(),
..Default::default()
};
style
})
.width(Fill)
.padding([4, 8])
.into()
}
fn pane<'a>(
title: String,
rows: Vec<Element<'a, Message>>,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
let body: Element<'_, Message> = if rows.is_empty() {
container(text(t!("(none)")).size(11).style(muted))
.padding(8)
.into()
} else {
scrollable(column(rows).spacing(1))
.height(sizing.height)
.into()
};
container(column![text(title).size(11).style(muted), body].spacing(4))
.padding(6)
.width(sizing.width)
.height(sizing.height)
.style(|theme: &Theme| container::Style {
border: Border {
width: 1.0,
radius: 4.0.into(),
color: theme.palette().background.strong.color,
},
..Default::default()
})
.into()
}
pub fn view_window<'a>(
state: &'a State,
sources: Vec<String>,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
// A layer already spoken for should not be offered again; mapping it twice
// could only mean the second one wins, which is not a choice worth making
// silently.
let mapped: Vec<&str> = state.mappings.iter().map(|m| m.from.as_str()).collect();
let from_rows: Vec<Element<'_, Message>> = sources
.iter()
.filter(|name| !mapped.contains(&name.as_str()))
.map(|name| {
list_row(
name.clone(),
state.selected_from.as_deref() == Some(name.as_str()),
Message::LayerTranslatorSelectFrom(name.clone()),
)
})
.collect();
let to_rows: Vec<Element<'_, Message>> = state
.targets
.iter()
.map(|target| {
list_row(
target.name.clone(),
state.selected_to.as_deref() == Some(target.name.as_str()),
Message::LayerTranslatorSelectTo(target.name.clone()),
)
})
.collect();
let mapping_rows: Vec<Element<'_, Message>> = state
.mappings
.iter()
.map(|mapping| {
row![
text(format!("{} → {}", mapping.from, mapping.to))
.size(12)
.width(Fill),
button(text(t!("Remove")).size(11))
.on_press(Message::LayerTranslatorUnmap(mapping.from.clone()))
.style(button::text)
.padding([2, 6]),
]
.align_y(iced::Center)
.padding([2, 6])
.into()
})
.collect();
let loaded: Element<'_, Message> = if state.source_file.is_empty() {
text(t!("No standard loaded.")).size(11).style(muted).into()
} else {
text(state.source_file.clone()).size(11).style(muted).into()
};
let can_map = state.selected_from.is_some() && state.selected_to.is_some();
let controls = row![
button(text(t!("Load…")).size(12))
.on_press(Message::LayerTranslatorLoad)
.padding([4, 10]),
button(text(t!("Map")).size(12))
.on_press_maybe(can_map.then_some(Message::LayerTranslatorMap))
.padding([4, 10]),
button(text(t!("Map same")).size(12))
.on_press_maybe(
(!state.targets.is_empty()).then_some(Message::LayerTranslatorMapSame)
)
.padding([4, 10]),
Space::new().width(Fill),
loaded,
]
.spacing(6)
.align_y(iced::Center);
let options = row![
checkbox(state.force_bylayer)
.on_toggle(Message::LayerTranslatorForceByLayer)
.size(14),
text(t!("Force objects to ByLayer")).size(11),
Space::new().width(Length::Fixed(10.0)),
checkbox(state.write_log)
.on_toggle(Message::LayerTranslatorWriteLog)
.size(14),
text(t!("Write translation log")).size(11),
]
.spacing(6)
.align_y(iced::Center);
let actions = row![
button(text(t!("Save mappings…")).size(12))
.on_press_maybe(
(!state.mappings.is_empty()).then_some(Message::LayerTranslatorSaveMappings)
)
.padding([4, 10]),
button(text(t!("Load mappings…")).size(12))
.on_press(Message::LayerTranslatorLoadMappings)
.padding([4, 10]),
Space::new().width(Fill),
button(text(t!("Cancel")).size(12))
.on_press(Message::CloseModal)
.padding([4, 12]),
button(text(t!("Translate")).size(12))
.on_press_maybe(
(!state.mappings.is_empty()).then_some(Message::LayerTranslatorTranslate)
)
.style(button::primary)
.padding([4, 12]),
]
.spacing(6)
.align_y(iced::Center);
let lists = row![
pane(t!("Translate from").into_owned(), from_rows, sizing),
pane(t!("Translate to").into_owned(), to_rows, sizing),
]
.spacing(8)
.height(sizing.height);
column![
controls,
lists,
pane(
t!("%{n} mapping(s)", n = state.mappings.len()).into_owned(),
mapping_rows,
sizing,
),
options,
actions,
]
.spacing(8)
.padding(10)
.width(sizing.width)
.into()
}
+1
View File
@@ -2,6 +2,7 @@ pub mod about;
pub mod block_palette;
pub mod layout_manager;
pub mod layer_state_manager;
pub mod layer_translator;
pub mod plot;
pub mod print_all;
pub mod plugin_manager;