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:
parent
6705e65e81
commit
325cd2474c
11 changed files with 1125 additions and 584 deletions
BIN
assets/plotstyles/monochrome.ctb
Normal file
BIN
assets/plotstyles/monochrome.ctb
Normal file
Binary file not shown.
BIN
assets/plotstyles/ocad.ctb
Normal file
BIN
assets/plotstyles/ocad.ctb
Normal file
Binary file not shown.
|
|
@ -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
|
|
@ -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}")),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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 0–100 (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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"));
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
|
|
|
|||
Loading…
Reference in a new issue