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

+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;