Feat: implement CTB/STB plot style table with PDF export integration
- src/io/plot_style.rs: PlotStyleTable and PlotStyleEntry data structures; CTB reader (deflate/zlib decompressed text format) with ACI→RGB and ACI→lineweight lookup; CTB writer (zlib compressed); LW_TABLE constant - src/io/pdf_export.rs: accept optional PlotStyleTable; apply ACI color and lineweight overrides during PDF rendering - src/scene/wire_model.rs: add `aci: u8` field to carry ACI index - src/scene/render.rs: render_style() now returns ACI alongside RGBA - src/scene/mod.rs + tessellate.rs: propagate aci through wire building - app: PlotStyleLoad/Loaded/Clear messages and handlers; PLOTSTYLE LOAD | CLEAR | STATUS command; active_plot_style state on H7CAD - flate2 added as dependency Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
911e36ba04
commit
1db3ad6a33
11 changed files with 429 additions and 28 deletions
|
|
@ -4,6 +4,7 @@
|
|||
// Default save format: DWG (AC1032 / R2018+).
|
||||
|
||||
pub mod pdf_export;
|
||||
pub mod plot_style;
|
||||
|
||||
use acadrust::io::dwg::DwgReader;
|
||||
use acadrust::{CadDocument, DwgWriter, DxfReader, DxfWriter};
|
||||
|
|
@ -97,6 +98,21 @@ pub async fn pick_save_path() -> Option<PathBuf> {
|
|||
dlg.save_file().await.map(|h| h.path().to_path_buf())
|
||||
}
|
||||
|
||||
// ── Plot Style Table ──────────────────────────────────────────────────────
|
||||
|
||||
/// 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 = rfd::AsyncFileDialog::new()
|
||||
.set_title("Load Plot Style Table")
|
||||
.add_filter("Plot Style Tables", &["ctb", "stb", "CTB", "STB"])
|
||||
.add_filter("CTB Files", &["ctb", "CTB"])
|
||||
.add_filter("STB Files", &["stb", "STB"])
|
||||
.add_filter("All Files", &["*"])
|
||||
.pick_file()
|
||||
.await?;
|
||||
plot_style::PlotStyleTable::load(handle.path()).ok()
|
||||
}
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Save the document to the given path.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
// drawing origin at the paper origin.
|
||||
|
||||
use crate::scene::WireModel;
|
||||
use crate::io::plot_style::PlotStyleTable;
|
||||
use printpdf::{Color, Line, LineCapStyle, LineJoinStyle, LinePoint, Mm, Op, PdfDocument,
|
||||
PdfPage, PdfSaveOptions, Point, Pt, Rgb};
|
||||
use std::io::Write;
|
||||
|
|
@ -30,8 +31,9 @@ pub fn export_pdf(
|
|||
offset_y: f32,
|
||||
rotation_deg: i32,
|
||||
path: &Path,
|
||||
plot_style: Option<&PlotStyleTable>,
|
||||
) -> Result<(), String> {
|
||||
let bytes = build_pdf(wires, paper_w as f32, paper_h as f32, offset_x, offset_y, rotation_deg);
|
||||
let bytes = build_pdf(wires, paper_w as f32, paper_h as f32, offset_x, offset_y, rotation_deg, plot_style);
|
||||
let mut file = std::fs::File::create(path).map_err(|e| e.to_string())?;
|
||||
file.write_all(&bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
|
@ -50,7 +52,7 @@ pub async fn pick_pdf_path_owned(stem: String) -> Option<std::path::PathBuf> {
|
|||
|
||||
// ── PDF builder ───────────────────────────────────────────────────────────
|
||||
|
||||
fn build_pdf(wires: &[WireModel], paper_w: f32, paper_h: f32, ox: f32, oy: f32, rotation_deg: i32) -> Vec<u8> {
|
||||
fn build_pdf(wires: &[WireModel], paper_w: f32, paper_h: f32, ox: f32, oy: f32, rotation_deg: i32, plot_style: Option<&PlotStyleTable>) -> Vec<u8> {
|
||||
let mut doc = PdfDocument::new("H7CAD Export");
|
||||
let mut ops: Vec<Op> = Vec::new();
|
||||
|
||||
|
|
@ -93,6 +95,10 @@ fn build_pdf(wires: &[WireModel], paper_w: f32, paper_h: f32, ox: f32, oy: f32,
|
|||
|
||||
let mut last_color: Option<[f32; 3]> = None;
|
||||
let mut last_lw: Option<f32> = None;
|
||||
// mm to PDF points (1 mm = 2.834645 pt).
|
||||
const MM_TO_PT: f32 = 2.834645;
|
||||
// Screen px to PDF points (approximate at 96 dpi).
|
||||
const PX_TO_PT: f32 = 0.35278;
|
||||
|
||||
for wire in wires {
|
||||
let [mut r, mut g, mut b, a] = wire.color;
|
||||
|
|
@ -103,15 +109,29 @@ fn build_pdf(wires: &[WireModel], paper_w: f32, paper_h: f32, ox: f32, oy: f32,
|
|||
if wire.name == "__paper_boundary__" {
|
||||
continue;
|
||||
}
|
||||
// Near-white and near-yellow (viewport active border) → dark grey for print.
|
||||
let is_light = r > 0.80 && g > 0.80 && b > 0.80;
|
||||
let is_yellow = r > 0.80 && g > 0.70 && b < 0.30;
|
||||
let is_cyan = r < 0.30 && g > 0.70 && b > 0.70;
|
||||
if is_light || is_yellow {
|
||||
r = 0.0; g = 0.0; b = 0.0;
|
||||
} else if is_cyan {
|
||||
// Viewport border: print as dark blue.
|
||||
r = 0.0; g = 0.15; b = 0.50;
|
||||
// Apply CTB plot style table overrides (color + lineweight).
|
||||
let mut lw_override: Option<f32> = None;
|
||||
if let Some(ctb) = plot_style {
|
||||
if wire.aci > 0 {
|
||||
if let Some([cr, cg, cb]) = ctb.resolve_color(wire.aci) {
|
||||
r = cr; g = cg; b = cb;
|
||||
}
|
||||
lw_override = ctb.resolve_lineweight(wire.aci)
|
||||
.map(|mm| (mm * MM_TO_PT).max(0.1));
|
||||
}
|
||||
}
|
||||
// Near-white and near-yellow (viewport active border) → dark grey for print
|
||||
// (only when no CTB override was applied).
|
||||
if lw_override.is_none() {
|
||||
let is_light = r > 0.80 && g > 0.80 && b > 0.80;
|
||||
let is_yellow = r > 0.80 && g > 0.70 && b < 0.30;
|
||||
let is_cyan = r < 0.30 && g > 0.70 && b > 0.70;
|
||||
if is_light || is_yellow {
|
||||
r = 0.0; g = 0.0; b = 0.0;
|
||||
} else if is_cyan {
|
||||
// Viewport border: print as dark blue.
|
||||
r = 0.0; g = 0.15; b = 0.50;
|
||||
}
|
||||
}
|
||||
|
||||
if last_color.map(|c| {
|
||||
|
|
@ -123,8 +143,9 @@ fn build_pdf(wires: &[WireModel], paper_w: f32, paper_h: f32, ox: f32, oy: f32,
|
|||
last_color = Some([r, g, b]);
|
||||
}
|
||||
|
||||
// Line weight: screen px → points (approximate 1 px ≈ 0.35 pt).
|
||||
let lw_pt = (wire.line_weight_px * 0.35278_f32).max(0.1);
|
||||
// Line weight: CTB override (in pt) or screen px → points.
|
||||
let lw_pt = lw_override
|
||||
.unwrap_or_else(|| (wire.line_weight_px * PX_TO_PT).max(0.1));
|
||||
if last_lw.map(|l| (l - lw_pt).abs() > 0.01).unwrap_or(true) {
|
||||
ops.push(Op::SetOutlineThickness { pt: Pt(lw_pt) });
|
||||
last_lw = Some(lw_pt);
|
||||
|
|
|
|||
270
src/io/plot_style.rs
Normal file
270
src/io/plot_style.rs
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
//! Plot Style Table — CTB (color-based) and STB (named) file support.
|
||||
//!
|
||||
//! CTB files map AutoCAD Color Index (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.
|
||||
//!
|
||||
//! STB files follow the same format but use named styles instead of
|
||||
//! ACI indices; they are read into a `Vec<NamedPlotStyle>`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
|
||||
// ── AutoCAD standard lineweight table (index → mm) ───────────────────────────
|
||||
|
||||
/// Lineweight table: index value → mm, matching AutoCAD's 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,
|
||||
];
|
||||
|
||||
// ── Per-color entry ───────────────────────────────────────────────────────────
|
||||
|
||||
/// 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 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.
|
||||
pub lineweight: u8,
|
||||
/// Screen percentage 0–100 (100 = opaque).
|
||||
pub screening: u8,
|
||||
}
|
||||
|
||||
impl Default for PlotStyleEntry {
|
||||
fn default() -> Self {
|
||||
PlotStyleEntry {
|
||||
description: String::new(),
|
||||
color: None,
|
||||
lineweight: 255, // use object lineweight
|
||||
screening: 100,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
/// 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).
|
||||
#[allow(dead_code)]
|
||||
pub fn identity(name: impl Into<String>) -> Self {
|
||||
PlotStyleTable {
|
||||
name: name.into(),
|
||||
is_stb: false,
|
||||
aci_entries: (0..=255).map(|_| PlotStyleEntry::default()).collect(),
|
||||
named_entries: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
let is_stb = name.to_lowercase().ends_with(".stb");
|
||||
let text = decompress_ctb(&raw)?;
|
||||
parse_plot_style_text(&text, name, is_stb)
|
||||
}
|
||||
|
||||
/// 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 entry.lineweight == 255 {
|
||||
None
|
||||
} else {
|
||||
LW_TABLE.get(entry.lineweight as usize).copied()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal serialisation ────────────────────────────────────────────
|
||||
|
||||
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");
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
// ── Deflate helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/// 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]) {
|
||||
use flate2::read::ZlibDecoder;
|
||||
ZlibDecoder::new(payload)
|
||||
.read_to_string(&mut text)
|
||||
.map_err(|e| format!("zlib decompress: {e}"))?;
|
||||
} else {
|
||||
use flate2::read::DeflateDecoder;
|
||||
DeflateDecoder::new(payload)
|
||||
.read_to_string(&mut text)
|
||||
.map_err(|e| format!("deflate decompress: {e}"))?;
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// 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();
|
||||
out.extend_from_slice(&compressed);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ── Text parser ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_plot_style_text(
|
||||
text: &str,
|
||||
name: String,
|
||||
is_stb: bool,
|
||||
) -> Result<PlotStyleTable, String> {
|
||||
let mut aci_entries: Vec<PlotStyleEntry> = (0..=255).map(|_| PlotStyleEntry::default()).collect();
|
||||
let mut named_entries: HashMap<String, PlotStyleEntry> = HashMap::new();
|
||||
let mut style_index: usize = 1; // CTB: 1-based ACI index
|
||||
let mut current: Option<PlotStyleEntry> = None;
|
||||
let mut current_name: String = String::new();
|
||||
|
||||
for line in text.lines() {
|
||||
let line = line.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 {
|
||||
named_entries.insert(current_name.clone(), entry);
|
||||
} else if style_index <= 255 {
|
||||
aci_entries[style_index] = entry;
|
||||
style_index += 1;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
"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>() {
|
||||
// AutoCAD packs RGB as 0xC0RRGGBB negative int.
|
||||
// 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PlotStyleTable { name, is_stb, aci_entries, named_entries })
|
||||
}
|
||||
Loading…
Reference in a new issue