cad-editor/src/io/plot_style.rs
Hakan Seven 325cd2474c 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
2026-07-31 13:41:34 +03:00

664 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Plot Style Table — CTB (color-based) and STB (named) file support.
//!
//! CTB files map indexed drawing colors (ACI, 1-255) to pen properties:
//! RGB color override, lineweight, and screeing percentage.
//!
//! 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::{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.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 ───────────────────────────────────────────────────────────
/// A single entry in a CTB or STB plot style table.
#[derive(Debug, Clone)]
pub struct PlotStyleEntry {
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 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: 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,
}
}
}
// ── Plot Style Table ──────────────────────────────────────────────────────────
/// A loaded CTB or STB plot style table.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct PlotStyleTable {
/// File name (without path), e.g. "monochrome.ctb".
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.
pub named_entries: HashMap<String, PlotStyleEntry>,
}
impl PlotStyleTable {
/// Create an identity CTB table (no overrides for any color).
pub fn identity(name: impl Into<String>) -> Self {
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(),
}
}
/// Load a CTB or STB file from disk.
pub fn load(path: &Path) -> Result<Self, String> {
let raw = std::fs::read(path).map_err(|e| e.to_string())?;
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
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> {
let text = self.to_text();
let compressed = compress_ctb(text.as_bytes())?;
std::fs::write(path, compressed).map_err(|e| e.to_string())
}
/// Resolve the effective print RGB color for the given ACI index.
/// Returns None if no override (use object color).
pub fn resolve_color(&self, aci: u8) -> Option<[f32; 3]> {
let entry = self.aci_entries.get(aci as usize)?;
entry
.color
.map(|[r, g, b]| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0])
}
/// Resolve the effective lineweight in mm for the given ACI index.
/// 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 matches!(entry.lineweight, 0 | 255) {
None
} else {
self.lineweights.get(entry.lineweight as usize).copied()
}
}
/// Resolve the screening factor for the given ACI index.
pub fn resolve_screening(&self, aci: u8) -> f32 {
self.aci_entries
.get(aci as usize)
.map(|entry| entry.screening.min(100) as f32 / 100.0)
.unwrap_or(1.0)
}
// ── Internal serialisation ────────────────────────────────────────────
fn to_text(&self) -> String {
let mut s = String::new();
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
}
}
// ── Deflate helpers ───────────────────────────────────────────────────────────
/// Decompress a CTB/STB file's raw bytes into the text content.
///
fn decompress_ctb(data: &[u8]) -> Result<String, String> {
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_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 {
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}"))?;
}
}
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;
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 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}");
continue;
}
if line == "end_plot_style" {
if let Some(entry) = current.take() {
if is_stb {
table.named_entries.insert(current_name.clone(), entry);
} else if style_index <= 255 {
table.aci_entries[style_index] = entry;
style_index += 1;
}
}
continue;
}
let Some(entry) = current.as_mut() else {
continue;
};
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" => 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(table)
}