forked from 1b-agent/opencadstudio-upstream
Merge pull request #1751 from ramox81/codex/adjust-parity
ADJUST: images and underlays, -IMAGEADJUST and PDF/DWF/DGNADJUST
This commit is contained in:
commit
f6fc244430
33 files changed
+903
-312
No files matched your search
@@ -507,6 +507,32 @@ impl OpenCADStudio {
|
||||
StepInput::SelectionComplete(kept)
|
||||
}
|
||||
|
||||
/// Keeps only the kinds of object the active command takes, dropping
|
||||
/// the rest of a completed selection silently.
|
||||
fn filter_command_entity_selection(&mut self, input: StepInput) -> StepInput {
|
||||
let StepInput::SelectionComplete(handles) = input else {
|
||||
return input;
|
||||
};
|
||||
let i = self.active_tab;
|
||||
let Some(keep) = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.and_then(|command| command.selection_entity_filter())
|
||||
else {
|
||||
return StepInput::SelectionComplete(handles);
|
||||
};
|
||||
let (kept, dropped): (Vec<Handle>, Vec<Handle>) = handles
|
||||
.into_iter()
|
||||
.partition(|h| self.tabs[i].scene.document.get_entity(*h).is_some_and(keep));
|
||||
for handle in &dropped {
|
||||
self.tabs[i].scene.deselect_entity(*handle);
|
||||
}
|
||||
if !dropped.is_empty() {
|
||||
self.refresh_properties();
|
||||
}
|
||||
StepInput::SelectionComplete(kept)
|
||||
}
|
||||
|
||||
/// Drops locked point clouds from a completed selection when the active
|
||||
/// command would move them, saying which were locked and how many went.
|
||||
fn filter_locked_point_cloud_selection(&mut self, input: StepInput) -> StepInput {
|
||||
@@ -562,6 +588,7 @@ impl OpenCADStudio {
|
||||
let input = self.filter_associative_dimension_selection(input);
|
||||
let input = self.filter_block_reference_selection(input);
|
||||
let input = self.filter_locked_point_cloud_selection(input);
|
||||
let input = self.filter_command_entity_selection(input);
|
||||
// XCLIP / CLIP learn which chosen references carry a clip already.
|
||||
{
|
||||
let i = self.active_tab;
|
||||
|
||||
+130
-91
@@ -1391,101 +1391,120 @@ impl OpenCADStudio {
|
||||
return Some(Task::none());
|
||||
}
|
||||
|
||||
// ADJUST — set brightness / contrast / fade on selected raster images
|
||||
// ADJUST BRIGHTNESS|CONTRAST|FADE <0-100>
|
||||
"ADJUST" => {
|
||||
use crate::command::SelectThenKeywordCommand;
|
||||
let has_sel = !self.tabs[i].scene.selected_entities().is_empty();
|
||||
let c = SelectThenKeywordCommand::new(
|
||||
"ADJUST",
|
||||
"ADJUST [Brightness / Contrast / Fade]:",
|
||||
vec![
|
||||
(
|
||||
"Brightness",
|
||||
"BRIGHTNESS",
|
||||
Some("ADJUST brightness 0-100:"),
|
||||
),
|
||||
("Contrast", "CONTRAST", Some("ADJUST contrast 0-100:")),
|
||||
("Fade", "FADE", Some("ADJUST fade 0-100:")),
|
||||
],
|
||||
has_sel,
|
||||
);
|
||||
self.command_line.push_info(&c.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(c));
|
||||
}
|
||||
cmd if cmd.starts_with("ADJUST ") => {
|
||||
let rest = cmd.trim_start_matches("ADJUST").trim();
|
||||
let parts: Vec<&str> = rest.splitn(2, char::is_whitespace).collect();
|
||||
let action = parts.first().map(|s| s.to_uppercase()).unwrap_or_default();
|
||||
let arg = parts.get(1).copied().unwrap_or("").trim();
|
||||
let handles: Vec<codec::Handle> = self.tabs[i]
|
||||
// ADJUST / -IMAGEADJUST / PDFADJUST / DWFADJUST / DGNADJUST:
|
||||
// chosen images or underlays (pickfirst first), then the option
|
||||
// and the value.
|
||||
"ADJUST" | "-IMAGEADJUST" | "PDFADJUST" | "DWFADJUST" | "DGNADJUST" => {
|
||||
let mode = match cmd {
|
||||
"-IMAGEADJUST" => "I",
|
||||
"PDFADJUST" => "P",
|
||||
"DWFADJUST" => "W",
|
||||
"DGNADJUST" => "G",
|
||||
_ => "A",
|
||||
};
|
||||
let keep = crate::modules::insert::xadjust::keeps(mode);
|
||||
let chosen: Vec<codec::Handle> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.iter()
|
||||
.map(|(h, _)| *h)
|
||||
.filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle))
|
||||
.into_iter()
|
||||
.filter(|(_, e)| keep(e))
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
self.command_line
|
||||
.push_error(crate::t!("ADJUST: select raster image(s) first.").as_ref());
|
||||
} else if action.is_empty() {
|
||||
self.command_line.push_info(
|
||||
crate::t!("Usage: ADJUST BRIGHTNESS|CONTRAST|FADE <0-100>").as_ref(),
|
||||
);
|
||||
} else if let Ok(v) = arg.parse::<u8>() {
|
||||
let v = v.min(100);
|
||||
self.push_undo_snapshot(i, "ADJUST");
|
||||
let mut changed = 0usize;
|
||||
let mut changed_handles = Vec::new();
|
||||
for h in &handles {
|
||||
if let Some(codec::EntityType::RasterImage(img)) = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.entities_mut()
|
||||
.find(|e| e.common().handle == *h)
|
||||
{
|
||||
match action.as_str() {
|
||||
"BRIGHTNESS" => {
|
||||
img.brightness = v;
|
||||
changed += 1;
|
||||
changed_handles.push(*h);
|
||||
}
|
||||
"CONTRAST" => {
|
||||
img.contrast = v;
|
||||
changed += 1;
|
||||
changed_handles.push(*h);
|
||||
}
|
||||
"FADE" => {
|
||||
img.fade = v;
|
||||
changed += 1;
|
||||
changed_handles.push(*h);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed > 0 {
|
||||
self.tabs[i].dirty = true;
|
||||
for &handle in &changed_handles {
|
||||
self.tabs[i].scene.reseed_derived_caches(handle);
|
||||
}
|
||||
let changes: Vec<_> = changed_handles
|
||||
.into_iter()
|
||||
.map(|handle| (handle, crate::scene::ChangeKind::Modified))
|
||||
.collect();
|
||||
self.tabs[i].scene.bump_entities(&changes);
|
||||
self.command_line.push_output(
|
||||
crate::tf!("ADJUST: {action} = {v} on {changed} image(s).").as_ref(),
|
||||
);
|
||||
} else {
|
||||
self.command_line.push_error(
|
||||
crate::t!("ADJUST: no raster images selected, or unknown property (use BRIGHTNESS|CONTRAST|FADE).").as_ref(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.command_line
|
||||
.push_error(crate::t!("ADJUST: value must be 0-100.").as_ref());
|
||||
if !chosen.is_empty() {
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.replace_selection(chosen.into_iter().collect());
|
||||
return Some(self.dispatch_command(&format!("_ADJUSTSEL {mode}")));
|
||||
}
|
||||
self.start_adjust_selection(i, cmd, mode);
|
||||
}
|
||||
// The gathered objects: one kind at a time, then the option.
|
||||
c if c.starts_with("_ADJUSTSEL ") => {
|
||||
use crate::command::CadCommand;
|
||||
use crate::modules::insert::xadjust::{self, AdjustCommand};
|
||||
let mode = c["_ADJUSTSEL ".len()..].trim().to_string();
|
||||
let keep = xadjust::keeps(&mode);
|
||||
let name = xadjust::command_name(&mode);
|
||||
let objects: Vec<(codec::Handle, codec::EntityType)> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.filter(|(h, e)| keep(e) && !self.tabs[i].scene.is_layer_locked(*h))
|
||||
.map(|(h, e)| (h, e.clone()))
|
||||
.collect();
|
||||
match AdjustCommand::for_objects(&objects) {
|
||||
None => {}
|
||||
Some(Err(message)) => {
|
||||
self.command_line.push_error(crate::t!(message).as_ref());
|
||||
self.start_adjust_selection(i, name, &mode);
|
||||
}
|
||||
Some(Ok(command)) => {
|
||||
self.command_line.push_info(&command.prompt());
|
||||
self.command_line.set_step_options(command.options());
|
||||
self.tabs[i].active_cmd = Some(Box::new(command));
|
||||
}
|
||||
}
|
||||
// Repeating runs the command, not this step.
|
||||
return Some(self.finish_dispatch(name));
|
||||
}
|
||||
// `_ADJUSTAPPLY <BRIGHTNESS|CONTRAST|FADE|MONOCHROME> <value> <handles…>`
|
||||
// writes the value to every listed image or underlay, one undo step.
|
||||
c if c.starts_with("_ADJUSTAPPLY ") => {
|
||||
let mut parts = c.split_whitespace().skip(1);
|
||||
let prop = parts.next().unwrap_or("").to_string();
|
||||
let value: u8 = parts.next().and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
let handles: Vec<codec::Handle> = parts
|
||||
.filter_map(|h| u64::from_str_radix(h, 16).ok())
|
||||
.map(codec::Handle::new)
|
||||
.collect();
|
||||
self.push_undo_snapshot(i, "ADJUST");
|
||||
let mut changed = Vec::new();
|
||||
for h in &handles {
|
||||
let done = match self.tabs[i].scene.document.get_entity_mut(*h) {
|
||||
Some(codec::EntityType::RasterImage(img)) => match prop.as_str() {
|
||||
"BRIGHTNESS" => {
|
||||
img.brightness = value;
|
||||
true
|
||||
}
|
||||
"CONTRAST" => {
|
||||
img.contrast = value;
|
||||
true
|
||||
}
|
||||
"FADE" => {
|
||||
img.fade = value;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
Some(codec::EntityType::Underlay(u)) => match prop.as_str() {
|
||||
"CONTRAST" => {
|
||||
u.set_contrast(value);
|
||||
true
|
||||
}
|
||||
"FADE" => {
|
||||
u.set_fade(value);
|
||||
true
|
||||
}
|
||||
"MONOCHROME" => {
|
||||
u.set_monochrome(value != 0);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
};
|
||||
if done {
|
||||
self.tabs[i].scene.reseed_derived_caches(*h);
|
||||
changed.push((*h, crate::scene::ChangeKind::Modified));
|
||||
}
|
||||
}
|
||||
if !changed.is_empty() {
|
||||
self.tabs[i].scene.bump_entities(&changed);
|
||||
self.tabs[i].dirty = true;
|
||||
}
|
||||
// The command used the set up; nothing stays chosen.
|
||||
self.tabs[i].scene.deselect_all();
|
||||
self.refresh_properties();
|
||||
}
|
||||
|
||||
// ANNOSCALE / CANNOSCALE <ratio> — set the current annotation scale
|
||||
@@ -2216,6 +2235,26 @@ pub(crate) fn table_to_csv(table: &codec::entities::Table) -> String {
|
||||
.join("\r\n")
|
||||
}
|
||||
|
||||
impl OpenCADStudio {
|
||||
/// The ADJUST family's object prompt, keeping only the kinds `mode`
|
||||
/// takes (see `xadjust::keeps`).
|
||||
fn start_adjust_selection(&mut self, i: usize, name: &str, mode: &str) {
|
||||
use crate::command::CadCommand;
|
||||
use crate::modules::insert::xadjust;
|
||||
self.tabs[i].scene.deselect_all();
|
||||
self.refresh_properties();
|
||||
let command = crate::modules::draw::select::SelectObjectsCommand::filtered(
|
||||
name,
|
||||
&format!("_ADJUSTSEL {mode}"),
|
||||
xadjust::select_prompt(mode),
|
||||
xadjust::keeps(mode),
|
||||
);
|
||||
self.command_line.push_info(&command.prompt());
|
||||
self.command_line.set_step_options(command.options());
|
||||
self.tabs[i].active_cmd = Some(Box::new(command));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::OpenCADStudio;
|
||||
|
||||
@@ -3911,7 +3911,9 @@ impl OpenCADStudio {
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
if field.starts_with("ul_") {
|
||||
if field.starts_with("ul_")
|
||||
|| matches!(field, "ri_bright" | "ri_contrast" | "ri_fade")
|
||||
{
|
||||
if let Err(message) = crate::entities::underlay::validate_property(field, &val)
|
||||
{
|
||||
self.command_line.push_error(message);
|
||||
|
||||
@@ -2983,6 +2983,12 @@ pub trait CadCommand: Send {
|
||||
false
|
||||
}
|
||||
|
||||
/// The kinds of object the command takes (ADJUST): the host drops the
|
||||
/// rest of each completed selection without a word.
|
||||
fn selection_entity_filter(&self) -> Option<fn(&EntityType) -> bool> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The command takes block references only (XCLIP): the host drops the
|
||||
/// rest and says how many were ineligible.
|
||||
fn selection_keeps_block_references(&self) -> bool {
|
||||
|
||||
@@ -333,7 +333,20 @@ impl PropertyEditable for RasterImage {
|
||||
.contains(codec::entities::ImageDisplayFlags::USE_CLIPPING_BOUNDARY);
|
||||
let clip_inverted = self.clip_boundary.clip_mode == codec::entities::ClipMode::Inside;
|
||||
let transparency = self.common.transparency.to_string();
|
||||
let whole = |label: &str, field: &'static str, value: u8| Property {
|
||||
label: t!(label).into_owned(),
|
||||
field,
|
||||
value: PropValue::EditText(value.to_string()),
|
||||
};
|
||||
vec![
|
||||
PropSection {
|
||||
title: t!("Image Adjust").into_owned(),
|
||||
props: vec![
|
||||
whole("Brightness", "ri_bright", self.brightness),
|
||||
whole("Contrast", "ri_contrast", self.contrast),
|
||||
whole("Fade", "ri_fade", self.fade),
|
||||
],
|
||||
},
|
||||
PropSection {
|
||||
title: t!("Geometry").into_owned(),
|
||||
props: vec![
|
||||
@@ -362,13 +375,6 @@ impl PropertyEditable for RasterImage {
|
||||
title: t!("Misc").into_owned(),
|
||||
props: vec![
|
||||
ro(t!("Name").as_ref(), "ri_name", self.file_name().to_string()),
|
||||
edit(
|
||||
t!("Brightness").as_ref(),
|
||||
"ri_bright",
|
||||
self.brightness as f64,
|
||||
),
|
||||
edit(t!("Contrast").as_ref(), "ri_contrast", self.contrast as f64),
|
||||
edit(t!("Fade").as_ref(), "ri_fade", self.fade as f64),
|
||||
ro(t!("Transparency").as_ref(), "ri_transparency", transparency),
|
||||
Property {
|
||||
label: t!("Show image").into_owned(),
|
||||
@@ -449,9 +455,10 @@ impl PropertyEditable for RasterImage {
|
||||
"ri_ox" => self.insertion_point.x = v,
|
||||
"ri_oy" => self.insertion_point.y = v,
|
||||
"ri_oz" => self.insertion_point.z = v,
|
||||
"ri_bright" => self.brightness = v.clamp(0.0, 100.0) as u8,
|
||||
"ri_contrast" => self.contrast = v.clamp(0.0, 100.0) as u8,
|
||||
"ri_fade" => self.fade = v.clamp(0.0, 100.0) as u8,
|
||||
// Out-of-range values are refused before they get here.
|
||||
"ri_bright" if (0.0..=100.0).contains(&v) => self.brightness = v as u8,
|
||||
"ri_contrast" if (0.0..=100.0).contains(&v) => self.contrast = v as u8,
|
||||
"ri_fade" if (0.0..=100.0).contains(&v) => self.fade = v as u8,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +227,8 @@ fn parse_flag(value: &str, current: bool) -> bool {
|
||||
pub(crate) fn validate_property(field: &str, value: &str) -> Result<(), &'static str> {
|
||||
let number = || value.trim().parse::<f64>().ok();
|
||||
match field {
|
||||
"ul_contrast" | "ul_fade" => match number() {
|
||||
// A raster image's Image Adjust rows take the same range.
|
||||
"ul_contrast" | "ul_fade" | "ri_bright" | "ri_contrast" | "ri_fade" => match number() {
|
||||
Some(v) if (0.0..=100.0).contains(&v) && v.fract() == 0.0 => Ok(()),
|
||||
_ => Err("Value must be an integer between 0 and 100"),
|
||||
},
|
||||
|
||||
+12
-9
@@ -557,13 +557,6 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
|
||||
"ACI:" => Some(("styles", "aci")),
|
||||
"ADDSELECTED: creating a new {kind} is not supported." => Some(("inquiry", "addselected-creating-a-new-kind-is-not-support")),
|
||||
"ADDSELECTED: selected object not found." => Some(("inquiry", "addselected-selected-object-not-found")),
|
||||
"ADJUST [Brightness / Contrast / Fade]:" => Some(("view", "adjust-brightness-contrast-fade")),
|
||||
"ADJUST brightness 0-100:" => Some(("view", "adjust-brightness-0-100")),
|
||||
"ADJUST contrast 0-100:" => Some(("view", "adjust-contrast-0-100")),
|
||||
"ADJUST fade 0-100:" => Some(("view", "adjust-fade-0-100")),
|
||||
"ADJUST: select raster image(s) first." => Some(("view", "adjust-select-raster-image-s-first")),
|
||||
"ADJUST: value must be 0-100." => Some(("view", "adjust-value-must-be-0-100")),
|
||||
"ADJUST: {action} = {v} on {changed} image(s)." => Some(("view", "adjust-action-v-on-changed-image-s")),
|
||||
"ALIGN Scale objects based on alignment points? [Y/N]:" => Some(("modify", "align-scale-objects-based-on-alignment-points")),
|
||||
"ALIGN Select objects (%{count} selected, Enter when done):" => Some(("modify", "align-select-objects-count-selected-enter-when")),
|
||||
"ALIGN Specify 1st destination point:" => Some(("modify", "align-specify-1st-destination-point")),
|
||||
@@ -3526,7 +3519,6 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
|
||||
"Updated {changed} underlay(s)." => Some(("view", "updated-changed-underlay-s")),
|
||||
"Upper" => Some(("annotate", "upper")),
|
||||
"Upside down" => Some(("common", "upside-down")),
|
||||
"Usage: ADJUST BRIGHTNESS|CONTRAST|FADE <0-100>" => Some(("view", "usage-adjust-brightness-contrast-fade-0-100")),
|
||||
"Usage: ANNOSCALE <ratio> e.g. 1:50, 2:1, or a factor" => Some(("view", "usage-annoscale-ratio-e-g-1-50-2-1-or-a-factor")),
|
||||
"Usage: ATTDISP ON | OFF | NORMAL" => Some(("draw", "usage-attdisp-on-off-normal")),
|
||||
"Usage: ATTSYNC <block name>. Blocks: {}" => Some(("insert", "usage-attsync-block-name-blocks")),
|
||||
@@ -3837,6 +3829,18 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
|
||||
"Attach DWG" => Some(("insert", "xref-attach-dwg")),
|
||||
"Attach Image" => Some(("insert", "xref-attach-image")),
|
||||
"Select image(s):" => Some(("insert", "select-images")),
|
||||
"Select image or underlay:" => Some(("insert", "adjust-select-image-or-underlay")),
|
||||
"Select PDF underlay:" => Some(("insert", "adjust-select-pdf-underlay")),
|
||||
"Select DWF underlay:" => Some(("insert", "adjust-select-dwf-underlay")),
|
||||
"Select DGN underlay:" => Some(("insert", "adjust-select-dgn-underlay")),
|
||||
"*Invalid selection set. Select underlay objects of the same file type only.*" => Some(("insert", "adjust-mixed-underlays")),
|
||||
"Enter image option [Contrast/Fade/Brightness] <Brightness>:" => Some(("insert", "adjust-image-option")),
|
||||
"Enter %{kind} underlay option [Fade/Contrast/Monochrome] <%{option}>:" => Some(("insert", "adjust-underlay-option")),
|
||||
"Monochrome? [Yes/No] <%{value}>:" => Some(("insert", "adjust-monochrome")),
|
||||
"Enter brightness value (0-100) <%{value}>:" => Some(("insert", "adjust-brightness-value")),
|
||||
"Enter contrast value (0-100) <%{value}>:" => Some(("insert", "adjust-contrast-value")),
|
||||
"Enter fade value (0-100) <%{value}>:" => Some(("insert", "adjust-fade-value")),
|
||||
"Value must be an integer between 0 and 100." => Some(("insert", "adjust-value-integer-0-100")),
|
||||
"Enter transparency mode [ON/OFF] <ON>:" => Some(("insert", "transparency-mode-on")),
|
||||
"Enter transparency mode [ON/OFF] <OFF>:" => Some(("insert", "transparency-mode-off")),
|
||||
"Attach..." => Some(("insert", "xref-attach-menu")),
|
||||
@@ -4217,7 +4221,6 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
|
||||
"ISOPLANE: expected Left, Top, or Right." => Some(("common", "isoplane-expected-left-top-or-right")),
|
||||
"ISODRAFT: expected On or Off." => Some(("common", "isodraft-expected-on-or-off")),
|
||||
"XDATA: selected entities are on locked layers." => Some(("common", "xdata-selected-entities-are-on-locked-layers")),
|
||||
"ADJUST: no raster images selected, or unknown property (use BRIGHTNESS|CONTRAST|FADE)." => Some(("common", "adjust-no-raster-images-selected-or-unknown-property-use-brightness-contrast-fade")),
|
||||
"ANNOAUTOSCALE: enter an integer from -4 through 4." => Some(("common", "annoautoscale-enter-an-integer-from-4-through-4")),
|
||||
"Usage: DATALINK <path-to-.csv>" => Some(("common", "usage-datalink-path-to-csv")),
|
||||
"DATALINKUPDATE: no linked tables found." => Some(("common", "datalinkupdate-no-linked-tables-found")),
|
||||
|
||||
@@ -41,6 +41,9 @@ pub struct SelectObjectsCommand {
|
||||
/// The command works on associative dimensions only; the host drops
|
||||
/// everything else from each completed selection.
|
||||
associative_dimensions_only: bool,
|
||||
/// The kinds of object the command takes; the host drops the rest of
|
||||
/// each completed selection silently, and Enter on nothing ends quietly.
|
||||
keep: Option<fn(&codec::EntityType) -> bool>,
|
||||
}
|
||||
|
||||
impl SelectObjectsCommand {
|
||||
@@ -57,6 +60,7 @@ impl SelectObjectsCommand {
|
||||
auto_constrain_settings: false,
|
||||
prompt_text: None,
|
||||
associative_dimensions_only: false,
|
||||
keep: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +78,7 @@ impl SelectObjectsCommand {
|
||||
auto_constrain_settings: false,
|
||||
prompt_text: None,
|
||||
associative_dimensions_only: false,
|
||||
keep: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +96,7 @@ impl SelectObjectsCommand {
|
||||
auto_constrain_settings: false,
|
||||
prompt_text: None,
|
||||
associative_dimensions_only: false,
|
||||
keep: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +114,7 @@ impl SelectObjectsCommand {
|
||||
auto_constrain_settings: false,
|
||||
prompt_text: None,
|
||||
associative_dimensions_only: false,
|
||||
keep: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +138,18 @@ impl SelectObjectsCommand {
|
||||
command
|
||||
}
|
||||
|
||||
/// The command's own wording, keeping only what `keep` accepts.
|
||||
pub fn filtered(
|
||||
prompt_cmd: &str,
|
||||
pending_cmd: &str,
|
||||
prompt_text: &'static str,
|
||||
keep: fn(&codec::EntityType) -> bool,
|
||||
) -> Self {
|
||||
let mut command = Self::with_prompt(prompt_cmd, pending_cmd, prompt_text);
|
||||
command.keep = Some(keep);
|
||||
command
|
||||
}
|
||||
|
||||
pub fn auto_constrain(pending_cmd: &str) -> Self {
|
||||
let mut command = Self::new(pending_cmd);
|
||||
command.auto_constrain_settings = true;
|
||||
@@ -203,6 +222,10 @@ impl CadCommand for SelectObjectsCommand {
|
||||
self.associative_dimensions_only
|
||||
}
|
||||
|
||||
fn selection_entity_filter(&self) -> Option<fn(&codec::EntityType) -> bool> {
|
||||
self.keep
|
||||
}
|
||||
|
||||
fn selection_drops_locked_point_clouds(&self) -> bool {
|
||||
matches!(
|
||||
self.pending_cmd.as_str(),
|
||||
@@ -320,6 +343,9 @@ impl CadCommand for SelectObjectsCommand {
|
||||
}
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
if self.keep.is_some() && self.handles.is_empty() {
|
||||
return CmdResult::Relaunch(std::mem::take(&mut self.pending_cmd), Vec::new());
|
||||
}
|
||||
if !self.commit_on_enter || self.handles.is_empty() {
|
||||
return CmdResult::Cancel;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ mod snap_underlays;
|
||||
pub(crate) mod solid3d_cmds;
|
||||
mod underlay_layers;
|
||||
pub(crate) mod wblock;
|
||||
mod xadjust;
|
||||
pub(crate) mod xadjust;
|
||||
pub(crate) mod xattach;
|
||||
pub(crate) mod xclip;
|
||||
pub(crate) mod xref_cmd;
|
||||
|
||||
@@ -1,4 +1,30 @@
|
||||
// ADJUST / -IMAGEADJUST / PDFADJUST / DWFADJUST / DGNADJUST — brightness,
|
||||
// contrast and fade of raster images; fade, contrast and monochrome of
|
||||
// PDF, DWF and DGN underlays.
|
||||
//
|
||||
// Select image or underlay:
|
||||
// Enter image option [Contrast/Fade/Brightness] <Brightness>:
|
||||
// Enter brightness value (0-100) <50>:
|
||||
//
|
||||
// Enter PDF underlay option [Fade/Contrast/Monochrome] <Fade>:
|
||||
// Enter fade value (0-100) <25>:
|
||||
// Monochrome? [Yes/No] <No>:
|
||||
//
|
||||
// An image value prompt offers the first image's own value. The underlay
|
||||
// option and the fade / contrast values offered are the last ones used in
|
||||
// the session, whatever the underlay kind. The host applies the value to
|
||||
// every chosen object as one undo step.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use codec::entities::{UnderlayDisplayFlags, UnderlayType};
|
||||
use codec::types::Handle;
|
||||
use codec::EntityType;
|
||||
|
||||
use crate::command::{CadCommand, CmdOption, CmdResult, InputKind};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use crate::t;
|
||||
|
||||
pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/xadjust.svg"));
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
@@ -8,3 +34,330 @@ pub fn tool() -> ToolDef {
|
||||
event: ModuleEvent::Command("ADJUST".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a command takes: `A` images or underlays, `I` images, `P` / `W` /
|
||||
/// `G` PDF / DWF / DGN underlays.
|
||||
pub fn keeps(mode: &str) -> fn(&EntityType) -> bool {
|
||||
fn any(e: &EntityType) -> bool {
|
||||
matches!(e, EntityType::RasterImage(_) | EntityType::Underlay(_))
|
||||
}
|
||||
fn image(e: &EntityType) -> bool {
|
||||
matches!(e, EntityType::RasterImage(_))
|
||||
}
|
||||
fn pdf(e: &EntityType) -> bool {
|
||||
matches!(e, EntityType::Underlay(u) if u.underlay_type == UnderlayType::Pdf)
|
||||
}
|
||||
fn dwf(e: &EntityType) -> bool {
|
||||
matches!(e, EntityType::Underlay(u) if u.underlay_type == UnderlayType::Dwf)
|
||||
}
|
||||
fn dgn(e: &EntityType) -> bool {
|
||||
matches!(e, EntityType::Underlay(u) if u.underlay_type == UnderlayType::Dgn)
|
||||
}
|
||||
match mode {
|
||||
"I" => image,
|
||||
"P" => pdf,
|
||||
"W" => dwf,
|
||||
"G" => dgn,
|
||||
_ => any,
|
||||
}
|
||||
}
|
||||
|
||||
/// The command each mode belongs to.
|
||||
pub fn command_name(mode: &str) -> &'static str {
|
||||
match mode {
|
||||
"I" => "-IMAGEADJUST",
|
||||
"P" => "PDFADJUST",
|
||||
"W" => "DWFADJUST",
|
||||
"G" => "DGNADJUST",
|
||||
_ => "ADJUST",
|
||||
}
|
||||
}
|
||||
|
||||
/// The selection prompt of each command.
|
||||
pub fn select_prompt(mode: &str) -> &'static str {
|
||||
match mode {
|
||||
"I" => "Select image(s):",
|
||||
"P" => "Select PDF underlay:",
|
||||
"W" => "Select DWF underlay:",
|
||||
"G" => "Select DGN underlay:",
|
||||
_ => "Select image or underlay:",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Prop {
|
||||
Brightness,
|
||||
Contrast,
|
||||
Fade,
|
||||
Monochrome,
|
||||
}
|
||||
|
||||
impl Prop {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Prop::Brightness => "Brightness",
|
||||
Prop::Contrast => "Contrast",
|
||||
Prop::Fade => "Fade",
|
||||
Prop::Monochrome => "Monochrome",
|
||||
}
|
||||
}
|
||||
fn key(self) -> &'static str {
|
||||
match self {
|
||||
Prop::Brightness => "BRIGHTNESS",
|
||||
Prop::Contrast => "CONTRAST",
|
||||
Prop::Fade => "FADE",
|
||||
Prop::Monochrome => "MONOCHROME",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const IMAGE_OPTIONS: [Prop; 3] = [Prop::Contrast, Prop::Fade, Prop::Brightness];
|
||||
const UNDERLAY_OPTIONS: [Prop; 3] = [Prop::Fade, Prop::Contrast, Prop::Monochrome];
|
||||
|
||||
/// The underlay option, fade and contrast last used in the session.
|
||||
static LAST_UNDERLAY: Mutex<(usize, u8, u8)> = Mutex::new((0, 25, 75));
|
||||
|
||||
fn last_underlay() -> (usize, u8, u8) {
|
||||
LAST_UNDERLAY.lock().map(|v| *v).unwrap_or((0, 25, 75))
|
||||
}
|
||||
|
||||
enum Subject {
|
||||
Image {
|
||||
brightness: u8,
|
||||
contrast: u8,
|
||||
fade: u8,
|
||||
},
|
||||
Underlay {
|
||||
kind: &'static str,
|
||||
monochrome: bool,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct AdjustCommand {
|
||||
handles: Vec<Handle>,
|
||||
subject: Subject,
|
||||
prop: Option<Prop>,
|
||||
}
|
||||
|
||||
/// Why a chosen set cannot be adjusted together.
|
||||
pub const MIXED: &str =
|
||||
"*Invalid selection set. Select underlay objects of the same file type only.*";
|
||||
|
||||
impl AdjustCommand {
|
||||
/// The command for the chosen objects (the first one supplies the image
|
||||
/// values and the monochrome state), or the reason they do not go
|
||||
/// together. `None` when nothing adjustable was chosen.
|
||||
pub fn for_objects(objects: &[(Handle, EntityType)]) -> Option<Result<Self, &'static str>> {
|
||||
let (_, first) = objects.first()?;
|
||||
let kind = |e: &EntityType| match e {
|
||||
EntityType::Underlay(u) => Some(u.underlay_type),
|
||||
_ => None,
|
||||
};
|
||||
if objects.iter().any(|(_, e)| kind(e) != kind(first)) {
|
||||
return Some(Err(MIXED));
|
||||
}
|
||||
let subject = match first {
|
||||
EntityType::RasterImage(img) => Subject::Image {
|
||||
brightness: img.brightness,
|
||||
contrast: img.contrast,
|
||||
fade: img.fade,
|
||||
},
|
||||
EntityType::Underlay(u) => Subject::Underlay {
|
||||
kind: match u.underlay_type {
|
||||
UnderlayType::Pdf => "PDF",
|
||||
UnderlayType::Dwf => "DWF",
|
||||
UnderlayType::Dgn => "DGN",
|
||||
},
|
||||
monochrome: u.flags.contains(UnderlayDisplayFlags::MONOCHROME),
|
||||
},
|
||||
_ => return None,
|
||||
};
|
||||
Some(Ok(Self {
|
||||
handles: objects.iter().map(|(h, _)| *h).collect(),
|
||||
subject,
|
||||
prop: None,
|
||||
}))
|
||||
}
|
||||
|
||||
fn options_list(&self) -> &'static [Prop; 3] {
|
||||
match self.subject {
|
||||
Subject::Image { .. } => &IMAGE_OPTIONS,
|
||||
Subject::Underlay { .. } => &UNDERLAY_OPTIONS,
|
||||
}
|
||||
}
|
||||
|
||||
fn default_option(&self) -> Prop {
|
||||
match self.subject {
|
||||
Subject::Image { .. } => Prop::Brightness,
|
||||
Subject::Underlay { .. } => UNDERLAY_OPTIONS[last_underlay().0.min(2)],
|
||||
}
|
||||
}
|
||||
|
||||
fn default_value(&self, prop: Prop) -> u8 {
|
||||
match (&self.subject, prop) {
|
||||
(Subject::Image { brightness, .. }, Prop::Brightness) => *brightness,
|
||||
(Subject::Image { contrast, .. }, Prop::Contrast) => *contrast,
|
||||
(Subject::Image { fade, .. }, _) => *fade,
|
||||
(Subject::Underlay { .. }, Prop::Contrast) => last_underlay().2,
|
||||
(Subject::Underlay { .. }, _) => last_underlay().1,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(&self, prop: Prop, value: u8) -> CmdResult {
|
||||
if let Subject::Underlay { .. } = self.subject {
|
||||
if let Ok(mut last) = LAST_UNDERLAY.lock() {
|
||||
last.0 = UNDERLAY_OPTIONS.iter().position(|p| *p == prop).unwrap_or(0);
|
||||
match prop {
|
||||
Prop::Fade => last.1 = value,
|
||||
Prop::Contrast => last.2 = value,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let handles: Vec<String> = self
|
||||
.handles
|
||||
.iter()
|
||||
.map(|h| format!("{:X}", h.value()))
|
||||
.collect();
|
||||
CmdResult::Dispatch(format!(
|
||||
"_ADJUSTAPPLY {} {} {}",
|
||||
prop.key(),
|
||||
value,
|
||||
handles.join(" ")
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `text` names `label` (its key letter or a longer prefix, in
|
||||
/// English or in the interface language).
|
||||
fn names(text: &str, label: &str) -> bool {
|
||||
let english = label.to_uppercase();
|
||||
let local = t!(label).to_uppercase();
|
||||
!text.is_empty() && (english.starts_with(text) || local.starts_with(text))
|
||||
}
|
||||
|
||||
impl CadCommand for AdjustCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"ADJUST"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match (self.prop, &self.subject) {
|
||||
(None, Subject::Image { .. }) => {
|
||||
t!("Enter image option [Contrast/Fade/Brightness] <Brightness>:").into_owned()
|
||||
}
|
||||
(None, Subject::Underlay { kind, .. }) => t!(
|
||||
"Enter %{kind} underlay option [Fade/Contrast/Monochrome] <%{option}>:",
|
||||
kind = kind,
|
||||
option = t!(self.default_option().label())
|
||||
)
|
||||
.into_owned(),
|
||||
(Some(Prop::Monochrome), Subject::Underlay { monochrome, .. }) => t!(
|
||||
"Monochrome? [Yes/No] <%{value}>:",
|
||||
value = if *monochrome { t!("Yes") } else { t!("No") }
|
||||
)
|
||||
.into_owned(),
|
||||
(Some(Prop::Brightness), _) => t!(
|
||||
"Enter brightness value (0-100) <%{value}>:",
|
||||
value = self.default_value(Prop::Brightness)
|
||||
)
|
||||
.into_owned(),
|
||||
(Some(Prop::Contrast), _) => t!(
|
||||
"Enter contrast value (0-100) <%{value}>:",
|
||||
value = self.default_value(Prop::Contrast)
|
||||
)
|
||||
.into_owned(),
|
||||
(Some(_), _) => t!(
|
||||
"Enter fade value (0-100) <%{value}>:",
|
||||
value = self.default_value(Prop::Fade)
|
||||
)
|
||||
.into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn options(&self) -> Vec<CmdOption> {
|
||||
match self.prop {
|
||||
None => self
|
||||
.options_list()
|
||||
.iter()
|
||||
.map(|p| CmdOption::new(p.label(), &p.label()[..1]))
|
||||
.collect(),
|
||||
Some(Prop::Monochrome) => vec![CmdOption::new("Yes", "Y"), CmdOption::new("No", "N")],
|
||||
Some(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn input_kind(&self) -> InputKind {
|
||||
InputKind::SingleToken
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let text = text.trim().to_uppercase();
|
||||
let invalid = || {
|
||||
Some(CmdResult::ReportError(
|
||||
t!("Invalid option keyword.").into_owned(),
|
||||
))
|
||||
};
|
||||
match self.prop {
|
||||
None => {
|
||||
let chosen = if text.is_empty() {
|
||||
Some(self.default_option())
|
||||
} else {
|
||||
self.options_list()
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|p| names(&text, p.label()))
|
||||
};
|
||||
let Some(prop) = chosen else {
|
||||
return invalid();
|
||||
};
|
||||
self.prop = Some(prop);
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
Some(Prop::Monochrome) => {
|
||||
let Subject::Underlay { monochrome, .. } = self.subject else {
|
||||
return Some(CmdResult::Cancel);
|
||||
};
|
||||
let on = if text.is_empty() {
|
||||
monochrome
|
||||
} else if names(&text, "Yes") {
|
||||
true
|
||||
} else if names(&text, "No") {
|
||||
false
|
||||
} else {
|
||||
return invalid();
|
||||
};
|
||||
Some(self.apply(Prop::Monochrome, on as u8))
|
||||
}
|
||||
Some(prop) => {
|
||||
let value = if text.is_empty() {
|
||||
Some(self.default_value(prop))
|
||||
} else {
|
||||
text.parse::<u8>().ok().filter(|v| *v <= 100)
|
||||
};
|
||||
match (value, &self.subject) {
|
||||
(Some(v), _) => Some(self.apply(prop, v)),
|
||||
(None, Subject::Image { .. }) => Some(CmdResult::ReportError(
|
||||
t!("Value must be an integer between 0 and 100.").into_owned(),
|
||||
)),
|
||||
(None, Subject::Underlay { .. }) => Some(CmdResult::ReportError(
|
||||
crate::tf!("Requires an integer between {min} and {max}.", min = 0, max = 100)
|
||||
.into_owned(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, _pt: glam::DVec3) -> CmdResult {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
self.on_text_input("").unwrap_or(CmdResult::Cancel)
|
||||
}
|
||||
}
|
||||
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &["-IMAGEADJUST", "PDFADJUST", "DWFADJUST", "DGNADJUST"]
|
||||
});
|
||||
@@ -252,7 +252,7 @@ impl ImageModel {
|
||||
Some(Self {
|
||||
render_instance: None,
|
||||
file_path: img.file_path.clone(),
|
||||
pixels: decoded.pixels,
|
||||
pixels: adjusted_image_pixels(&img.file_path, &decoded.pixels, img.brightness, img.contrast),
|
||||
width: decoded.width,
|
||||
height: decoded.height,
|
||||
opacity,
|
||||
@@ -659,6 +659,67 @@ pub fn clear_image_cache() {
|
||||
/// The result — including a `None` — is cached so the raster loader and the
|
||||
/// unresolved-reference placeholder agree on one answer without fetching
|
||||
/// twice.
|
||||
/// Brightness and contrast (0–100, 50 = unchanged) as one lookup table per
|
||||
/// colour channel. Measured against the reference's display of a gradient:
|
||||
/// brightness is a power curve x^g with g = ((100 − B) / B)^0.72 (0 is black,
|
||||
/// 100 white); contrast bends lightness around one half the same way,
|
||||
/// 0.5 ± 0.5·|2x − 1|^h with h = ((100 − C) / C)^0.72 (0 is flat grey, 100 a
|
||||
/// hard step). Brightness is applied first. Fade is the model's opacity.
|
||||
pub(crate) fn brightness_contrast_lut(brightness: u8, contrast: u8) -> [u8; 256] {
|
||||
// ponytail: the 0.72 exponent is a least-squares fit of seven measured
|
||||
// levels (within about 10/255); refine with more reference samples.
|
||||
let curve = |v: u8| -> f64 { (f64::from(100 - v.min(100)) / f64::from(v.min(100).max(1))).powf(0.72) };
|
||||
let mut lut = [0u8; 256];
|
||||
for (i, out) in lut.iter_mut().enumerate() {
|
||||
let mut x = i as f64 / 255.0;
|
||||
x = match brightness.min(100) {
|
||||
0 => 0.0,
|
||||
100 => if i == 0 { 0.0 } else { 1.0 },
|
||||
b => x.powf(curve(b)),
|
||||
};
|
||||
x = match contrast.min(100) {
|
||||
0 => 0.5,
|
||||
100 => if x >= 0.5 { 1.0 } else { 0.0 },
|
||||
c => {
|
||||
let d = 2.0 * x - 1.0;
|
||||
0.5 + 0.5 * d.signum() * d.abs().powf(curve(c))
|
||||
}
|
||||
};
|
||||
*out = (x.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
}
|
||||
lut
|
||||
}
|
||||
|
||||
/// The decoded raster with the entity's brightness and contrast applied,
|
||||
/// memoised per source and setting (the GPU keeps textures per pixel buffer).
|
||||
fn adjusted_image_pixels(path: &str, pixels: &Arc<Vec<u8>>, brightness: u8, contrast: u8) -> Arc<Vec<u8>> {
|
||||
if brightness == 50 && contrast == 50 {
|
||||
return Arc::clone(pixels);
|
||||
}
|
||||
type Key = (String, usize, u8, u8);
|
||||
static CACHE: OnceLock<Mutex<HashMap<Key, Arc<Vec<u8>>>>> = OnceLock::new();
|
||||
let key = (path.to_string(), Arc::as_ptr(pixels) as usize, brightness, contrast);
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
if let Some(hit) = cache.lock().unwrap_or_else(|p| p.into_inner()).get(&key) {
|
||||
return Arc::clone(hit);
|
||||
}
|
||||
let lut = brightness_contrast_lut(brightness, contrast);
|
||||
let mut out = pixels.as_ref().clone();
|
||||
for px in out.chunks_exact_mut(4) {
|
||||
for c in &mut px[..3] {
|
||||
*c = lut[*c as usize];
|
||||
}
|
||||
}
|
||||
let out = Arc::new(out);
|
||||
let mut cache = cache.lock().unwrap_or_else(|p| p.into_inner());
|
||||
// ponytail: cleared wholesale past 64 entries; an LRU if many images are adjusted.
|
||||
if cache.len() > 64 {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(key, Arc::clone(&out));
|
||||
out
|
||||
}
|
||||
|
||||
pub fn resolve_image(path: &str) -> Option<DecodedImage> {
|
||||
let path = path.trim();
|
||||
if path.is_empty() {
|
||||
|
||||
Reference in new issue
Block a user