Merge pull request #1751 from ramox81/codex/adjust-parity

ADJUST: images and underlays, -IMAGEADJUST and PDF/DWF/DGNADJUST
This commit is contained in:
Hakan Seven committed 2026-10-14 08:42:35 +03:00
commit f6fc244430
33 files changed
+903 -312

No files matched your search

+27
View File
@@ -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
View File
@@ -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;
+3 -1
View File
@@ -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);
+6
View File
@@ -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 {
+17 -10
View File
@@ -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,
_ => {}
}
}
+2 -1
View File
@@ -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
View File
@@ -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")),
+26
View File
@@ -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;
}
+1 -1
View File
@@ -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;
+353
View File
@@ -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"]
});
+62 -1
View File
@@ -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() {