feat(plot): complete plot configuration

- unify Model and Paper Space plot area behavior
- add CTB discovery, read/write, and bundled defaults
- apply scale, render mode, and output options consistently
This commit is contained in:
Hakan Seven 2026-07-31 13:41:34 +03:00
commit 325cd2474c
11 changed files with 1125 additions and 584 deletions

Binary file not shown.

BIN
assets/plotstyles/ocad.ctb Normal file

Binary file not shown.

View file

@ -647,8 +647,6 @@ pub(super) struct OpenCADStudio {
mtext_click_time: Option<Instant>,
mtext_click_off: usize,
mtext_click_count: u8,
/// Plot scale for model-space window plots: "Fit" | "1:1" | … | "2:1".
plot_scale: String,
/// Pending model-space plot window (x0, y0, x1, y1) in world XY, or None.
plot_window: Option<(f64, f64, f64, f64)>,
plot_format: crate::io::paper_sizes::PaperSize,
@ -2773,7 +2771,6 @@ impl OpenCADStudio {
mtext_click_time: None,
mtext_click_off: 0,
mtext_click_count: 0,
plot_scale: "Fit".to_string(),
plot_window: None,
plot_format: crate::io::paper_sizes::PaperSize::A4,
plot_orientation: crate::io::paper_sizes::Orientation::Landscape,
@ -2800,7 +2797,15 @@ impl OpenCADStudio {
save_dialog_for_unsaved: false,
default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(),
// Plot style
active_plot_style: None,
active_plot_style: crate::io::plot_style::PlotStyleTable::load_named(
crate::io::plot_style::DEFAULT_PLOT_STYLE,
)
.or_else(|_| {
crate::io::plot_style::PlotStyleTable::builtin(
crate::io::plot_style::DEFAULT_PLOT_STYLE,
)
})
.ok(),
// Color scheme (default: Oxocarbon)
active_theme: Theme::Oxocarbon,
ui_theme: config::UiThemeConfig::default(),

File diff suppressed because it is too large Load diff

View file

@ -5176,7 +5176,6 @@ impl OpenCADStudio {
}
self.plot_dialog.style_name = table.name.clone();
self.plot_dialog.style_missing = false;
self.plot_dialog.with_styles = true;
self.command_line.push_output(&format!(
"Plot style '{}' loaded ({} color entries).",
table.name,
@ -5187,6 +5186,7 @@ impl OpenCADStudio {
.count()
));
self.active_plot_style = Some(table);
self.plot_dialog.plot_styles = crate::io::plot_style::available_ctb_names();
Task::none()
}
Message::PlotStyleLoaded(None) => Task::none(),
@ -5194,7 +5194,6 @@ impl OpenCADStudio {
self.active_plot_style = None;
self.plot_dialog.style_name.clear();
self.plot_dialog.style_missing = false;
self.plot_dialog.with_styles = false;
self.command_line.push_output("Plot style table cleared.");
Task::none()
}
@ -5264,12 +5263,34 @@ impl OpenCADStudio {
Message::PlotStylePanelSave => self.on_plot_style_panel_save(),
Message::PlotStylePanelSavePath(Some(path)) => {
let path = if path
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("ctb"))
{
path
} else {
path.with_extension("ctb")
};
if let Some(table) = &self.active_plot_style {
match table.save(&path) {
Ok(()) => self.command_line.push_output(&format!(
"Plot style table saved to \"{}\".",
path.display()
)),
Ok(()) => {
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
if let Some(table) = self.active_plot_style.as_mut() {
table.name = name.clone();
}
self.plot_dialog.style_name = name;
self.plot_dialog.style_missing = false;
self.plot_dialog.plot_styles =
crate::io::plot_style::available_ctb_names();
self.command_line.push_output(&format!(
"Plot style table saved to \"{}\".",
path.display()
));
}
Err(e) => self.command_line.push_error(&format!("Save error: {e}")),
}
}

View file

@ -776,14 +776,16 @@ mod save_failure_tests {
/// Show a file-open dialog and load the selected CTB or STB file.
pub async fn pick_plot_style() -> Option<plot_style::PlotStyleTable> {
let handle = crate::sys::file_dialog()
let mut dialog = crate::sys::file_dialog()
.set_title("Load Plot Style Table")
.add_filter("Plot Style Tables", &["ctb", "stb", "CTB", "STB"])
.add_filter("Plot Style Tables", &["ctb", "CTB"])
.add_filter("CTB Files", &["ctb", "CTB"])
.add_filter("STB Files", &["stb", "STB"])
.add_filter("All Files", &["*"])
.pick_file()
.await?;
.add_filter("All Files", &["*"]);
#[cfg(not(target_arch = "wasm32"))]
if let Ok(dir) = plot_style::ensure_plot_styles_dir() {
dialog = dialog.set_directory(dir);
}
let handle = dialog.pick_file().await?;
plot_style::PlotStyleTable::load(&crate::sys::handle_path(&handle)).ok()
}

View file

@ -15,9 +15,10 @@ use crate::scene::model::hatch_model::HatchPattern;
use crate::scene::WireModel;
#[cfg(not(target_arch = "wasm32"))]
use printpdf::{
BuiltinFont, Color, Line, LineCapStyle, LineDashPattern, LineJoinStyle, LinePoint, Mm, Op,
PaintMode, PdfDocument, PdfFontHandle, PdfPage, PdfSaveOptions, Point, Polygon, PolygonRing,
Pt, Rgb, TextItem, WindingOrder,
BlendMode, BuiltinFont, Color, ExtendedGraphicsState, ExtendedGraphicsStateId, Line,
LineCapStyle, LineDashPattern, LineJoinStyle, LinePoint, Mm, Op, PaintMode, PdfDocument,
PdfFontHandle, PdfPage, PdfSaveOptions, Point, Polygon, PolygonRing, Pt, Rgb, TextItem,
WindingOrder,
};
#[cfg(not(target_arch = "wasm32"))]
use std::io::Write;
@ -67,9 +68,8 @@ pub struct PdfPlotOptions {
pub object_lineweights: bool,
pub scale_lineweights: bool,
pub transparency: bool,
pub monochrome: bool,
pub wireframe: bool,
pub stamp: bool,
pub merge_lines: bool,
pub group_splits: PlotGroupSplits,
}
@ -87,9 +87,8 @@ impl Default for PdfPlotOptions {
object_lineweights: true,
scale_lineweights: false,
transparency: false,
monochrome: false,
wireframe: false,
stamp: false,
merge_lines: false,
group_splits: PlotGroupSplits::default(),
}
}
@ -195,6 +194,20 @@ fn build_pdf(
rectangle: printpdf::Rect::from_wh(Mm(paper_w).into(), Mm(paper_h).into()),
});
let normal_blend = if options.merge_lines {
let merge = doc.add_graphics_state(
ExtendedGraphicsState::default().with_blend_mode(BlendMode::multiply()),
);
let normal = doc.add_graphics_state(
ExtendedGraphicsState::default().with_blend_mode(BlendMode::normal()),
);
ops.push(Op::SaveGraphicsState);
ops.push(Op::LoadGraphicsState { gs: merge });
Some(normal)
} else {
None
};
// Round line caps/joins for CAD aesthetics.
ops.push(Op::SetLineCapStyle {
cap: LineCapStyle::Round,
@ -283,7 +296,15 @@ fn build_pdf(
// Hatch / wipeout fills render before wires so linework stays visible.
for hatch in wipeouts.iter().chain(hatches.iter()) {
emit_hatch(&mut ops, hatch, ox, oy, plot_style, options);
emit_hatch(
&mut ops,
hatch,
ox,
oy,
plot_style,
options,
normal_blend.as_ref(),
);
}
let mut last_color: Option<[f32; 3]> = None;
@ -337,7 +358,7 @@ fn build_pdf(
b = 0.50;
}
}
[r, g, b] = plotted_color([r, g, b], a, screening, options, false);
[r, g, b] = plotted_color([r, g, b], a, screening, options);
if last_color
.map(|c| (c[0] - r).abs() > 0.01 || (c[1] - g).abs() > 0.01 || (c[2] - b).abs() > 0.01)
@ -437,6 +458,9 @@ fn build_pdf(
if needs_state {
ops.push(Op::RestoreGraphicsState);
}
if options.merge_lines {
ops.push(Op::RestoreGraphicsState);
}
if options.stamp {
emit_plot_stamp(&mut ops);
}
@ -488,15 +512,11 @@ fn flush_line(ops: &mut Vec<Op>, pts: &[LinePoint]) {
#[cfg(not(target_arch = "wasm32"))]
fn plotted_color(
mut rgb: [f32; 3],
rgb: [f32; 3],
alpha: f32,
screening: f32,
options: PdfPlotOptions,
preserve_white: bool,
) -> [f32; 3] {
if options.monochrome && !preserve_white {
rgb = [0.0, 0.0, 0.0];
}
let amount = screening.clamp(0.0, 1.0)
* if options.transparency {
alpha.clamp(0.0, 1.0)
@ -520,7 +540,7 @@ fn emit_wire_fills(
options: PdfPlotOptions,
) {
for wire in wires {
if wire.fill_tris.is_empty() || (options.wireframe && wire.fill_is_3d) {
if wire.fill_tris.is_empty() {
continue;
}
let [mut r, mut g, mut b, a] = wire.color;
@ -541,7 +561,7 @@ fn emit_wire_fills(
if !color_overridden {
[r, g, b] = adapt_text_color([r, g, b]);
}
[r, g, b] = plotted_color([r, g, b], a, screening, options, false);
[r, g, b] = plotted_color([r, g, b], a, screening, options);
ops.push(Op::SetFillColor {
col: Color::Rgb(Rgb {
r,
@ -623,6 +643,7 @@ fn emit_hatch(
oy: f64,
plot_style: Option<&PlotStyleTable>,
options: PdfPlotOptions,
normal_blend: Option<&ExtendedGraphicsStateId>,
) {
if hatch.boundary.is_empty() {
return;
@ -672,13 +693,7 @@ fn emit_hatch(
b = 0.50;
}
}
[r, g, b] = plotted_color(
[r, g, b],
a,
screening,
options,
is_wipeout,
);
[r, g, b] = plotted_color([r, g, b], a, screening, options);
// `boundary` holds f32 offsets from the f64 `world_origin`, so resolve the
// pair in f64 and only narrow once the offset has cancelled — casting
// `world_origin` to f32 first re-introduces the ~0.5 m UTM quantisation the
@ -728,7 +743,6 @@ fn emit_hatch(
color2[3],
1.0,
options,
false,
);
let avg = [
(r + second[0]) * 0.5,
@ -789,6 +803,12 @@ fn emit_hatch(
}),
});
}
if is_wipeout {
if let Some(gs) = normal_blend {
ops.push(Op::SaveGraphicsState);
ops.push(Op::LoadGraphicsState { gs: gs.clone() });
}
}
ops.push(Op::DrawPolygon {
polygon: Polygon {
rings,
@ -796,6 +816,9 @@ fn emit_hatch(
winding_order: WindingOrder::EvenOdd,
},
});
if is_wipeout && normal_blend.is_some() {
ops.push(Op::RestoreGraphicsState);
}
}
// ── Text (SDF glyph quads → vector strokes / fills) ────────────────────────
@ -908,7 +931,7 @@ fn emit_text(
let rgb = ctb_color.unwrap_or_else(|| {
adapt_text_color([quad[0].color[0], quad[0].color[1], quad[0].color[2]])
});
let [r, g, b] = plotted_color(rgb, a, screening, options, false);
let [r, g, b] = plotted_color(rgb, a, screening, options);
// Quad corners in world XY: verts run [bl, br, tr, bl, tr, tl].
let bl = glyph_world_xy(&quad[0]);

View file

@ -3,23 +3,84 @@
//! CTB files map indexed drawing colors (ACI, 1-255) to pen properties:
//! RGB color override, lineweight, and screeing percentage.
//!
//! File format: deflate-compressed text (key = value pairs) with
//! 255 `begin_plot_style … end_plot_style` blocks.
//! File format: a fixed 60-byte header followed by zlib-compressed text.
//!
//! STB files follow the same format but use named styles instead of
//! ACI indices; they are read into a `Vec<NamedPlotStyle>`.
use rustc_hash::FxHashMap as HashMap;
use std::io::Read;
use std::path::Path;
use std::path::{Component, Path, PathBuf};
pub const DEFAULT_PLOT_STYLE: &str = "ocad.ctb";
pub const MONOCHROME_PLOT_STYLE: &str = "monochrome.ctb";
#[cfg(not(target_arch = "wasm32"))]
pub fn plot_styles_dir() -> Result<PathBuf, String> {
crate::config::config_dir()
.map(|path| path.join("plotstyles"))
.ok_or_else(|| "Plot styles folder could not be resolved".to_string())
}
#[cfg(not(target_arch = "wasm32"))]
pub fn ensure_plot_styles_dir() -> Result<PathBuf, String> {
let dir = plot_styles_dir()?;
std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
for (name, bytes) in [
(
DEFAULT_PLOT_STYLE,
include_bytes!("../../assets/plotstyles/ocad.ctb").as_slice(),
),
(
MONOCHROME_PLOT_STYLE,
include_bytes!("../../assets/plotstyles/monochrome.ctb").as_slice(),
),
] {
let path = dir.join(name);
if !path.exists() {
std::fs::write(path, bytes).map_err(|error| error.to_string())?;
}
}
Ok(dir)
}
/// CTB files available to the Plot dialog.
pub fn available_ctb_names() -> Vec<String> {
#[cfg(not(target_arch = "wasm32"))]
{
let Ok(dir) = ensure_plot_styles_dir() else {
return vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()];
};
let Ok(entries) = std::fs::read_dir(dir) else {
return vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()];
};
let mut names: Vec<String> = entries
.filter_map(Result::ok)
.filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_file()))
.filter_map(|entry| {
let path = entry.path();
path.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("ctb"))
.then(|| entry.file_name().to_string_lossy().into_owned())
})
.collect();
names.sort_by_key(|name| name.to_ascii_lowercase());
names.dedup_by(|left, right| left.eq_ignore_ascii_case(right));
names
}
#[cfg(target_arch = "wasm32")]
{
vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()]
}
}
// ── Standard lineweight table (index → mm) ───────────────────────────────────
/// Lineweight table: index value → mm, matching the stored LWEIGHT codes.
/// Index 0 = 0.00 mm (hairline), others follow the DXF lineweight enum.
pub const LW_TABLE: &[f32] = &[
0.00, 0.05, 0.09, 0.10, 0.13, 0.15, 0.18, 0.20, 0.25, 0.30, 0.35, 0.40, 0.50, 0.53, 0.60, 0.70,
0.80, 0.90, 1.00, 1.06, 1.20, 1.40, 1.58, 2.00, 2.11,
0.00, 0.05, 0.09, 0.10, 0.13, 0.15, 0.18, 0.20, 0.25, 0.30, 0.35, 0.40, 0.45, 0.50,
0.53, 0.60, 0.65, 0.70, 0.80, 0.90, 1.00, 1.06, 1.20, 1.40, 1.58, 2.00, 2.11,
];
// ── Per-color entry ───────────────────────────────────────────────────────────
@ -27,24 +88,45 @@ pub const LW_TABLE: &[f32] = &[
/// A single entry in a CTB or STB plot style table.
#[derive(Debug, Clone)]
pub struct PlotStyleEntry {
/// Optional display name (empty for CTB; the style name for STB).
pub name: String,
pub localized_name: String,
pub description: String,
/// If `Some([r,g,b])`, override the entity color with this RGB value (0..255).
/// If `None`, use the object color.
pub color: Option<[u8; 3]>,
/// Lineweight index into `LW_TABLE`. 255 = use object lineweight.
/// Lineweight index into the table. 0 (and legacy 255) = object lineweight.
pub lineweight: u8,
/// Screen percentage 0100 (100 = opaque).
pub screening: u8,
pub color_policy: u8,
pub physical_pen_number: u16,
pub virtual_pen_number: u16,
pub linepattern_size: f32,
pub linetype: u8,
pub adaptive_linetype: bool,
pub fill_style: u8,
pub end_style: u8,
pub join_style: u8,
}
impl Default for PlotStyleEntry {
fn default() -> Self {
PlotStyleEntry {
name: String::new(),
localized_name: String::new(),
description: String::new(),
color: None,
lineweight: 255, // use object lineweight
lineweight: 0,
screening: 100,
color_policy: 1,
physical_pen_number: 0,
virtual_pen_number: 0,
linepattern_size: 0.5,
linetype: 31,
adaptive_linetype: true,
fill_style: 73,
end_style: 4,
join_style: 5,
}
}
}
@ -59,6 +141,11 @@ pub struct PlotStyleTable {
pub name: String,
/// Whether this is a named-style (STB) table rather than color-based (CTB).
pub is_stb: bool,
pub description: String,
pub scale_factor: f32,
pub apply_factor: bool,
pub custom_lineweight_display_units: u8,
pub lineweights: Vec<f32>,
/// For CTB: entries indexed by ACI (index 0 unused; 1..=255 are valid).
pub aci_entries: Vec<PlotStyleEntry>, // 256 entries, index = ACI
/// For STB: named style entries.
@ -71,6 +158,11 @@ impl PlotStyleTable {
PlotStyleTable {
name: name.into(),
is_stb: false,
description: String::new(),
scale_factor: 1.0,
apply_factor: false,
custom_lineweight_display_units: 0,
lineweights: LW_TABLE.to_vec(),
aci_entries: (0..=255).map(|_| PlotStyleEntry::default()).collect(),
named_entries: HashMap::default(),
}
@ -84,11 +176,63 @@ impl PlotStyleTable {
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let is_stb = name.to_lowercase().ends_with(".stb");
let text = decompress_ctb(&raw)?;
Self::from_bytes(name, &raw)
}
pub fn from_bytes(name: impl Into<String>, raw: &[u8]) -> Result<Self, String> {
let name = name.into();
let is_stb = name.to_ascii_lowercase().ends_with(".stb");
let text = decompress_ctb(raw)?;
parse_plot_style_text(&text, name, is_stb)
}
pub fn builtin(name: &str) -> Result<Self, String> {
match name.to_ascii_lowercase().as_str() {
DEFAULT_PLOT_STYLE => Self::from_bytes(
DEFAULT_PLOT_STYLE,
include_bytes!("../../assets/plotstyles/ocad.ctb"),
),
MONOCHROME_PLOT_STYLE => Self::from_bytes(
MONOCHROME_PLOT_STYLE,
include_bytes!("../../assets/plotstyles/monochrome.ctb"),
),
_ => Err(format!("Unknown built-in plot style: {name}")),
}
}
/// Load one CTB by file name from the per-user plot styles folder.
pub fn load_named(name: &str) -> Result<Self, String> {
let path = Path::new(name);
if path.components().count() != 1
|| !matches!(path.components().next(), Some(Component::Normal(_)))
|| !path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("ctb"))
{
return Err(format!("Invalid plot style name: {name}"));
}
#[cfg(not(target_arch = "wasm32"))]
{
let dir = ensure_plot_styles_dir()?;
let matched = std::fs::read_dir(&dir)
.map_err(|error| error.to_string())?
.filter_map(Result::ok)
.find(|entry| {
entry
.file_name()
.to_string_lossy()
.eq_ignore_ascii_case(name)
})
.map(|entry| entry.path())
.ok_or_else(|| format!("Plot style not found: {name}"))?;
return Self::load(&matched);
}
#[cfg(target_arch = "wasm32")]
Self::builtin(name)
}
/// Write this table to disk as a CTB/STB file.
#[allow(dead_code)]
pub fn save(&self, path: &Path) -> Result<(), String> {
@ -110,10 +254,10 @@ impl PlotStyleTable {
/// Returns None if no override (use object lineweight).
pub fn resolve_lineweight(&self, aci: u8) -> Option<f32> {
let entry = self.aci_entries.get(aci as usize)?;
if entry.lineweight == 255 {
if matches!(entry.lineweight, 0 | 255) {
None
} else {
LW_TABLE.get(entry.lineweight as usize).copied()
self.lineweights.get(entry.lineweight as usize).copied()
}
}
@ -129,31 +273,69 @@ impl PlotStyleTable {
fn to_text(&self) -> String {
let mut s = String::new();
s.push_str("description=\n");
s.push_str("apply_factor=0\n");
s.push_str("unit_type=1\n");
s.push_str("custom_lineweight_display_units=0\n");
for (_idx, entry) in self.aci_entries.iter().enumerate().skip(1) {
s.push_str("begin_plot_style\n");
s.push_str(&format!(" description={}\n", entry.description));
s.push_str(" physical_pen_number=0\n");
s.push_str(" virtual_pen_number=0\n");
s.push_str(&format!(" screen={}\n", entry.screening));
s.push_str(" linepattern_size=0.5\n");
s.push_str(" linetype=31\n");
s.push_str(" adaptive_linetype=TRUE\n");
s.push_str(&format!(" lineweight={}\n", entry.lineweight));
s.push_str(" fill_style=64\n");
s.push_str(" end_style=0\n");
s.push_str(" join_style=0\n");
if let Some([r, g, b]) = entry.color {
s.push_str(&format!(" color1=#{:02X}{:02X}{:02X}\n", r, g, b));
} else {
// 0xC2000000 = "use object color"
s.push_str(" color1=-1056964608\n");
}
s.push_str("end_plot_style\n");
let description = self.description.replace(['\r', '\n'], " ");
s.push_str(&format!("description=\"{description}\n"));
s.push_str("aci_table_available=TRUE\n");
s.push_str(&format!("scale_factor={:.1}\n", self.scale_factor));
s.push_str(&format!(
"apply_factor={}\n",
if self.apply_factor { "TRUE" } else { "FALSE" }
));
s.push_str(&format!(
"custom_lineweight_display_units={}\n",
self.custom_lineweight_display_units
));
s.push_str("aci_table{\n");
for index in 0..255 {
s.push_str(&format!(" {index}=\"Color_{}\n", index + 1));
}
s.push_str("}\nplot_style{\n");
for (index, entry) in self.aci_entries.iter().enumerate().skip(1).take(255) {
let style_index = index - 1;
let style_name = if entry.name.is_empty() {
format!("Color_{index}")
} else {
entry.name.replace(['\r', '\n'], " ")
};
let localized_name = if entry.localized_name.is_empty() {
style_name.clone()
} else {
entry.localized_name.replace(['\r', '\n'], " ")
};
let description = entry.description.replace(['\r', '\n'], " ");
s.push_str(&format!(" {style_index}{{\n"));
s.push_str(&format!(" name=\"{style_name}\n"));
s.push_str(&format!(" localized_name=\"{localized_name}\n"));
s.push_str(&format!(" description=\"{description}\n"));
if let Some(rgb) = entry.color {
let packed = packed_rgb(rgb);
s.push_str(&format!(" color={packed}\n mode_color={packed}\n"));
} else {
s.push_str(" color=-1\n");
}
s.push_str(&format!(" color_policy={}\n", entry.color_policy));
s.push_str(&format!(
" physical_pen_number={}\n virtual_pen_number={}\n",
entry.physical_pen_number, entry.virtual_pen_number
));
s.push_str(&format!(" screen={}\n", entry.screening));
s.push_str(&format!(
" linepattern_size={}\n linetype={}\n adaptive_linetype={}\n",
entry.linepattern_size,
entry.linetype,
if entry.adaptive_linetype { "TRUE" } else { "FALSE" }
));
s.push_str(&format!(" lineweight={}\n", entry.lineweight));
s.push_str(&format!(
" fill_style={}\n end_style={}\n join_style={}\n }}\n",
entry.fill_style, entry.end_style, entry.join_style
));
}
s.push_str("}\ncustom_lineweight_table{\n");
for (index, weight) in self.lineweights.iter().enumerate() {
s.push_str(&format!(" {index}={weight:.2}\n"));
}
s.push_str("}\n");
s
}
}
@ -162,72 +344,290 @@ impl PlotStyleTable {
/// Decompress a CTB/STB file's raw bytes into the text content.
///
/// CTB files start with a plain-text header (first line: "PIAFILEVERSION_2.0")
/// followed by raw-deflate compressed content. Some tools write pure zlib
/// (with the two-byte zlib header 0x78 0x9C) instead — we handle both.
fn decompress_ctb(data: &[u8]) -> Result<String, String> {
// Find the first newline — everything after it is the compressed payload.
let split_at = data
.iter()
.position(|&b| b == b'\n')
.map(|p| p + 1)
.unwrap_or(0);
let payload = &data[split_at..];
// Try zlib (0x78 prefix) first, then raw deflate.
let mut text = String::new();
if payload.starts_with(&[0x78]) {
const PREFIX: &[u8] = b"PIAFILEVERSION_2.0,CTBVER1,compress\r\npmzlibcodec";
let mut decoded = Vec::new();
if data.starts_with(PREFIX) {
if data.len() < 60 {
return Err("CTB header is truncated".into());
}
let checksum = u32::from_le_bytes(data[48..52].try_into().unwrap());
let text_len = u32::from_le_bytes(data[52..56].try_into().unwrap()) as usize;
let compressed_len = u32::from_le_bytes(data[56..60].try_into().unwrap()) as usize;
if compressed_len > data.len() - 60 {
return Err("CTB compressed payload is truncated".into());
}
let payload = &data[60..60 + compressed_len];
if adler32(payload) != checksum {
return Err("CTB compressed payload checksum mismatch".into());
}
use flate2::read::ZlibDecoder;
ZlibDecoder::new(payload)
.read_to_string(&mut text)
.map_err(|e| format!("zlib decompress: {e}"))?;
.read_to_end(&mut decoded)
.map_err(|e| format!("CTB zlib decompress: {e}"))?;
if decoded.len() != text_len {
return Err(format!(
"CTB content length mismatch: expected {text_len}, got {}",
decoded.len()
));
}
} else {
use flate2::read::DeflateDecoder;
DeflateDecoder::new(payload)
.read_to_string(&mut text)
.map_err(|e| format!("deflate decompress: {e}"))?;
let split_at = data
.iter()
.position(|&b| b == b'\n')
.map(|p| p + 1)
.unwrap_or(0);
let payload = &data[split_at..];
if payload.starts_with(&[0x78]) {
use flate2::read::ZlibDecoder;
ZlibDecoder::new(payload)
.read_to_end(&mut decoded)
.map_err(|e| format!("legacy CTB zlib decompress: {e}"))?;
} else {
use flate2::read::DeflateDecoder;
DeflateDecoder::new(payload)
.read_to_end(&mut decoded)
.map_err(|e| format!("legacy CTB deflate decompress: {e}"))?;
}
}
Ok(text)
if decoded.last() == Some(&0) {
decoded.pop();
}
String::from_utf8(decoded).map_err(|e| format!("CTB text is not UTF-8: {e}"))
}
/// Compress plot-style text content as a CTB/STB file.
fn compress_ctb(text: &[u8]) -> Result<Vec<u8>, String> {
use flate2::{write::ZlibEncoder, Compression};
use std::io::Write;
let header = b"PIAFILEVERSION_2.0\r\n";
let mut compressed: Vec<u8> = Vec::new();
{
let mut enc = ZlibEncoder::new(&mut compressed, Compression::default());
enc.write_all(text).map_err(|e| e.to_string())?;
}
let mut out = header.to_vec();
const PREFIX: &[u8] = b"PIAFILEVERSION_2.0,CTBVER1,compress\r\npmzlibcodec";
let mut body = text.to_vec();
body.push(0);
let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
enc.write_all(&body).map_err(|e| e.to_string())?;
let compressed = enc.finish().map_err(|e| e.to_string())?;
let mut out = PREFIX.to_vec();
out.extend_from_slice(&adler32(&compressed).to_le_bytes());
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
out.extend_from_slice(&(compressed.len() as u32).to_le_bytes());
out.extend_from_slice(&compressed);
Ok(out)
}
fn adler32(bytes: &[u8]) -> u32 {
const MOD: u32 = 65_521;
let mut a = 1u32;
let mut b = 0u32;
for byte in bytes {
a = (a + u32::from(*byte)) % MOD;
b = (b + a) % MOD;
}
(b << 16) | a
}
fn packed_rgb([r, g, b]: [u8; 3]) -> i32 {
u32::from_be_bytes([0xC2, r, g, b]) as i32
}
// ── Text parser ───────────────────────────────────────────────────────────────
fn parse_plot_style_text(text: &str, name: String, is_stb: bool) -> Result<PlotStyleTable, String> {
if text.lines().any(|line| line.trim() == "begin_plot_style") {
return parse_legacy_plot_style_text(text, name, is_stb);
}
#[derive(Default)]
struct PendingStyle {
index: usize,
name: String,
entry: PlotStyleEntry,
color: Option<i32>,
mode_color: Option<i32>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Section {
Root,
Other,
PlotStyles,
Lineweights,
}
let mut aci_entries: Vec<PlotStyleEntry> =
(0..=255).map(|_| PlotStyleEntry::default()).collect();
let mut named_entries: HashMap<String, PlotStyleEntry> = HashMap::default();
let mut style_index: usize = 1; // CTB: 1-based ACI index
let mut current: Option<PlotStyleEntry> = None;
let mut current_name: String = String::new();
let mut description = String::new();
let mut scale_factor = 1.0f32;
let mut apply_factor = false;
let mut custom_lineweight_display_units = 0u8;
let mut lineweights = Vec::<f32>::new();
let mut section = Section::Root;
let mut current: Option<PendingStyle> = None;
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if line == "plot_style{" {
section = Section::PlotStyles;
continue;
}
if line == "custom_lineweight_table{" {
section = Section::Lineweights;
continue;
}
if line.ends_with('{') {
if section == Section::PlotStyles && current.is_none() {
if let Ok(index) = line.trim_end_matches('{').trim().parse::<usize>() {
let default_name = format!("Color_{}", index + 1);
let mut style = PendingStyle {
index,
name: default_name.clone(),
..Default::default()
};
style.entry.name = default_name.clone();
style.entry.localized_name = default_name;
current = Some(style);
continue;
}
}
section = Section::Other;
continue;
}
if line == "}" {
if let Some(mut style) = current.take() {
let packed = style.mode_color.or(style.color);
style.entry.color = packed.and_then(unpack_plot_color);
if is_stb {
named_entries.insert(style.name, style.entry);
} else if style.index < 255 {
aci_entries[style.index + 1] = style.entry;
}
} else {
section = Section::Root;
}
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
let value = value.trim().trim_start_matches('"');
if let Some(style) = current.as_mut() {
match key {
"name" => {
style.name = value.to_string();
style.entry.name = value.to_string();
}
"localized_name" => style.entry.localized_name = value.to_string(),
"description" => {
style.entry.description = value.to_string();
}
"screen" => {
if let Ok(v) = value.parse::<u8>() {
style.entry.screening = v.min(100);
}
}
"lineweight" => {
if let Ok(v) = value.parse::<u8>() {
style.entry.lineweight = v;
}
}
"color" => style.color = value.parse::<i32>().ok(),
"mode_color" => style.mode_color = value.parse::<i32>().ok(),
"color_policy" => style.entry.color_policy = value.parse().unwrap_or(1),
"physical_pen_number" => {
style.entry.physical_pen_number = value.parse().unwrap_or(0)
}
"virtual_pen_number" => {
style.entry.virtual_pen_number = value.parse().unwrap_or(0)
}
"linepattern_size" => {
style.entry.linepattern_size = value.parse().unwrap_or(0.5)
}
"linetype" => style.entry.linetype = value.parse().unwrap_or(31),
"adaptive_linetype" => {
style.entry.adaptive_linetype = value.eq_ignore_ascii_case("TRUE")
}
"fill_style" => style.entry.fill_style = value.parse().unwrap_or(73),
"end_style" => style.entry.end_style = value.parse().unwrap_or(4),
"join_style" => style.entry.join_style = value.parse().unwrap_or(5),
_ => {}
}
continue;
}
match section {
Section::Root => match key {
"description" => description = value.to_string(),
"scale_factor" => scale_factor = value.parse().unwrap_or(1.0),
"apply_factor" => apply_factor = value.eq_ignore_ascii_case("TRUE"),
"custom_lineweight_display_units" => {
custom_lineweight_display_units = value.parse().unwrap_or(0)
}
_ => {}
},
Section::Lineweights => {
if let (Ok(index), Ok(weight)) = (key.parse::<usize>(), value.parse::<f32>()) {
if lineweights.len() <= index {
lineweights.resize(index + 1, 0.0);
}
lineweights[index] = weight;
}
}
_ => {}
}
}
if lineweights.is_empty() {
lineweights = LW_TABLE.to_vec();
}
Ok(PlotStyleTable {
name,
is_stb,
description,
scale_factor,
apply_factor,
custom_lineweight_display_units,
lineweights,
aci_entries,
named_entries,
})
}
fn unpack_plot_color(packed: i32) -> Option<[u8; 3]> {
if matches!(packed, -1 | -1_006_632_961 | -1_056_964_608) {
return None;
}
let bytes = (packed as u32).to_be_bytes();
Some([bytes[1], bytes[2], bytes[3]])
}
fn parse_legacy_plot_style_text(
text: &str,
name: String,
is_stb: bool,
) -> Result<PlotStyleTable, String> {
let mut table = PlotStyleTable::identity(name);
table.is_stb = is_stb;
let mut style_index = 1usize;
let mut current: Option<PlotStyleEntry> = None;
let mut current_name = String::new();
for line in text.lines().map(str::trim) {
if line == "begin_plot_style" {
current = Some(PlotStyleEntry::default());
current_name = format!("Color_{}", style_index);
current_name = format!("Color_{style_index}");
continue;
}
if line == "end_plot_style" {
if let Some(entry) = current.take() {
if is_stb {
named_entries.insert(current_name.clone(), entry);
table.named_entries.insert(current_name.clone(), entry);
} else if style_index <= 255 {
aci_entries[style_index] = entry;
table.aci_entries[style_index] = entry;
style_index += 1;
}
}
@ -236,54 +636,29 @@ fn parse_plot_style_text(text: &str, name: String, is_stb: bool) -> Result<PlotS
let Some(entry) = current.as_mut() else {
continue;
};
if let Some((key, val)) = line.split_once('=') {
let key = key.trim();
let val = val.trim();
match key {
"description" => {
if !val.is_empty() {
entry.description = val.to_string();
current_name = val.to_string();
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let value = value.trim();
match key.trim() {
"description" => {
entry.description = value.to_string();
if !value.is_empty() {
current_name = value.to_string();
}
"screen" => {
if let Ok(v) = val.parse::<u8>() {
entry.screening = v;
}
}
"lineweight" => {
if let Ok(v) = val.parse::<u8>() {
entry.lineweight = v;
}
}
"color1" => {
if val.starts_with('#') && val.len() == 7 {
// #RRGGBB
let r = u8::from_str_radix(&val[1..3], 16).unwrap_or(0);
let g = u8::from_str_radix(&val[3..5], 16).unwrap_or(0);
let b = u8::from_str_radix(&val[5..7], 16).unwrap_or(0);
entry.color = Some([r, g, b]);
} else if let Ok(packed) = val.parse::<i32>() {
// The file packs RGB as a 0xC0RRGGBB negative integer.
// Value 0xC2000000 (-1056964608) = use object color.
if packed != -1056964608i32 {
let u = packed as u32;
let r = ((u >> 16) & 0xFF) as u8;
let g = ((u >> 8) & 0xFF) as u8;
let b = (u & 0xFF) as u8;
entry.color = Some([r, g, b]);
}
}
}
_ => {}
}
"screen" => entry.screening = value.parse::<u8>().unwrap_or(100).min(100),
"lineweight" => entry.lineweight = value.parse().unwrap_or(0),
"color1" if value.starts_with('#') && value.len() == 7 => {
entry.color = Some([
u8::from_str_radix(&value[1..3], 16).unwrap_or(0),
u8::from_str_radix(&value[3..5], 16).unwrap_or(0),
u8::from_str_radix(&value[5..7], 16).unwrap_or(0),
]);
}
"color1" => entry.color = value.parse::<i32>().ok().and_then(unpack_plot_color),
_ => {}
}
}
Ok(PlotStyleTable {
name,
is_stb,
aci_entries,
named_entries,
})
Ok(table)
}

View file

@ -15,7 +15,8 @@ use crate::scene::WireModel;
/// Extra options for a print job. On CUPS (Linux/macOS) these map to `lp`
/// flags / `-o` options. On Windows the generated PDF already carries render
/// options, while copies and driver quality are not exposed by the shell verb.
/// options. Windows queues repeated jobs when more than one copy is requested;
/// driver quality remains managed by the selected printer.
#[derive(Debug, Clone, Default)]
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub struct PrintOptions {
@ -23,12 +24,8 @@ pub struct PrintOptions {
pub printer: Option<String>,
/// Number of copies (treated as at least 1).
pub copies: u32,
/// Force grayscale output.
pub mono: bool,
/// Print quality label selected in the plot dialog.
pub quality: Option<String>,
/// Rasterisation resolution in DPI.
pub dpi: Option<u32>,
/// Controls applied while building the intermediate PDF.
pub render: crate::io::pdf_export::PdfPlotOptions,
}
@ -215,8 +212,7 @@ fn dispatch_to_printer_opts(
#[cfg(target_os = "windows")]
{
// Target a named printer via the "printto" verb; fall back to the
// default-printer "print" verb. Copies / quality / colour aren't
// expressible through a shell verb, so they are ignored here.
// default-printer "print" verb.
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
let wide = |s: &str| -> Vec<u16> { OsStr::new(s).encode_wide().chain(Some(0)).collect() };
@ -226,21 +222,22 @@ fn dispatch_to_printer_opts(
_ => (wide("print"), None, "default printer".to_string()),
};
let params_ptr = params.as_ref().map(|v| v.as_ptr()).unwrap_or(std::ptr::null());
let result = unsafe {
windows_sys::Win32::UI::Shell::ShellExecuteW(
std::ptr::null_mut(),
verb.as_ptr(),
path_wide.as_ptr(),
params_ptr,
std::ptr::null(),
windows_sys::Win32::UI::WindowsAndMessaging::SW_HIDE,
) as usize
};
if result > 32 {
Ok(label)
} else {
Err(format!("ShellExecute failed (code {result})"))
for _ in 0..opts.copies.max(1) {
let result = unsafe {
windows_sys::Win32::UI::Shell::ShellExecuteW(
std::ptr::null_mut(),
verb.as_ptr(),
path_wide.as_ptr(),
params_ptr,
std::ptr::null(),
windows_sys::Win32::UI::WindowsAndMessaging::SW_HIDE,
) as usize
};
if result <= 32 {
return Err(format!("ShellExecute failed (code {result})"));
}
}
Ok(label)
}
#[cfg(not(target_os = "windows"))]
@ -256,17 +253,11 @@ fn dispatch_to_printer_opts(
if copies > 1 {
cmd.arg("-n").arg(copies.to_string());
}
if opts.mono {
cmd.arg("-o").arg("ColorModel=Gray");
}
if let Some(dpi) = opts.dpi {
cmd.arg("-o").arg(format!("Resolution={dpi}dpi"));
}
if let Some(q) = opts.quality.as_deref() {
// CUPS print-quality: 3 = draft, 4 = normal, 5 = high / best.
let pq = match q {
"Draft" => "3",
"Presentation" | "Maximum" => "5",
"Low" => "3",
"High" => "5",
_ => "4",
};
cmd.arg("-o").arg(format!("print-quality={pq}"));

View file

@ -4825,17 +4825,49 @@ impl Scene {
}
/// Return paper entities and projected model-viewport entities separately.
/// Keeping the two groups distinct lets non-GPU plotters honor the requested
/// paper/model draw order.
pub fn plot_wire_groups(&self) -> (Vec<WireModel>, Vec<WireModel>) {
/// A plot-only render override is applied to cloned wires; viewport entities
/// and their saved display modes remain unchanged.
pub fn plot_wire_groups(
&self,
render_mode_override: Option<acadrust::entities::ViewportRenderMode>,
) -> (Vec<WireModel>, Vec<WireModel>) {
let apply_mode = |wires: &mut Vec<WireModel>, mode| {
let flags = view::render::render_mode_flags(mode);
for wire in wires.iter_mut().filter(|wire| wire.fill_is_3d) {
if !flags.face3d_fill && !flags.mesh_fill {
wire.fill_tris.clear();
wire.fill_tris_low.clear();
}
if !flags.show_3d_edges {
wire.points.clear();
wire.points_low.clear();
}
}
};
if self.current_layout == "Model" {
return (self.entity_wires_arc().as_ref().clone(), Vec::new());
let mut wires = self.entity_wires_arc().as_ref().clone();
apply_mode(
&mut wires,
render_mode_override.unwrap_or_else(|| self.active_model_tile_render_mode()),
);
return (wires, Vec::new());
}
let paper_block = self.current_layout_block_handle();
(
self.paper_sheet_wires_arc().as_ref().clone(),
self.viewport_content_wires(paper_block, None, None),
)
let (_, _, viewport_handles) = self.paper_viewport_handles();
let mut model_wires = Vec::new();
for handle in viewport_handles.iter().copied() {
let Some(EntityType::Viewport(viewport)) = self.document.get_entity(handle) else {
continue;
};
if viewport.common.owner_handle != paper_block || !viewport.status.is_on {
continue;
}
let mode = render_mode_override.unwrap_or(viewport.render_mode);
let mut wires = self.viewport_content_wires(paper_block, Some(handle), None);
apply_mode(&mut wires, mode);
model_wires.extend(wires);
}
(self.paper_sheet_wires_arc().as_ref().clone(), model_wires)
}
/// Per-entity stable draw-order depth, keyed by entity handle value.

View file

@ -20,21 +20,22 @@ pub const OUT_PDF: &str = "Save to PDF file…";
/// settings captured when the dialog opened.
pub const SETUP_NONE: &str = "<none>";
pub const SETUP_PREV: &str = "<previous>";
pub const STYLE_NONE: &str = "<none>";
/// One of the many boolean plot options (folded into a single message so the
/// dialog needn't carry a variant per checkbox).
#[derive(Debug, Clone, Copy)]
pub enum PlotFlag {
Background,
MergeLines,
FitToPaper,
Center,
ScaleLw,
UpsideDown,
Mono,
Lineweights,
WithStyles,
Transparency,
PaperspaceLast,
Stamp,
SaveLayout,
}
/// Every edit the Plot dialog can emit. Wrapped in `Message::PlotDlg` so the
@ -55,10 +56,10 @@ pub enum PlotDlgMsg {
Copies(String),
OffsetX(String),
OffsetY(String),
Dpi(String),
Flag(PlotFlag),
LoadStyle,
ClearStyle,
SaveStyle,
Style(String),
PickWindow,
// ── Named page-setup manager ─────────────────────────────────────────
/// Pick a named page setup (loads its values into the editor).
@ -112,19 +113,24 @@ pub struct PlotDialogState {
#[serde(skip)]
pub offset_y: String,
pub scale: String,
#[serde(default = "legacy_fit_to_paper_default")]
pub fit_to_paper: bool,
#[serde(skip)]
pub scales: Vec<(String, f64)>,
pub scale_lw: bool,
pub quality: String,
pub dpi: String,
pub shade: String,
pub mono: bool,
pub background: bool,
pub merge_lines: bool,
pub lineweights: bool,
pub with_styles: bool,
pub transparency: bool,
pub paperspace_last: bool,
pub stamp: bool,
pub save_layout: bool,
/// Display name of the active plot style table ("" = none).
pub style_name: String,
/// CTB file names discovered in the per-user plot styles folder.
#[serde(skip)]
pub plot_styles: Vec<String>,
/// The selected setup references a style table that is not loaded.
#[serde(skip)]
pub style_missing: bool,
@ -159,19 +165,20 @@ impl Default for PlotDialogState {
center: true,
offset_x: "0.0".into(),
offset_y: "0.0".into(),
scale: "Fit".into(),
scale: "1:1".into(),
fit_to_paper: true,
scales: Vec::new(),
scale_lw: true,
quality: "Normal".into(),
dpi: "300".into(),
shade: "As displayed".into(),
mono: false,
background: true,
merge_lines: false,
lineweights: true,
with_styles: true,
transparency: false,
paperspace_last: false,
stamp: false,
save_layout: false,
style_name: String::new(),
plot_styles: Vec::new(),
style_missing: false,
page_setups: Vec::new(),
selected_setup: String::new(),
@ -198,23 +205,26 @@ impl PlotDialogState {
self.offset_x = o.offset_x.clone();
self.offset_y = o.offset_y.clone();
self.scale = o.scale.clone();
self.fit_to_paper = o.fit_to_paper;
self.scale_lw = o.scale_lw;
self.quality = o.quality.clone();
self.dpi = o.dpi.clone();
self.shade = o.shade.clone();
self.mono = o.mono;
self.background = o.background;
self.merge_lines = o.merge_lines;
self.lineweights = o.lineweights;
self.with_styles = o.with_styles;
self.transparency = o.transparency;
self.paperspace_last = o.paperspace_last;
self.stamp = o.stamp;
self.save_layout = o.save_layout;
self.style_name = o.style_name.clone();
self.style_missing = o.style_missing;
}
}
fn legacy_fit_to_paper_default() -> bool {
false
}
fn btn(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style {
move |theme: &Theme, st| {
let palette = theme.palette();
@ -436,15 +446,6 @@ fn check_enabled<'a>(
.into()
}
fn check_static<'a>(label: &'a str, on: bool) -> Element<'a, Message> {
checkbox(on)
.label(label)
.size(14)
.text_size(11)
.style(checkbox::primary)
.into()
}
fn panel<'a>(content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
container(content)
.width(Length::Fill)
@ -545,29 +546,27 @@ pub fn view_window(
left: 12.0,
});
let mut left_bar = row![
let mut copy_button = button(text("Copy").size(11))
.style(btn(false))
.padding([4, 12]);
if can_copy {
copy_button = copy_button.on_press(Message::PlotDlg(PlotDlgMsg::CopySetup));
}
let mut delete_button = button(text("Delete").size(11))
.style(btn(false))
.padding([4, 12]);
if is_named {
delete_button = delete_button.on_press(Message::PlotDlg(PlotDlgMsg::DeleteSetup));
}
let left_bar = row![
button(text("New").size(11))
.on_press(Message::PlotDlg(PlotDlgMsg::NewSetup))
.style(btn(false))
.padding([4, 12]),
copy_button,
delete_button,
]
.spacing(4);
if can_copy {
left_bar = left_bar.push(
button(text("Copy").size(11))
.on_press(Message::PlotDlg(PlotDlgMsg::CopySetup))
.style(btn(false))
.padding([4, 12]),
);
}
if is_named {
left_bar = left_bar.push(
button(text("Delete").size(11))
.on_press(Message::PlotDlg(PlotDlgMsg::DeleteSetup))
.style(btn(false))
.padding([4, 12]),
);
}
// ── Printer / plotter ─────────────────────────────────────────────────
let mut printer_opts = vec![OUT_DEFAULT.to_string()];
@ -580,7 +579,7 @@ pub fn view_window(
};
let paper_opts: Vec<String> = PaperSize::ALL.iter().map(|p| p.label().to_string()).collect();
let paper_note: Element<'_, Message> = if s.area == "Layout" {
text("Layout plots the current sheet; Apply to layout updates its paper size.")
text("Layout plots the current sheet using the selected paper size.")
.size(10)
.style(muted_style)
.width(width)
@ -606,19 +605,16 @@ pub fn view_window(
.padding([4, 8]),
);
}
let destination = if s.to_file {
"Destination: PDF file"
} else if s.printer.is_some() {
"Destination: selected system printer"
let copies_row: Element<'_, Message> = if s.to_file {
Space::new().height(0).into()
} else {
"Destination: system default printer"
field_row("Copies", &s.copies, PlotDlgMsg::Copies, 60)
};
let printer_panel = panel(
column![
section_label("Printer / plotter"),
output_row,
field_row_enabled("Copies", &s.copies, PlotDlgMsg::Copies, 60, !s.to_file),
text(destination).size(10).style(muted_style),
copies_row,
]
.spacing(7),
);
@ -664,51 +660,57 @@ pub fn view_window(
.spacing(7),
check_enabled("Center the plot", s.center, PlotFlag::Center, common_area),
].spacing(7));
let scale_options = s.scales.iter().map(|(name, _)| name.clone()).collect();
let scale_panel = panel(column![
section_label("Plot scale"),
check_enabled(
"Fit to paper",
s.fit_to_paper,
PlotFlag::FitToPaper,
common_area,
),
drop_row_enabled(
"Scale",
strs(&["Fit", "1:1", "1:2", "1:5", "1:10", "1:20", "1:50", "1:100", "2:1"]),
Some(if common_area { s.scale.clone() } else { "1:1".into() }),
scale_options,
Some(s.scale.clone()),
PlotDlgMsg::Scale,
width,
common_area,
common_area && !s.fit_to_paper,
),
check_enabled("Scale lineweights", s.scale_lw, PlotFlag::ScaleLw, common_area),
].spacing(7));
// ── Style and shaded viewport settings ───────────────────────────────
let style_label = if s.style_name.is_empty() {
"(none)".to_string()
} else if s.style_missing {
format!("{} (not loaded)", s.style_name)
let mut style_options = vec![STYLE_NONE.to_string()];
style_options.extend(s.plot_styles.iter().cloned());
if !s.style_name.is_empty()
&& !style_options
.iter()
.any(|name| name.eq_ignore_ascii_case(&s.style_name))
{
style_options.push(s.style_name.clone());
}
let style_selected = if s.style_name.is_empty() {
STYLE_NONE.to_string()
} else {
s.style_name.clone()
};
let style_panel = panel(column![
section_label("Plot style table (pen assignments)"),
drop_row(
"Table",
style_options,
Some(style_selected),
PlotDlgMsg::Style,
width,
),
row![
container(text(style_label).size(12))
.style(|theme: &Theme| {
let palette = theme.palette();
container::Style {
background: Some(Background::Color(palette.background.base.color)),
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
}
})
.padding([4, 8])
.width(width),
button(text("Load…").size(11))
.on_press(Message::PlotDlg(PlotDlgMsg::LoadStyle))
.style(btn(false))
.padding([4, 10]),
button(text("Clear").size(11))
.on_press(Message::PlotDlg(PlotDlgMsg::ClearStyle))
button(text("Save…").size(11))
.on_press(Message::PlotDlg(PlotDlgMsg::SaveStyle))
.style(btn(false))
.padding([4, 10]),
]
@ -720,62 +722,53 @@ pub fn view_window(
section_label("Shaded viewport options"),
drop_row(
"Shade plot",
strs(&["As displayed", "Wireframe"]),
strs(&[
"As displayed",
"2D Wireframe",
"3D Wireframe",
"Hidden Line",
"Flat Shaded",
"Gouraud Shaded",
"Flat Shaded + Edges",
"Gouraud Shaded + Edges",
]),
Some(s.shade.clone()),
PlotDlgMsg::Shade,
width,
),
drop_row(
"Quality",
strs(&["Draft", "Preview", "Normal", "Presentation", "Maximum", "Custom"]),
strs(&["Low", "Normal", "High"]),
Some(s.quality.clone()),
PlotDlgMsg::Quality,
width,
),
field_row_enabled("DPI", &s.dpi, PlotDlgMsg::Dpi, 70, s.quality == "Custom"),
text("Vector PDF stays resolution-independent; quality controls printer rasterization.")
.size(10)
.style(muted_style)
.width(width),
text("Hidden-line and rendered raster modes need a raster viewport backend.")
.size(10)
.style(muted_style)
.width(width),
].spacing(7));
// ── Output options and orientation ────────────────────────────────────
let paper_order_option: Element<'_, Message> = if s.paper_space {
check(
"Paper space last",
s.paperspace_last,
PlotFlag::PaperspaceLast,
)
} else {
Space::new().height(0).into()
};
let options_panel = panel(column![
section_label("Plot options"),
row![
column![
check_static("Plot in background", true),
check("Plot in background", s.background, PlotFlag::Background),
check("Object lineweights", s.lineweights, PlotFlag::Lineweights),
check_enabled(
"Plot with styles",
s.with_styles,
PlotFlag::WithStyles,
!s.style_name.is_empty(),
),
check("Monochrome", s.mono, PlotFlag::Mono),
check("Plot transparency", s.transparency, PlotFlag::Transparency),
]
.spacing(6)
.width(width),
column![
check_enabled(
"Paper space last",
s.paperspace_last,
PlotFlag::PaperspaceLast,
s.paper_space,
),
check_static("Hide paper objects (unavailable)", false),
paper_order_option,
check("Merge overlapping lines", s.merge_lines, PlotFlag::MergeLines),
check("Plot stamp", s.stamp, PlotFlag::Stamp),
check_enabled(
"Save changes to layout",
s.save_layout,
PlotFlag::SaveLayout,
s.paper_space,
),
]
.spacing(6)
.width(width),
@ -784,7 +777,6 @@ pub fn view_window(
].spacing(7));
let orientation_panel = panel(column![
section_label("Drawing orientation"),
drop_row(
"Orientation",
strs(&["Portrait", "Landscape"]),