From 325cd2474cbac94f339b2a28ddbe5232f7aa52eb Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 31 Jul 2026 13:41:34 +0300 Subject: [PATCH] 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 --- assets/plotstyles/monochrome.ctb | Bin 0 -> 4315 bytes assets/plotstyles/ocad.ctb | Bin 0 -> 4154 bytes src/app/mod.rs | 13 +- src/app/update/file.rs | 694 ++++++++++++++++++------------- src/app/update/mod.rs | 33 +- src/io/mod.rs | 14 +- src/io/pdf_export.rs | 73 ++-- src/io/plot_style.rs | 607 +++++++++++++++++++++------ src/io/print_to_printer.rs | 49 +-- src/scene/mod.rs | 48 ++- src/ui/window/plot.rs | 210 +++++----- 11 files changed, 1141 insertions(+), 600 deletions(-) create mode 100644 assets/plotstyles/monochrome.ctb create mode 100644 assets/plotstyles/ocad.ctb diff --git a/assets/plotstyles/monochrome.ctb b/assets/plotstyles/monochrome.ctb new file mode 100644 index 0000000000000000000000000000000000000000..a4c0cd93bd5dcc66a379540417bd6b432a98dd1f GIT binary patch literal 4315 zcmd^?c~lc=x`$Z?>?V!7MA;&uj?*LrX;cQC!T@f7K)WD;AvDsZVAzyJ0Rc4zu+;{~ zu^75Vszd}?T8NOSK+vFqmY7HgtAJ@_7ib7OMsD(z_qKD-xqZ(3eg8|Up7-}Y?^|D; zoOtYE?A+tJ!*j>J{d?Tq{H!glsE!95R9`nz1HwYWPrwlo`g-9ZvB5!y1H!(C1H`9} zTkY7RsgboC-nHc|Y+v)ild*$@lgtWbD zM$uK>H6->kwfB4FK9x%E?M)AxN|)9RwNK!6?o;W5Kl_Ca4^MQ+I_D=-DQ;rHmZ8Uq z9dCr&5*~;sNzyv0R4JHR@bN=U)raZo{>ecF-qkxeSDuU~TArMro#~#HwkxE{I>F%G zMJMUvLma=4iMfjKiT0_~sZ_sTj@xx9h1Xw=B_wXkteKjV_HV1~%yRFYQGPsOM<1DL zlE%bWRb{?vn^P(yJBG@VCJ%pnF`8c1Il7gd^`TnTw=cxjb>QT$b)*>``D9DZ~H z)j7M4aZw9jL%Zl@5X5{ECs{+iSV}$2yag}$pNqWR_!?w~w?PTDo|giz@L0=M=-9>5ti{0;9V0*5k>%wLkZGjVGH z^&XtzgS7Bo((qs=U|n#Y7m@=rsdzn)ioiik1Dsb%y$CbCk*8G& zqy7xHcq4VZU>bge$w%OOR3DtjM2g^+)BL{h65?AW+(N@Uc<&K7j2Vsd1k`f4#RqBU zy{F-!%t-_mP~&i(52C{2>-+Oa_l8$SLqobICpqGB#)6Kw%fcjAwoJb?itN*eCUi~W zIO0mig3h;B!z8b_OusjZoYsewx+aO7-mOW;uRp7?ojhH$ec`g1mCyaxQyl;5+8sbm zesKD$9;=Mh*TzQ_8?RSKLeK7rkM^BOau4iFNje{YocfhGH^30e#W;r09W2`rD!{4@ zp(3oy5W0(L8$l(Qxe;^^b2Ng=umB@SfN_ihngeT`6{qbg z#@OzOe7~{XGkJuuU9J48vE2*#J!89VvZ(|3=1%UnlO$D z)PiN3Ky6sH3Dk~tnLwSGwkagX%uS&#%+WN$c7<_`mN=JV3iV^zrqCc(Z3+!xU8c|o zrcHrHF>?x}z#J*i1QtMnrZ5f#n!&OuF17;W1!Qq<7X_NfNE$*-)Ivk3jk;(Ebx^j3 zZ~=NzL--|HpdlooxQ0+49n=smLP?rzTc*h_3vsTCrf>zy))bP_i<-h!Xo05C2*ovp zCg`B1kb;u5gl4FPme3q^(Gsph*;+ofb0*35;@ko)p#_R-32Eq{mXMB;w1pc{3vJ;y zsEf966Ux>WZb2_<3;&81XbZQZxOSXvrfH2d9QxbByaWfC(oAUYG?hI4adA84*Ez?? z#FUXmum9-c>g#B#d1~EdNUoPtX(1lXmW^2B+ zzdn&#onSjesZoY^*f0Dbf0AfC6Q7teyl7Nu_R#*xRC3?Pw2dCMXU%T7|0j;1zqO8= z-S+2-{*Q}0DL3aFPyc)DA6)F&enQdTbX7Dxq1pAKc1S!U?@>Gz?rf`>O1=DadNE&7UX@t%&iPR7%-C=JL)D&dXF5|)E()XY z)2GWW7pLaz5!m<#h2=c2secw4WbP7Y5%R&SwSg9LSaoqP#pUPnoI2`IQRz%Tkc*&{ zn^Sp>f0`OHgz$N#g8IhPoRIeV&B&x@>tso3rBCat`VQwdsXV%mAHD5q>yKR_N$1Y# zrRX&J{HitnSYxgBAPv);4b~8yok03D;-a>*{8v=xPdKau*RS}7z|j84DeTpIcP*76JFTVj#}tGHuq-hBD|mF=oe zOX<6_$mc6mNvt%8{zc5=SeJ^`@vOyT=O&O-jM<9S?{o%fqvytB7`4Ag6$+k^>U(v} z=&E+)XAu3;JDoW!=?N(yi@XyQ`oC9*cB}iOny#>qa{_%X#j{pES7&fb#3Xaj$Z9gi zf9+=>s!pQjUhWm3vvRJ&>K|lvtNmHz^<(Fh+rob3-Vu|Q5v3C`{%b(#ZHVrwmYnB1 zj1KAnT~R|X_W}s{*KCE=mCr&{L-`LVM1+xRJnLUzD((X@>6@6x3D;Ep-}G`bh*DMm ztDrO@i+qF_Ygi$2RgXotC0EJSpO(^JszQjdh!EBOtfpf8*8{g6i2jl=s_*4;E4~}~ zHSLstze1#b221JGc$OE@KSxX=fw79mWBiG=A`qRbF0K0WzR}yeKMzqoDzDj!;xypq zl0`Nk+=dh)XIAkRE0>C5)mqnq{*4g5kSJ~Kkv%^z?HFszGZ@KM zg{a&LRc^#sWnz*A;ig)-IlVb?RzVOPqul~w&~V$4y8}1Sjs1s)8?_^jjr|iL+R^69sQ3Rr7U6j zPTtq&)`qO!b1I72y?ME%;H;Hg2LF^dE*`g!i>g|8#xn4nUZ02W?F-IYdr6UbmQttg zv$wynJCGi9b00&hW0H^CeA|NF;Ve{FotM}+BoA2cJ>VATj5$VStpA#3Qz^k7x-Pr6 z>ZfBM#$lm;D^~fiFnOmuSy$(^`W|u?!@p++^$r&E!Am!USY#IKBfw z&jLMG0cdwNgH1q}b)OQDonhpzxG1LSWs#}=K(^XI@2pK=np$pyaXsn;yd}Firv2)X zL~Hf)ZviO7Ch#hNPS>Lz0P=9=xDn9T`p0qrRALi|Ob+n84Y@vs|8xN^u|X0#a9Meq z@f5h2W$4nRV5*~r%l85(KS;6(K+U%q?F4jP_c8%R82-}@K&?TNtwS<+)J9KXlXnBS zypy8wz~wi0j@E!$*ny#Mmj_%(oANLKIW?dc02KZ$$C`k4>CY07`KG*E0J_zHUTZDD zy^k$t9rJJnE_rttTcXsD(?3^tq5%k_hBgO)%a6xA_5mm`pW#kG`ML*X04g*rJqRG- zF%K^QZ75(IEjyH+Bim3YL;1jE@gYtRa1r_HzbF90@tgYY0++8EQ4xUByf_Af;4uW~ z|4;~^Yn%Fb019qIUlB>(oSp2umKH_d+7>bH02i!Gx3Uqqv=|=n0WK%ll2G6>UBq}y zK=*Y`ngG;o*zk7%-DFE50d)TtMhgLz>&7>+d<|IHRxHj7;8KZR$h1+v0UIis&}pFH z;l~*!JQMT>?gCHE&4G`Ar*Sj-r>*LDLGm5P^gS5nyncSMW7LK9x5E8NzRjMQ-;?>v zErT(V3JY}5Bl9I(lYAE&GcL)ePqa|B?x!>nmlo-tJg{`{Zn?E&d6Ae`F*C&rpg3QvfAqP7zxahFJgh2(k`YG* zBdo2{pfcp>)sprB`UbbyHrClLksGDSDS< gZS!y+`+37~-hO6}MT`=6Dc3Gtm@t)CAUDST3omGkVgLXD literal 0 HcmV?d00001 diff --git a/assets/plotstyles/ocad.ctb b/assets/plotstyles/ocad.ctb new file mode 100644 index 0000000000000000000000000000000000000000..dd11428d27bfbe42b059a308111935d2c2211065 GIT binary patch literal 4154 zcmeH_eN<9;8pkyoYDm;^))MfIiP~LXYCZqYJ zqozZdOonBOY1c|ql2)8dlW9`)8j6lNR;ZY7h}d%W%)ZR-wmbXhp0oda&%Mw6obT`R zd(QRVPuRELYwxkWM~?3Q{7?XH%cu54pFO(QZT7)5ASO2S+&Kh121ukvoDQaiga&&Y z#mz_lR$p($SWl1n%^0sMI=Jtn3-vFLn#d)0eHT)%W-456>gkV!9PBU!9DB%ZS+Spf zxIDQdS>l>}wRd8JAd`@T;{GwX>J@*Vcxq)ic0oPEdf>iO&U(8r{&sX>NINEZ{aQOV z!AxCI&(A3mo0AfmODt9j^S)I;O@LK52Wsk^NYp$NtLYj@IrgyT#qwM0cT0-t*m(cw zW%c~z(ySUb#Na^Va&_j+PwW@b4?Gd8=# zyw@^FPg!{9X3<<_srEDwz8O$9=5WYQypGazIk_cTB-h~)sgV8%mq8V&zEl| zWS=k1+_WxgeUbg_Ibnu?;&)pZ7W7@)UxwAc7!0;X*M6k=r0SQmzem~&L#rwO9J954n!;QE;zKp1vM}_<`(kFwS_Av1uP+NjYbgIN z=BL6mjK4>U3PY<(n2ymI0Ia`vhH)xC-b zMs^Hlz4(TuTq%s@G`dyG6Giy^CH z3)3wevt7-qps$UmSbh^?f9qqKX*tKtqgfsF{9=T}0ezRb>t-tRofxLqq9^sgfo1o2fIY zVy)#AP1L$^1)=`s!EfSg_khKi%TMPsTC5wi zk^9??B`@smsirj#Pvu?#H;m4s?k&f&nzi(rA^zAX=laZv<(Z+k+6is=-j_vD>aI)--&j z`+(i@IP`jMS*@BqQaDH-%1<3+X=hk1bz8q3Rce9#GmzScCQso`&s|K`szr>a9syQX zExQzGbk?X^wQ|w8Q)Tf^OHWuH70;No7N1W{#Zs+4+?#)4>f*OFtNmMJv0BYWWT_`E z%i_D~6>|H_g4|C$aV;K&RSDQHI|{s}E`~%~ePDfPUq!)8{W` z^LbqwemnVq!rMe2kMToZKp1|3f3zp_^hckr2l6xF9iMRR2Y$ECtY~im7V$Md?6)nEwr-g8_8A3-k%{hk>DGKvf%P_}39^{mFyE6fe2uv;mlFUG*0W=SQN?#dhOobZz z!Q(;DE+H@!2Az5j5TCWtefx&@L zT^GoQL;2}|1P|rwnqZ*i&(J%S-%PDUcb*~D4eV$51VP(`N?90mI1`YFz~C~#QzvBr z7;H7l@>BabiJ~=4LlbJ-RlDD;ZS!v zFph`1b~;v1gJBRQ6BrkP=gNR^ot*)+1prU#>^`J6>6U*u2-+l6 zejf(y%>;@?;AdsP1~c&Fs$T<*xIvp~z|A)Bvq0!t7x)Dn+L8|R;h`E^Y7-1R2~nGL z%SXu=K)v!t8yFP`-RS~v!l9b~4%&B?csg45i_96s6VcJFDTfq)IB=f%DbS)_+L4!$ z4dZM%>_WHA^}ih9Uh-(9rz$Wyp;8rST0p$M=l3|9rt+drSZ(j)EQX zI#n6GRkJ&wNZm z1?7bH^K1cuI6}zCU5k)jV3RUo$PK6pLqs+Lfpc{tS))ubsF2(stQbwlv^UT;qN2T`IrJep*^21@Fb3SX5`u+q+B+s0EYBLRbUa>n-I8CC(`FA z)5E9`uZXN0PS_f!j*jn5fCw@@f~&fQfj{G#KeOR`5$Tnff=WVrDO+%mIC3x}*99T{ zhE3wZknyMrcSQD11a8TRl!`Jn3T1GnS{8~za@ zy$VxMMQE>N3yu;;j%MU~BcuX0sS1X?j;ioMWcwm;C!9&ODAPJr$dkydNM~%cb4SN> z6J;cMR#$Wr1MlUU_uBCNi1cbqK{cWMHd{a;j!-gk&mg1?Y|eyjGXbb%I+d%c!@&Ew=KVJO5F-6H zrr6OYyT;2aGz`G})(?GAO<9 zH~D}3g=n%*{-YH`GJpAsFbQnjeL6nF!8YjlX3e_Z*gx(OZaw~{&gCn+mmiNZ#kHwS R+v91mu+$|FyQzfwKLK;JW(5EM literal 0 HcmV?d00001 diff --git a/src/app/mod.rs b/src/app/mod.rs index 46b3a99a6..ab7e2e036 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -647,8 +647,6 @@ pub(super) struct OpenCADStudio { mtext_click_time: Option, 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(), diff --git a/src/app/update/file.rs b/src/app/update/file.rs index eddcfada7..6dc15c7cc 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -43,6 +43,44 @@ where } } +fn plot_dialog_scale_factor(d: &crate::ui::window::plot::PlotDialogState) -> f64 { + d.scales + .iter() + .find(|(name, _)| name == &d.scale) + .map(|(_, factor)| *factor) + .or_else(|| { + let (paper, drawing) = parse_plot_scale(&d.scale); + (paper > 0.0 && drawing > 0.0).then_some(paper / drawing) + }) + .unwrap_or(1.0) + .max(1e-9) +} + +fn scale_name_for_factor(scales: &[(String, f64)], factor: f64) -> Option { + scales + .iter() + .find(|(_, candidate)| { + (*candidate - factor).abs() <= 1e-6 * factor.abs().max(1.0) + }) + .map(|(name, _)| name.clone()) +} + +fn plot_render_mode_override( + d: &crate::ui::window::plot::PlotDialogState, +) -> Option { + use acadrust::entities::ViewportRenderMode as Mode; + match d.shade.as_str() { + "2D Wireframe" => Some(Mode::Wireframe2D), + "3D Wireframe" => Some(Mode::Wireframe3D), + "Hidden Line" => Some(Mode::HiddenLine), + "Flat Shaded" => Some(Mode::FlatShaded), + "Gouraud Shaded" => Some(Mode::GouraudShaded), + "Flat Shaded + Edges" => Some(Mode::FlatShadedWithEdges), + "Gouraud Shaded + Edges" => Some(Mode::GouraudShadedWithEdges), + _ => None, + } +} + #[cfg(not(target_arch = "wasm32"))] fn native_paths_match(left: &std::path::Path, right: &std::path::Path) -> bool { match ( @@ -123,13 +161,14 @@ fn plot_content_extents( fn plot_scene_content( scene: &crate::scene::Scene, paper_space_last: bool, + render_mode_override: Option, ) -> ( std::sync::Arc>, Vec, Vec, crate::io::pdf_export::PlotGroupSplits, ) { - let (paper_wires, model_wires) = scene.plot_wire_groups(); + let (paper_wires, model_wires) = scene.plot_wire_groups(render_mode_override); let paper_hatches = scene.paper_canvas_hatches().as_ref().clone(); let paper_wipeouts = scene.paper_canvas_wipeouts().as_ref().clone(); if scene.current_layout == "Model" { @@ -1984,7 +2023,7 @@ pub(super) fn on_open_file(&mut self) -> Task { /// Write the given plot page settings into the active layout's Layout + /// PlotSettings objects (paper size, plot area, offset, rotation, scale). /// No-op on the Model tab (which has no paper layout). Marks the tab dirty - /// and re-tessellates the sheet. Called by the Plot dialog on commit. + /// and re-tessellates the sheet. Called by the Plot dialog's Set current action. #[allow(clippy::too_many_arguments)] pub(super) fn apply_plot_page_settings( &mut self, @@ -1995,151 +2034,133 @@ pub(super) fn on_open_file(&mut self) -> Task { offset_x: f64, offset_y: f64, rotation: i16, - scale_str: &str, ) { - let i = self.active_tab; - let dialog = self.plot_dialog.clone(); - let layout_name = self.tabs[i].scene.current_layout.clone(); - let plot_window = self.plot_window; - if layout_name != "Model" { - let w: f64 = w.max(1.0); - let h: f64 = h.max(1.0); - // Update the Layout object's limits AND its embedded - // PlotSettings fields. `paper_limits()` (sheet rendering) and - // the DWG writer both read these from the Layout, so a page - // setup that only touched a side PlotSettings object would not - // reflect on screen or survive a save. The dialog's w/h are - // the final sheet dimensions, so store them verbatim with no - // further rotation swap (#156). - for obj in self.tabs[i].scene.document.objects.values_mut() { - if let acadrust::objects::ObjectType::Layout(l) = obj { - if l.name == layout_name { - l.min_limits = (0.0, 0.0); - l.max_limits = (w, h); - l.min_extents = (0.0, 0.0, 0.0); - l.max_extents = (w, h, 0.0); - l.paper_width = w; - l.paper_height = h; - l.plot_rotation = 0; - l.plot_paper_units = 1; // millimetres - l.plot_origin_x = offset_x; - l.plot_origin_y = offset_y; - // Custom dimensions no longer match a named size. - l.paper_size = String::new(); - break; - } - } + let i = self.active_tab; + let dialog = self.plot_dialog.clone(); + let layout_name = self.tabs[i].scene.current_layout.clone(); + let plot_window = self.plot_window; + if layout_name != "Model" { + let w: f64 = w.max(1.0); + let h: f64 = h.max(1.0); + for obj in self.tabs[i].scene.document.objects.values_mut() { + if let acadrust::objects::ObjectType::Layout(l) = obj { + if l.name == layout_name { + l.min_limits = (0.0, 0.0); + l.max_limits = (w, h); + l.min_extents = (0.0, 0.0, 0.0); + l.max_extents = (w, h, 0.0); + l.paper_width = w; + l.paper_height = h; + l.plot_rotation = 0; + l.plot_paper_units = 1; + l.plot_origin_x = offset_x; + l.plot_origin_y = offset_y; + l.paper_size = String::new(); + break; } - - // Find or create the PlotSettings object for this layout. - use acadrust::objects::{ - ObjectType, PlotPaperUnits, PlotRotation, PlotSettings, PlotType, - ShadePlotMode, ShadePlotResolutionLevel, - }; - let plot_handle = - self.tabs[i] - .scene - .document - .objects - .iter() - .find_map(|(h, obj)| { - if let ObjectType::PlotSettings(ps) = obj { - if ps.page_name == layout_name { - Some(*h) - } else { - None - } - } else { - None - } - }); - - let ps_entry = if let Some(h) = plot_handle { - self.tabs[i].scene.document.objects.get_mut(&h) - } else { - // Create a new PlotSettings object and insert it. - let mut ps = PlotSettings::new(layout_name.clone()); - ps.handle = self.tabs[i].scene.document.allocate_handle(); - let h = ps.handle; - self.tabs[i] - .scene - .document - .objects - .insert(h, ObjectType::PlotSettings(ps)); - self.tabs[i].scene.document.objects.get_mut(&h) - }; - - if let Some(ObjectType::PlotSettings(ps)) = ps_entry { - ps.paper_width = w; - ps.paper_height = h; - ps.paper_size = dialog.paper.clone(); - ps.paper_units = PlotPaperUnits::Millimeters; - ps.plot_type = match plot_area { - "Window" => PlotType::Window, - "Display" => PlotType::LastScreenDisplay, - "Extents" => PlotType::Extents, - _ => PlotType::Layout, - }; - if plot_area == "Window" { - if let Some((x0, y0, x1, y1)) = plot_window { - ps.set_plot_window(x0, y0, x1, y1); - } - } - ps.flags.plot_centered = center && plot_area != "Layout"; - ps.origin_x = if plot_area == "Layout" { 0.0 } else { offset_x }; - ps.origin_y = if plot_area == "Layout" { 0.0 } else { offset_y }; - ps.rotation = match rotation { - 90 => PlotRotation::Degrees90, - 180 => PlotRotation::Degrees180, - 270 => PlotRotation::Degrees270, - _ => PlotRotation::None, - }; - // Apply plot scale. - use acadrust::objects::ScaledType; - let (num, den) = parse_plot_scale(scale_str); - if scale_str == "Fit" { - ps.set_scale_to_fit(); - } else { - ps.scale_type = ScaledType::CustomScale; - ps.scale_numerator = num; - ps.scale_denominator = den; - } - ps.printer_name = if dialog.to_file { - crate::ui::window::plot::OUT_PDF.into() - } else { - dialog.printer.clone().unwrap_or_default() - }; - ps.current_style_sheet = dialog.style_name.clone(); - ps.flags.scale_lineweights = dialog.scale_lw; - ps.flags.print_lineweights = dialog.lineweights; - ps.flags.plot_plot_styles = dialog.with_styles; - ps.flags.draw_viewports_first = dialog.paperspace_last; - ps.flags.plot_hidden = false; - ps.shade_plot_mode = match dialog.shade.as_str() { - "Wireframe" => ShadePlotMode::Wireframe, - _ => ShadePlotMode::AsDisplayed, - }; - ps.shade_plot_resolution = match dialog.quality.as_str() { - "Draft" => ShadePlotResolutionLevel::Draft, - "Preview" => ShadePlotResolutionLevel::Preview, - "Presentation" => ShadePlotResolutionLevel::Presentation, - "Maximum" => ShadePlotResolutionLevel::Maximum, - "Custom" => ShadePlotResolutionLevel::Custom, - _ => ShadePlotResolutionLevel::Normal, - }; - ps.shade_plot_dpi = - dialog.dpi.parse::().unwrap_or(300).clamp(1, 32767); - } - - self.tabs[i].dirty = true; - // The paper sheet fill is cached by epoch, while document - // entity tessellation is unaffected by the paper size. - self.tabs[i].scene.bump_geometry_no_blocks(); - self.command_line.push_info(&format!( - "Page setup: {w:.1}×{h:.1} mm area={plot_area} \ - center={center} rot={rotation}°" - )); } + } + + use acadrust::objects::{ + ObjectType, PlotPaperUnits, PlotRotation, PlotSettings, PlotType, ScaledType, + ShadePlotMode, ShadePlotResolutionLevel, + }; + let plot_handle = self.tabs[i] + .scene + .document + .objects + .iter() + .find_map(|(h, obj)| { + if let ObjectType::PlotSettings(ps) = obj { + (ps.page_name == layout_name).then_some(*h) + } else { + None + } + }); + + let ps_entry = if let Some(h) = plot_handle { + self.tabs[i].scene.document.objects.get_mut(&h) + } else { + let mut ps = PlotSettings::new(layout_name.clone()); + ps.handle = self.tabs[i].scene.document.allocate_handle(); + let h = ps.handle; + self.tabs[i] + .scene + .document + .objects + .insert(h, ObjectType::PlotSettings(ps)); + self.tabs[i].scene.document.objects.get_mut(&h) + }; + + if let Some(ObjectType::PlotSettings(ps)) = ps_entry { + ps.paper_width = w; + ps.paper_height = h; + ps.paper_size = dialog.paper.clone(); + ps.paper_units = PlotPaperUnits::Millimeters; + ps.plot_type = match plot_area { + "Window" => PlotType::Window, + "Display" => PlotType::LastScreenDisplay, + "Extents" => PlotType::Extents, + _ => PlotType::Layout, + }; + if plot_area == "Window" { + if let Some((x0, y0, x1, y1)) = plot_window { + ps.set_plot_window(x0, y0, x1, y1); + } + } + ps.flags.plot_centered = center && plot_area != "Layout"; + ps.origin_x = if plot_area == "Layout" { 0.0 } else { offset_x }; + ps.origin_y = if plot_area == "Layout" { 0.0 } else { offset_y }; + ps.rotation = match rotation { + 90 => PlotRotation::Degrees90, + 180 => PlotRotation::Degrees180, + 270 => PlotRotation::Degrees270, + _ => PlotRotation::None, + }; + if dialog.fit_to_paper && plot_area != "Layout" { + ps.set_scale_to_fit(); + } else { + let factor = if plot_area == "Layout" { + 1.0 + } else { + plot_dialog_scale_factor(&dialog) + }; + ps.scale_type = ScaledType::CustomScale; + ps.scale_numerator = factor; + ps.scale_denominator = 1.0; + } + ps.printer_name = if dialog.to_file { + crate::ui::window::plot::OUT_PDF.into() + } else { + dialog.printer.clone().unwrap_or_default() + }; + ps.current_style_sheet = dialog.style_name.clone(); + ps.flags.scale_lineweights = dialog.scale_lw; + ps.flags.print_lineweights = dialog.lineweights; + ps.flags.plot_plot_styles = !dialog.style_name.is_empty(); + ps.flags.draw_viewports_first = dialog.paperspace_last; + ps.flags.plot_hidden = false; + ps.shade_plot_mode = match dialog.shade.as_str() { + "2D Wireframe" | "3D Wireframe" => ShadePlotMode::Wireframe, + "Hidden Line" => ShadePlotMode::Hidden, + "As displayed" => ShadePlotMode::AsDisplayed, + _ => ShadePlotMode::Rendered, + }; + ps.shade_plot_resolution = match dialog.quality.as_str() { + "Low" => ShadePlotResolutionLevel::Draft, + "High" => ShadePlotResolutionLevel::Presentation, + _ => ShadePlotResolutionLevel::Normal, + }; + ps.shade_plot_dpi = 300; + } + + self.tabs[i].dirty = true; + self.tabs[i].scene.bump_geometry_no_blocks(); + self.command_line.push_info(&format!( + "Page setup: {w:.1}×{h:.1} mm area={plot_area} \ + center={center} rot={rotation}°" + )); + } } pub(in crate::app) fn on_plot_export_path_some( @@ -2151,9 +2172,8 @@ pub(super) fn on_open_file(&mut self) -> Task { let plot_style = self.dialog_plot_style(&self.plot_dialog); let render_options = Self::pdf_plot_options(&self.plot_dialog, group_splits); let worker_path = path.clone(); - background_task( - move || { - crate::io::pdf_export::export_pdf( + let work = move || { + crate::io::pdf_export::export_pdf( &wires, &hatches, &wipeouts, @@ -2170,9 +2190,8 @@ pub(super) fn on_open_file(&mut self) -> Task { ) .map(|_| format!("Exported: {}", worker_path.display())) .map_err(|e| format!("Export failed: {e}")) - }, - |result| Message::BackgroundIoFinished(result, false), - ) + }; + self.run_plot_work(self.plot_dialog.background, false, work) } /// Export the pending Extents/Window/Display area using the same clipped @@ -2197,9 +2216,8 @@ pub(super) fn on_open_file(&mut self) -> Task { let render_options = Self::pdf_plot_options(&self.plot_dialog, group_splits); let worker_path = path.clone(); self.close_active_modal(); - background_task( - move || { - crate::io::pdf_export::export_pdf( + let work = move || { + crate::io::pdf_export::export_pdf( &wires, &hatches, &wipeouts, @@ -2220,9 +2238,42 @@ pub(super) fn on_open_file(&mut self) -> Task { .file_name().unwrap_or_default().to_string_lossy() ) }).map_err(|e| format!("Plot failed: {e}")) - }, - |result| Message::BackgroundIoFinished(result, false), - ) + }; + self.run_plot_work(self.plot_dialog.background, false, work) + } + + pub(super) fn report_plot_result( + &mut self, + result: Result, + reopen_plot: bool, + ) { + match result { + Ok(message) => self.command_line.push_info(&message), + Err(error) => self.command_line.push_error(&error), + } + if reopen_plot { + self.active_modal = Some(crate::app::ModalKind::Plot); + } + } + + fn run_plot_work( + &mut self, + background: bool, + reopen_plot: bool, + work: F, + ) -> Task + where + F: FnOnce() -> Result + Send + 'static, + { + if background { + background_task(work, move |result| { + Message::BackgroundIoFinished(result, reopen_plot) + }) + } else { + let result = work(); + self.report_plot_result(result, reopen_plot); + Task::none() + } } /// Build the render inputs and page geometry for a full-layout plot: wires @@ -2234,7 +2285,11 @@ pub(super) fn on_open_file(&mut self) -> Task { let scene = &self.tabs[i].scene; let paper_space = scene.current_layout != "Model"; let (source_wires, hatches, wipeouts, mut group_splits) = - plot_scene_content(scene, self.plot_dialog.paperspace_last); + plot_scene_content( + scene, + self.plot_dialog.paperspace_last, + plot_render_mode_override(&self.plot_dialog), + ); // The printable-area rectangle is an on-screen guide, not drawing // content. It used to leak into every paper-space PDF/preview/print. let wires = if paper_space { @@ -2288,14 +2343,13 @@ pub(super) fn on_open_file(&mut self) -> Task { .unwrap_or((x0, y0, x1, y1)); let content_w = (max_x - min_x).max(1e-9); let content_h = (max_y - min_y).max(1e-9); - let scale = if self.plot_dialog.scale == "Fit" { + let scale = if self.plot_dialog.fit_to_paper { const MARGIN: f64 = 1.05; ((paper_w / MARGIN) / content_w) .min((paper_h / MARGIN) / content_h) .max(1e-9) } else { - let (num, den) = parse_plot_scale(&self.plot_dialog.scale); - ((num / den) * mm_per_unit).max(1e-9) + (plot_dialog_scale_factor(&self.plot_dialog) * mm_per_unit).max(1e-9) }; let target_x = if centered { (paper_w - content_w * scale) * 0.5 @@ -2505,12 +2559,29 @@ pub(super) fn on_open_file(&mut self) -> Task { /// layout's plot settings and the printers found on the system. pub(super) fn on_plot_dialog_open(&mut self) -> Task { use crate::io::paper_sizes::Orientation; + let scales: Vec<(String, f64)> = self.tabs[self.active_tab] + .scene + .scale_list() + .into_iter() + .map(|(name, _, factor)| (name, factor)) + .collect(); + let one_to_one = scale_name_for_factor(&scales, 1.0) + .or_else(|| scales.first().map(|(name, _)| name.clone())) + .unwrap_or_else(|| "1:1".into()); // Keep the user's last print preferences (printer, copies, quality, // output options — persisted in `self.plot_dialog`); only refresh the // runtime printer list and reseed the drawing-specific fields from the // active layout. let d = &mut self.plot_dialog; d.printers = crate::io::print_to_printer::list_printers(); + d.plot_styles = crate::io::plot_style::available_ctb_names(); + d.scales = scales; + if d.scale.eq_ignore_ascii_case("fit") { + d.fit_to_paper = true; + d.scale = one_to_one.clone(); + } else if !d.scales.iter().any(|(name, _)| name == &d.scale) { + d.scale = one_to_one; + } d.paper_space = self.tabs[self.active_tab].scene.current_layout != "Model"; d.paper = self.plot_format.label().to_string(); d.orientation = match self.plot_orientation { @@ -2518,14 +2589,32 @@ pub(super) fn on_open_file(&mut self) -> Task { Orientation::Landscape => "Landscape", } .to_string(); + d.quality = match d.quality.as_str() { + "Low" | "Draft" => "Low", + "High" | "Presentation" | "Maximum" | "Custom" => "High", + _ => "Normal", + } + .into(); + d.shade = match d.shade.as_str() { + "Wireframe" => "2D Wireframe", + value if value.starts_with("As displayed") => "As displayed", + "2D Wireframe" + | "3D Wireframe" + | "Hidden Line" + | "Flat Shaded" + | "Gouraud Shaded" + | "Flat Shaded + Edges" + | "Gouraud Shaded + Edges" => d.shade.as_str(), + _ => "As displayed", + } + .into(); d.style_name = self .active_plot_style .as_ref() .map(|t| t.name.clone()) .unwrap_or_default(); d.style_missing = false; - d.with_styles &= !d.style_name.is_empty(); - // `area` and `scale` are remembered user choices (default Window / Fit), + // `area`, scale and fit are remembered user choices, // not reseeded from the layout. Offset / center / rotation ARE layout // properties, so reflect them. if let Some(ps) = self.tabs[self.active_tab].scene.effective_plot_settings() { @@ -2569,7 +2658,7 @@ pub(super) fn on_open_file(&mut self) -> Task { self.plot_dialog.center = true; self.plot_dialog.offset_x = "0.0".into(); self.plot_dialog.offset_y = "0.0".into(); - self.plot_dialog.scale = "Fit".into(); + self.plot_dialog.fit_to_paper = true; } /// Handle one edit / action from the Plot dialog. @@ -2577,7 +2666,9 @@ pub(super) fn on_open_file(&mut self) -> Task { &mut self, msg: crate::ui::window::plot::PlotDlgMsg, ) -> Task { - use crate::ui::window::plot::{PlotDlgMsg as M, PlotFlag, OUT_DEFAULT, OUT_PDF}; + use crate::ui::window::plot::{ + PlotDlgMsg as M, PlotFlag, OUT_DEFAULT, OUT_PDF, STYLE_NONE, + }; match msg { M::Close => { self.close_active_modal(); @@ -2625,7 +2716,12 @@ pub(super) fn on_open_file(&mut self) -> Task { self.plot_dialog.center = false; self.plot_dialog.offset_x = "0.0".into(); self.plot_dialog.offset_y = "0.0".into(); - self.plot_dialog.scale = "1:1".into(); + self.plot_dialog.fit_to_paper = false; + self.plot_dialog.scale = scale_name_for_factor( + &self.plot_dialog.scales, + 1.0, + ) + .unwrap_or_else(|| "1:1".into()); } self.plot_dialog.area = s; Task::none() @@ -2637,15 +2733,7 @@ pub(super) fn on_open_file(&mut self) -> Task { Task::none() } M::Quality(s) => { - self.plot_dialog.quality = s.clone(); - self.plot_dialog.dpi = match s.as_str() { - "Draft" | "Preview" => "150", - "Presentation" => "600", - "Maximum" => "", - "Custom" => return Task::none(), - _ => "300", - } - .into(); + self.plot_dialog.quality = s; Task::none() } M::Shade(s) => { @@ -2664,39 +2752,50 @@ pub(super) fn on_open_file(&mut self) -> Task { self.plot_dialog.offset_y = s; Task::none() } - M::Dpi(s) => { - self.plot_dialog.dpi = s; - Task::none() - } M::Flag(f) => { let d = &mut self.plot_dialog; match f { + PlotFlag::Background => d.background = !d.background, + PlotFlag::MergeLines => d.merge_lines = !d.merge_lines, + PlotFlag::FitToPaper if d.area != "Layout" => { + d.fit_to_paper = !d.fit_to_paper + } PlotFlag::Center if d.area != "Layout" => d.center = !d.center, PlotFlag::ScaleLw if d.area != "Layout" => d.scale_lw = !d.scale_lw, PlotFlag::UpsideDown => { d.upside_down = !d.upside_down; } - PlotFlag::Mono => d.mono = !d.mono, PlotFlag::Lineweights => d.lineweights = !d.lineweights, - PlotFlag::WithStyles if !d.style_name.is_empty() => { - d.with_styles = !d.with_styles - } PlotFlag::Transparency => d.transparency = !d.transparency, PlotFlag::PaperspaceLast if d.paper_space => { d.paperspace_last = !d.paperspace_last } PlotFlag::Stamp => d.stamp = !d.stamp, - PlotFlag::SaveLayout if d.paper_space => d.save_layout = !d.save_layout, _ => {} } Task::none() } M::LoadStyle => Task::done(Message::PlotStyleLoad), - M::ClearStyle => { - self.active_plot_style = None; - self.plot_dialog.style_name.clear(); - self.plot_dialog.style_missing = false; - self.plot_dialog.with_styles = false; + M::SaveStyle => Task::done(Message::PlotStylePanelSave), + M::Style(name) => { + if name == STYLE_NONE { + self.active_plot_style = None; + self.plot_dialog.style_name.clear(); + self.plot_dialog.style_missing = false; + } else { + match crate::io::plot_style::PlotStyleTable::load_named(&name) { + Ok(table) => { + self.plot_dialog.style_name = table.name.clone(); + self.plot_dialog.style_missing = false; + self.active_plot_style = Some(table); + } + Err(error) => { + self.plot_dialog.style_name = name; + self.plot_dialog.style_missing = true; + self.command_line.push_error(&error); + } + } + } Task::none() } M::PickWindow => { @@ -2846,7 +2945,10 @@ pub(super) fn on_open_file(&mut self) -> Task { d.offset_x = "0.0".into(); d.offset_y = "0.0".into(); d.upside_down = false; - d.scale = "Fit".into(); + d.fit_to_paper = is_model; + d.scale = scale_name_for_factor(&d.scales, 1.0) + .or_else(|| d.scales.first().map(|(name, _)| name.clone())) + .unwrap_or_else(|| "1:1".into()); } else if name == SETUP_PREV { if let Some(prev) = self.plot_prev.clone() { self.plot_dialog.copy_settings_from(&prev); @@ -2935,13 +3037,13 @@ pub(super) fn on_open_file(&mut self) -> Task { }; if d.area == "Layout" { ps.set_standard_scale(ScaledType::OneToOne); - } else if d.scale == "Fit" { + } else if d.fit_to_paper { ps.set_scale_to_fit(); } else { - let (num, den) = parse_plot_scale(&d.scale); + let factor = plot_dialog_scale_factor(d); ps.scale_type = ScaledType::CustomScale; - ps.scale_numerator = num; - ps.scale_denominator = den; + ps.scale_numerator = factor; + ps.scale_denominator = 1.0; } ps.printer_name = if d.to_file { crate::ui::window::plot::OUT_PDF.into() @@ -2951,22 +3053,21 @@ pub(super) fn on_open_file(&mut self) -> Task { ps.current_style_sheet = d.style_name.clone(); ps.flags.scale_lineweights = d.scale_lw; ps.flags.print_lineweights = d.lineweights; - ps.flags.plot_plot_styles = d.with_styles; + ps.flags.plot_plot_styles = !d.style_name.is_empty(); ps.flags.draw_viewports_first = d.paperspace_last; ps.flags.plot_hidden = false; ps.shade_plot_mode = match d.shade.as_str() { - "Wireframe" => ShadePlotMode::Wireframe, - _ => ShadePlotMode::AsDisplayed, + "2D Wireframe" | "3D Wireframe" => ShadePlotMode::Wireframe, + "Hidden Line" => ShadePlotMode::Hidden, + "As displayed" => ShadePlotMode::AsDisplayed, + _ => ShadePlotMode::Rendered, }; ps.shade_plot_resolution = match d.quality.as_str() { - "Draft" => ShadePlotResolutionLevel::Draft, - "Preview" => ShadePlotResolutionLevel::Preview, - "Presentation" => ShadePlotResolutionLevel::Presentation, - "Maximum" => ShadePlotResolutionLevel::Maximum, - "Custom" => ShadePlotResolutionLevel::Custom, + "Low" => ShadePlotResolutionLevel::Draft, + "High" => ShadePlotResolutionLevel::Presentation, _ => ShadePlotResolutionLevel::Normal, }; - ps.shade_plot_dpi = d.dpi.parse::().unwrap_or(300).clamp(1, 32767); + ps.shade_plot_dpi = 300; ps } @@ -2983,11 +3084,33 @@ pub(super) fn on_open_file(&mut self) -> Task { ps.plot_window.upper_right_y, )); } - let style_loaded = !ps.current_style_sheet.is_empty() + if !ps.current_style_sheet.is_empty() && self - .active_plot_style - .as_ref() - .is_some_and(|table| table.name == ps.current_style_sheet); + .active_plot_style + .as_ref() + .is_none_or(|table| !table.name.eq_ignore_ascii_case(&ps.current_style_sheet)) + { + if let Ok(table) = crate::io::plot_style::PlotStyleTable::load_named( + &ps.current_style_sheet, + ) { + self.active_plot_style = Some(table); + } + } + let active_style_name = self.active_plot_style.as_ref().map(|table| table.name.clone()); + let style_name = if ps.current_style_sheet.is_empty() { + active_style_name.clone().unwrap_or_default() + } else if active_style_name + .as_deref() + .is_some_and(|name| name.eq_ignore_ascii_case(&ps.current_style_sheet)) + { + active_style_name.clone().unwrap_or_default() + } else { + ps.current_style_sheet.clone() + }; + let style_loaded = !style_name.is_empty() + && active_style_name + .as_deref() + .is_some_and(|name| name.eq_ignore_ascii_case(&style_name)); let (paper, orient) = paper_label_from_dims(ps.paper_width, ps.paper_height); let d = &mut self.plot_dialog; d.paper = paper; @@ -3015,44 +3138,38 @@ pub(super) fn on_open_file(&mut self) -> Task { .into(); } d.upside_down = matches!(deg, 180 | 270); - d.scale = if d.area == "Layout" { - "1:1".into() - } else if ps.is_scale_to_fit() { - "Fit".into() + d.fit_to_paper = d.area != "Layout" && ps.is_scale_to_fit(); + let target_factor = if d.area == "Layout" { + 1.0 + } else if ps.scale_denominator.abs() > 1e-9 { + ps.scale_numerator / ps.scale_denominator } else { - let n = ps.scale_numerator; - let m = ps.scale_denominator; - if (n - 1.0).abs() < 1e-9 { - format!("1:{}", m as i64) - } else if (m - 1.0).abs() < 1e-9 { - format!("{}:1", n as i64) - } else { - "Fit".into() - } + 1.0 }; + if !d.fit_to_paper || !d.scales.iter().any(|(name, _)| name == &d.scale) { + d.scale = scale_name_for_factor(&d.scales, target_factor) + .or_else(|| scale_name_for_factor(&d.scales, 1.0)) + .or_else(|| d.scales.first().map(|(name, _)| name.clone())) + .unwrap_or_else(|| "1:1".into()); + } d.scale_lw = ps.flags.scale_lineweights; d.lineweights = ps.flags.print_lineweights; - d.with_styles = ps.flags.plot_plot_styles; d.paperspace_last = ps.flags.draw_viewports_first; d.shade = match ps.shade_plot_mode { - ShadePlotMode::Wireframe => "Wireframe", - _ => "As displayed", + ShadePlotMode::Wireframe => "2D Wireframe", + ShadePlotMode::Hidden => "Hidden Line", + ShadePlotMode::Rendered => "Gouraud Shaded", + ShadePlotMode::AsDisplayed => "As displayed", } .into(); d.quality = match ps.shade_plot_resolution { - ShadePlotResolutionLevel::Draft => "Draft", - ShadePlotResolutionLevel::Preview => "Preview", - ShadePlotResolutionLevel::Presentation => "Presentation", - ShadePlotResolutionLevel::Maximum => "Maximum", - ShadePlotResolutionLevel::Custom => "Custom", + ShadePlotResolutionLevel::Draft => "Low", + ShadePlotResolutionLevel::Presentation + | ShadePlotResolutionLevel::Maximum + | ShadePlotResolutionLevel::Custom => "High", _ => "Normal", } .into(); - d.dpi = if d.quality == "Maximum" { - String::new() - } else { - ps.shade_plot_dpi.max(1).to_string() - }; if ps.printer_name.to_ascii_lowercase().contains("pdf") { d.to_file = true; d.printer = None; @@ -3060,7 +3177,7 @@ pub(super) fn on_open_file(&mut self) -> Task { d.to_file = false; d.printer = (!ps.printer_name.is_empty()).then(|| ps.printer_name.clone()); } - d.style_name = ps.current_style_sheet.clone(); + d.style_name = style_name; d.style_missing = !d.style_name.is_empty() && !style_loaded; } @@ -3083,11 +3200,6 @@ pub(super) fn on_open_file(&mut self) -> Task { }; self.plot_format = paper; self.plot_orientation = orient; - self.plot_scale = if d.area == "Layout" { - "1:1".into() - } else { - d.scale.clone() - }; } fn apply_dialog_to_layout(&mut self) { @@ -3106,15 +3218,13 @@ pub(super) fn on_open_file(&mut self) -> Task { off_x, off_y, rotation, - if d.area == "Layout" { "1:1" } else { &d.scale }, ); } /// Open a preview PDF, export a PDF, or send the job to the chosen printer. - /// The active layout changes only when "Save to layout" is checked. fn on_plot_dlg_commit(&mut self, preview: bool) -> Task { let d = self.plot_dialog.clone(); - if d.with_styles && d.style_missing { + if d.style_missing { self.command_line.push_error(&format!( "Plot style table '{}' is not loaded.", d.style_name @@ -3123,13 +3233,9 @@ pub(super) fn on_open_file(&mut self) -> Task { } // Remember the user's print preferences across sessions. self.save_config(); - // Preview and normal printing must not resize/mutate the live layout. - // Persist only when explicitly requested; the runtime paper/scale - // choices still drive this one plot operation. + // Preview and normal printing must not resize/mutate the live layout; + // the runtime paper/scale choices still drive this one plot operation. self.sync_dialog_plot_runtime(); - if !preview && d.save_layout { - self.apply_dialog_to_layout(); - } self.active_modal = None; let plot_style = self.dialog_plot_style(&d); @@ -3165,17 +3271,15 @@ pub(super) fn on_open_file(&mut self) -> Task { if preview { let tmp = std::env::temp_dir().join("open_cad_studio_preview.pdf"); let render_options = Self::pdf_plot_options(&d, wgroup_splits); - return background_task( - move || { - crate::io::pdf_export::export_pdf( + let work = move || { + crate::io::pdf_export::export_pdf( &w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, wrotation, wscale, wclip, &tmp, plot_style.as_ref(), render_options, ).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) .map(|_| "Opened plot preview.".to_string()).map_err(|e| format!("Preview failed: {e}")) - }, - |result| Message::BackgroundIoFinished(result, true), - ); + }; + return self.run_plot_work(d.background, true, work); } if d.to_file { // Tested clipped export (opens a save dialog). @@ -3184,18 +3288,16 @@ pub(super) fn on_open_file(&mut self) -> Task { let tmp = std::env::temp_dir().join("open_cad_studio_print.pdf"); let render_options = Self::pdf_plot_options(&d, wgroup_splits); let opts = self.plot_print_options(&d, wgroup_splits); - return background_task( - move || { - crate::io::pdf_export::export_pdf( + let work = move || { + crate::io::pdf_export::export_pdf( &w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, wrotation, wscale, wclip, &tmp, plot_style.as_ref(), render_options, ).and_then(|_| crate::io::print_to_printer::print_existing_pdf(&tmp, &opts)) .map(|printer| format!("Sent to printer: {printer}")) .map_err(|e| format!("Print failed: {e}")) - }, - |result| Message::BackgroundIoFinished(result, false), - ); + }; + return self.run_plot_work(d.background, false, work); } let (wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip) = @@ -3204,9 +3306,8 @@ pub(super) fn on_open_file(&mut self) -> Task { if preview { let tmp = std::env::temp_dir().join("open_cad_studio_preview.pdf"); let render_options = Self::pdf_plot_options(&d, group_splits); - return background_task( - move || { - crate::io::pdf_export::export_pdf( + let work = move || { + crate::io::pdf_export::export_pdf( &wires, &hatches, &wipeouts, @@ -3222,9 +3323,8 @@ pub(super) fn on_open_file(&mut self) -> Task { render_options, ).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) .map(|_| "Opened plot preview.".to_string()).map_err(|e| format!("Preview failed: {e}")) - }, - |result| Message::BackgroundIoFinished(result, true), - ); + }; + return self.run_plot_work(d.background, true, work); } if d.to_file { @@ -3234,16 +3334,16 @@ pub(super) fn on_open_file(&mut self) -> Task { let opts = self.plot_print_options(&d, group_splits); self.command_line.push_info("Sending to system printer…"); - background_task( - move || { - iced::futures::executor::block_on( - crate::io::print_to_printer::print_wires_with( + let work = move || { + iced::futures::executor::block_on( + crate::io::print_to_printer::print_wires_with( wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip, plot_style, opts, )) - }, - Message::PrintResult, - ) + .map(|printer| format!("Sent to printer: {printer}")) + .map_err(|error| format!("Print failed: {error}")) + }; + self.run_plot_work(d.background, false, work) } /// Build a [`PrintOptions`](crate::io::print_to_printer::PrintOptions) from @@ -3253,20 +3353,10 @@ pub(super) fn on_open_file(&mut self) -> Task { d: &crate::ui::window::plot::PlotDialogState, group_splits: crate::io::pdf_export::PlotGroupSplits, ) -> crate::io::print_to_printer::PrintOptions { - let dpi = match d.quality.as_str() { - "Draft" | "Preview" => Some(150), - "Normal" => Some(300), - "Presentation" => Some(600), - "Maximum" => None, - "Custom" => d.dpi.trim().parse::().ok().filter(|value| *value > 0), - _ => Some(300), - }; crate::io::print_to_printer::PrintOptions { printer: d.printer.clone(), copies: d.copies.trim().parse::().unwrap_or(1).max(1), - mono: d.mono, quality: Some(d.quality.clone()), - dpi, render: Self::pdf_plot_options(d, group_splits), } } @@ -3279,9 +3369,8 @@ pub(super) fn on_open_file(&mut self) -> Task { object_lineweights: d.lineweights, scale_lineweights: d.scale_lw, transparency: d.transparency, - monochrome: d.mono, - wireframe: d.shade == "Wireframe", stamp: d.stamp, + merge_lines: d.merge_lines, group_splits, } } @@ -3290,12 +3379,12 @@ pub(super) fn on_open_file(&mut self) -> Task { &self, d: &crate::ui::window::plot::PlotDialogState, ) -> Option { - if !d.with_styles || d.style_missing { + if d.style_name.is_empty() || d.style_missing { return None; } self.active_plot_style .as_ref() - .filter(|table| table.name == d.style_name) + .filter(|table| table.name.eq_ignore_ascii_case(&d.style_name)) .cloned() } @@ -3319,7 +3408,11 @@ pub(super) fn on_open_file(&mut self) -> Task { )); } let (wires, hatches, wipeouts, _) = - plot_scene_content(scene, self.plot_dialog.paperspace_last); + plot_scene_content( + scene, + self.plot_dialog.paperspace_last, + plot_render_mode_override(&self.plot_dialog), + ); let extents = plot_content_extents( &wires .iter() @@ -3400,17 +3493,17 @@ pub(super) fn on_open_file(&mut self) -> Task { let (sheet_w, sheet_h) = sheet_mm(self.plot_format, self.plot_orientation); let win_w = (x1 - x0).max(1e-9); let win_h = (y1 - y0).max(1e-9); - let scale_sel = if self.plot_scale.trim().eq_ignore_ascii_case("fit") { + let scale_sel = if self.plot_dialog.fit_to_paper { PlotScale::Fit } else { - let (num, den) = parse_plot_scale(&self.plot_scale); - if num > 0.0 && den > 0.0 { + let factor = plot_dialog_scale_factor(&self.plot_dialog); + if factor > 0.0 { let mm_per_unit = if self.tabs[i].scene.current_layout == "Model" { 1.0 } else { 1.0 / self.tabs[i].scene.paper_space_unit_factor().max(1e-9) }; - PlotScale::Ratio((num / den) * mm_per_unit) + PlotScale::Ratio(factor * mm_per_unit) } else { PlotScale::Fit } @@ -3430,7 +3523,11 @@ pub(super) fn on_open_file(&mut self) -> Task { let scene = &self.tabs[i].scene; let (wx0, wy0, wx1, wy1) = (x0 as f32, y0 as f32, x1 as f32, y1 as f32); let (all_wires, hatches, wipeouts, mut group_splits) = - plot_scene_content(scene, self.plot_dialog.paperspace_last); + plot_scene_content( + scene, + self.plot_dialog.paperspace_last, + plot_render_mode_override(&self.plot_dialog), + ); let first_wire_count = all_wires[..group_splits.wires.min(all_wires.len())] .iter() .filter(|w| w.name != "paper_printable_area") @@ -3532,13 +3629,16 @@ pub(super) fn on_open_file(&mut self) -> Task { .unwrap_or("export.ctb".into()); Task::perform( async move { - crate::sys::file_dialog() + let mut dialog = crate::sys::file_dialog() .set_title("Save Plot Style Table") .set_file_name(&default_name) - .add_filter("Plot Style Files", &["ctb", "stb", "CTB", "STB"]) - .add_filter("All Files", &["*"]) - .save_file() - .await + .add_filter("Plot Style Files", &["ctb", "CTB"]) + .add_filter("All Files", &["*"]); + #[cfg(not(target_arch = "wasm32"))] + if let Ok(dir) = crate::io::plot_style::ensure_plot_styles_dir() { + dialog = dialog.set_directory(dir); + } + dialog.save_file().await .map(|h| crate::sys::handle_path(&h)) }, Message::PlotStylePanelSavePath, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index db95ebbc7..95d45f5bd 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -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}")), } } diff --git a/src/io/mod.rs b/src/io/mod.rs index 6cb8005e2..957cdae47 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -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 { - 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() } diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 1c8ad36f0..df300f176 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -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, 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]); diff --git a/src/io/plot_style.rs b/src/io/plot_style.rs index f7fda1104..95934069e 100644 --- a/src/io/plot_style.rs +++ b/src/io/plot_style.rs @@ -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`. 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 { + 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 { + 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 { + #[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 = 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, /// For CTB: entries indexed by ACI (index 0 unused; 1..=255 are valid). pub aci_entries: Vec, // 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, raw: &[u8]) -> Result { + 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 { + 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 { + 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 { 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 { - // 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, String> { use flate2::{write::ZlibEncoder, Compression}; use std::io::Write; - let header = b"PIAFILEVERSION_2.0\r\n"; - let mut compressed: Vec = 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 { + 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, + mode_color: Option, + } + + #[derive(Clone, Copy, PartialEq, Eq)] + enum Section { + Root, + Other, + PlotStyles, + Lineweights, + } + let mut aci_entries: Vec = (0..=255).map(|_| PlotStyleEntry::default()).collect(); let mut named_entries: HashMap = HashMap::default(); - let mut style_index: usize = 1; // CTB: 1-based ACI index - let mut current: Option = 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::::new(); + let mut section = Section::Root; + let mut current: Option = 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::() { + 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::() { + style.entry.screening = v.min(100); + } + } + "lineweight" => { + if let Ok(v) = value.parse::() { + style.entry.lineweight = v; + } + } + "color" => style.color = value.parse::().ok(), + "mode_color" => style.mode_color = value.parse::().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::(), value.parse::()) { + 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 { + let mut table = PlotStyleTable::identity(name); + table.is_stb = is_stb; + let mut style_index = 1usize; + let mut current: Option = 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 { - 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::() { - entry.screening = v; - } - } - "lineweight" => { - if let Ok(v) = val.parse::() { - 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::() { - // 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::().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::().ok().and_then(unpack_plot_color), + _ => {} } } - - Ok(PlotStyleTable { - name, - is_stb, - aci_entries, - named_entries, - }) + Ok(table) } diff --git a/src/io/print_to_printer.rs b/src/io/print_to_printer.rs index d56ec6ebb..af83a5e66 100644 --- a/src/io/print_to_printer.rs +++ b/src/io/print_to_printer.rs @@ -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, /// 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, - /// Rasterisation resolution in DPI. - pub dpi: Option, /// 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 { 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}")); diff --git a/src/scene/mod.rs b/src/scene/mod.rs index d4d78217f..42dbdbe77 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -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, Vec) { + /// 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, + ) -> (Vec, Vec) { + let apply_mode = |wires: &mut Vec, 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. diff --git a/src/ui/window/plot.rs b/src/ui/window/plot.rs index 73faccc6e..f64421933 100644 --- a/src/ui/window/plot.rs +++ b/src/ui/window/plot.rs @@ -20,21 +20,22 @@ pub const OUT_PDF: &str = "Save to PDF file…"; /// settings captured when the dialog opened. pub const SETUP_NONE: &str = ""; pub const SETUP_PREV: &str = ""; +pub const STYLE_NONE: &str = ""; /// 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, /// 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> { 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 = 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"]),