diff --git a/editor/src/consts.rs b/editor/src/consts.rs index a014e8891f..f854b347ae 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -183,6 +183,23 @@ pub const DEFAULT_DOCUMENT_NAME: &str = "Untitled Document"; pub const MAX_UNDO_HISTORY_LEN: usize = 100; // TODO: Add this to user preferences pub const AUTO_SAVE_TIMEOUT_SECONDS: u64 = 1; +// PLOTTER +/// Address of the pen plotter print server, hard-coded for the conference booth LAN where it won't change. +pub const PLOTTER_SERVER_ADDRESS: &str = "http://192.168.77.10:4747"; +/// Drawable area of letter paper in the plotter, in inches (portrait). The artwork's bounding box (the artboard is +/// dropped) is scaled to fit, rotated to landscape when wider than tall. +pub const PLOTTER_PAPER_SIZE_INCHES: (f64, f64) = (7.5, 10.); +/// Fixed per-job overhead in seconds before the pen starts moving. Derived directly from timed plots: two versions of +/// the same artwork plotted separately (3:24 + 12:17) minus both overlaid in a single job (15:23) leaves the setup +/// time counted one extra time, giving 18 seconds. +pub const PLOTTER_SETUP_SECONDS: f64 = 18.; +/// Pen-down drawing speed in inches per second. Fitted (with the pen lift time below) to timed plots of the same +/// artwork with solid strokes (349 in, 20 lifts, 3:24) and dash-baked strokes (176 in, 818 lifts, 12:17). +pub const PLOTTER_PEN_SPEED_INCHES_PER_SECOND: f64 = 2.047; +/// Seconds per pen lift/reposition/lower cycle, once per subpath. This also covers pen-up travel, which is not +/// modeled by distance because the print server reorders paths to minimize it. Fitted alongside the pen speed above. +pub const PLOTTER_PEN_LIFT_SECONDS: f64 = 0.774; + // INPUT pub const DOUBLE_CLICK_MILLISECONDS: u64 = 500; diff --git a/editor/src/messages/dialog/dialog_message.rs b/editor/src/messages/dialog/dialog_message.rs index df52cce6b4..f3a359401d 100644 --- a/editor/src/messages/dialog/dialog_message.rs +++ b/editor/src/messages/dialog/dialog_message.rs @@ -10,6 +10,8 @@ pub enum DialogMessage { NewDocumentDialog(NewDocumentDialogMessage), #[child] PreferencesDialog(PreferencesDialogMessage), + #[child] + SendToPlotterDialog(SendToPlotterDialogMessage), // Messages Dismiss, @@ -22,6 +24,9 @@ pub enum DialogMessage { title: String, description: String, }, + DisplaySendToPlotterSuccess { + job_name: String, + }, RequestAboutGraphiteDialog, RequestAboutGraphiteDialogWithLocalizedCommitDate { localized_commit_date: String, @@ -37,6 +42,7 @@ pub enum DialogMessage { }, RequestNewDocumentDialog, RequestPreferencesDialog, + RequestSendToPlotterDialog, RequestConfirmRestartDialog { preferences_requiring_restart: Vec, }, diff --git a/editor/src/messages/dialog/dialog_message_handler.rs b/editor/src/messages/dialog/dialog_message_handler.rs index e788ff5364..77f982e2fd 100644 --- a/editor/src/messages/dialog/dialog_message_handler.rs +++ b/editor/src/messages/dialog/dialog_message_handler.rs @@ -4,6 +4,7 @@ use crate::messages::dialog::simple_dialogs::{ConfirmRestartDialog, LicensesThir use crate::messages::frontend::utility_types::ExportBounds; use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::prelude::*; +use graphene_std::vector::style::RenderMode; #[derive(ExtractField)] pub struct DialogMessageContext<'a> { @@ -18,6 +19,7 @@ pub struct DialogMessageHandler { export_dialog: ExportDialogMessageHandler, new_document_dialog: NewDocumentDialogMessageHandler, preferences_dialog: PreferencesDialogMessageHandler, + send_to_plotter_dialog: SendToPlotterDialogMessageHandler, } #[message_handler_data] @@ -29,6 +31,7 @@ impl MessageHandler> for DialogMessageHa DialogMessage::ExportDialog(message) => self.export_dialog.process_message(message, responses, ExportDialogMessageContext { portfolio }), DialogMessage::NewDocumentDialog(message) => self.new_document_dialog.process_message(message, responses, ()), DialogMessage::PreferencesDialog(message) => self.preferences_dialog.process_message(message, responses, PreferencesDialogMessageContext { preferences }), + DialogMessage::SendToPlotterDialog(message) => self.send_to_plotter_dialog.process_message(message, responses, SendToPlotterDialogMessageContext { portfolio }), DialogMessage::Dismiss => { if let Some(message) = self.on_dismiss.take() { @@ -60,6 +63,11 @@ impl MessageHandler> for DialogMessageHa let dialog = simple_dialogs::ErrorDialog { title, description }; dialog.send_dialog_to_frontend(responses); } + DialogMessage::DisplaySendToPlotterSuccess { job_name } => { + self.on_dismiss = None; + let dialog = simple_dialogs::SendToPlotterSuccessDialog { job_name }; + dialog.send_dialog_to_frontend(responses); + } DialogMessage::RequestAboutGraphiteDialog => { self.on_dismiss = Some(DialogMessage::Close.into()); responses.add(FrontendMessage::TriggerAboutGraphiteLocalizedCommitDate { @@ -136,6 +144,23 @@ impl MessageHandler> for DialogMessageHa self.on_dismiss = Some(PreferencesDialogMessage::Confirm.into()); self.preferences_dialog.send_dialog_to_frontend(responses, preferences); } + DialogMessage::RequestSendToPlotterDialog => { + self.on_dismiss = Some(DialogMessage::Close.into()); + if let Some(document) = portfolio.active_document() { + // Switch the viewport to outline mode so the artwork previews as the pen plotter will draw it + responses.add(DocumentMessage::SetRenderMode { render_mode: RenderMode::Outline }); + + // Kick off a render of the plotter SVG purely to measure it for the time estimate shown in the dialog + responses.add(PortfolioMessage::SubmitPlotterExport { + job_name: document.name.clone(), + estimate_only: true, + }); + + self.send_to_plotter_dialog.job_name = document.name.clone(); + self.send_to_plotter_dialog.estimate = crate::messages::dialog::send_to_plotter_dialog::PlotTimeEstimate::Pending; + self.send_to_plotter_dialog.send_dialog_to_frontend(responses); + } + } DialogMessage::RequestConfirmRestartDialog { preferences_requiring_restart } => { self.on_dismiss = Some(DialogMessage::Close.into()); let dialog = ConfirmRestartDialog { preferences_requiring_restart }; @@ -149,5 +174,6 @@ impl MessageHandler> for DialogMessageHa RequestExportDialog, RequestNewDocumentDialog, RequestPreferencesDialog, + RequestSendToPlotterDialog, ); } diff --git a/editor/src/messages/dialog/mod.rs b/editor/src/messages/dialog/mod.rs index 67a186f993..aead067195 100644 --- a/editor/src/messages/dialog/mod.rs +++ b/editor/src/messages/dialog/mod.rs @@ -11,6 +11,7 @@ mod dialog_message_handler; pub mod export_dialog; pub mod new_document_dialog; pub mod preferences_dialog; +pub mod send_to_plotter_dialog; pub mod simple_dialogs; #[doc(inline)] diff --git a/editor/src/messages/dialog/send_to_plotter_dialog/mod.rs b/editor/src/messages/dialog/send_to_plotter_dialog/mod.rs new file mode 100644 index 0000000000..71c63687c8 --- /dev/null +++ b/editor/src/messages/dialog/send_to_plotter_dialog/mod.rs @@ -0,0 +1,7 @@ +mod send_to_plotter_dialog_message; +mod send_to_plotter_dialog_message_handler; + +#[doc(inline)] +pub use send_to_plotter_dialog_message::{SendToPlotterDialogMessage, SendToPlotterDialogMessageDiscriminant}; +#[doc(inline)] +pub use send_to_plotter_dialog_message_handler::{PlotTimeEstimate, SendToPlotterDialogMessageContext, SendToPlotterDialogMessageHandler, estimated_plot_seconds}; diff --git a/editor/src/messages/dialog/send_to_plotter_dialog/send_to_plotter_dialog_message.rs b/editor/src/messages/dialog/send_to_plotter_dialog/send_to_plotter_dialog_message.rs new file mode 100644 index 0000000000..53c17df8c1 --- /dev/null +++ b/editor/src/messages/dialog/send_to_plotter_dialog/send_to_plotter_dialog_message.rs @@ -0,0 +1,10 @@ +use crate::messages::prelude::*; + +#[impl_message(Message, DialogMessage, SendToPlotterDialog)] +#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub enum SendToPlotterDialogMessage { + JobName { name: String }, + UpdateTimeEstimate { seconds: Option }, + + Submit, +} diff --git a/editor/src/messages/dialog/send_to_plotter_dialog/send_to_plotter_dialog_message_handler.rs b/editor/src/messages/dialog/send_to_plotter_dialog/send_to_plotter_dialog_message_handler.rs new file mode 100644 index 0000000000..71e662b8f9 --- /dev/null +++ b/editor/src/messages/dialog/send_to_plotter_dialog/send_to_plotter_dialog_message_handler.rs @@ -0,0 +1,153 @@ +use crate::consts::{PLOTTER_PAPER_SIZE_INCHES, PLOTTER_PEN_LIFT_SECONDS, PLOTTER_PEN_SPEED_INCHES_PER_SECOND, PLOTTER_SETUP_SECONDS}; +use crate::messages::layout::utility_types::widget_prelude::*; +use crate::messages::prelude::*; +use graphene_std::renderer::plot_statistics::PlotStatistics; + +#[derive(ExtractField)] +pub struct SendToPlotterDialogMessageContext<'a> { + pub portfolio: &'a PortfolioMessageHandler, +} + +/// How long the plotter is expected to take on the current document, shown in the dialog. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub enum PlotTimeEstimate { + /// The estimate render is still in flight. + #[default] + Pending, + /// The estimate could not be computed. + Unavailable, + /// Estimated plot duration in seconds. + Seconds(f64), +} + +/// A dialog to send the current document to the pen plotter print server as an SVG. +#[derive(Debug, Clone, Default, ExtractField)] +pub struct SendToPlotterDialogMessageHandler { + pub job_name: String, + pub estimate: PlotTimeEstimate, +} + +#[message_handler_data] +impl MessageHandler> for SendToPlotterDialogMessageHandler { + fn process_message(&mut self, message: SendToPlotterDialogMessage, responses: &mut VecDeque, context: SendToPlotterDialogMessageContext) { + let SendToPlotterDialogMessageContext { portfolio } = context; + + match message { + SendToPlotterDialogMessage::JobName { name } => self.job_name = name, + + SendToPlotterDialogMessage::UpdateTimeEstimate { seconds } => { + self.estimate = match seconds { + Some(seconds) => PlotTimeEstimate::Seconds(seconds), + None => PlotTimeEstimate::Unavailable, + }; + + // Refresh only the dialog content, since re-displaying the whole dialog would reopen it if the user has already closed it + self.send_layout(responses, LayoutTarget::DialogColumn1); + return; + } + + SendToPlotterDialogMessage::Submit => { + // Fall back to the document name so the attendant can still tell jobs apart if the field was cleared + let job_name = if self.job_name.trim().is_empty() { + portfolio.active_document().map(|document| document.name.clone()).unwrap_or_default() + } else { + self.job_name.clone() + }; + + responses.add_front(PortfolioMessage::SubmitPlotterExport { job_name, estimate_only: false }); + } + } + + self.send_dialog_to_frontend(responses); + } + + advertise_actions!(SendToPlotterDialogUpdate; + ); +} + +impl DialogLayoutHolder for SendToPlotterDialogMessageHandler { + const ICON: &'static str = "File"; + const TITLE: &'static str = "Send to Plotter"; + + fn layout_buttons(&self) -> Layout { + let widgets = vec![ + TextButton::new("Send") + .emphasized(true) + .on_update(|_| { + DialogMessage::CloseAndThen { + followups: vec![SendToPlotterDialogMessage::Submit.into()], + } + .into() + }) + .widget_instance(), + TextButton::new("Cancel").on_update(|_| FrontendMessage::DialogClose.into()).widget_instance(), + ]; + + Layout(vec![LayoutGroup::row(widgets)]) + } +} + +impl LayoutHolder for SendToPlotterDialogMessageHandler { + fn layout(&self) -> Layout { + let job_name = vec![ + TextLabel::new("Job Name").table_align(true).min_width(100).widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + TextInput::new(&self.job_name) + .on_update(|text_input: &TextInput| SendToPlotterDialogMessage::JobName { name: text_input.value.clone() }.into()) + .min_width(200) + .widget_instance(), + ]; + + let estimate_text = match self.estimate { + PlotTimeEstimate::Pending => "Estimating…".to_string(), + PlotTimeEstimate::Unavailable => "Unknown".to_string(), + PlotTimeEstimate::Seconds(seconds) => format_plot_duration(seconds), + }; + let estimated_time = vec![ + TextLabel::new("Estimated Time").table_align(true).min_width(100).widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + TextLabel::new(estimate_text).widget_instance(), + ]; + + Layout(vec![LayoutGroup::row(job_name), LayoutGroup::row(estimated_time)]) + } +} + +/// Estimates how long the plotter will take to draw the given SVG, in seconds. +/// +/// The artwork's bounding box is scaled to fit the paper (rotated to landscape when wider than tall), then timed as a +/// fixed setup cost, pen-down drawing at a constant speed, and a fixed cost per pen lift (which also covers pen-up +/// travel, since the print server reorders paths to minimize it). The pen draws every path as its outline, so total +/// path length approximates the pen-down distance regardless of fills. Intricate fine detail like text tends to run +/// over this estimate because the machine cannot reach full speed on tiny curves. +pub fn estimated_plot_seconds(statistics: &PlotStatistics) -> f64 { + let (paper_width, paper_height) = if statistics.width > statistics.height { + (PLOTTER_PAPER_SIZE_INCHES.1, PLOTTER_PAPER_SIZE_INCHES.0) + } else { + PLOTTER_PAPER_SIZE_INCHES + }; + let scale = if statistics.width > 0. && statistics.height > 0. { + (paper_width / statistics.width).min(paper_height / statistics.height) + } else { + 0. + }; + + let pen_down_inches = statistics.pen_down_distance * scale; + + PLOTTER_SETUP_SECONDS + pen_down_inches / PLOTTER_PEN_SPEED_INCHES_PER_SECOND + statistics.pen_lift_count as f64 * PLOTTER_PEN_LIFT_SECONDS +} + +/// Formats an estimated duration like "About 3 min 20 sec", rounded to the nearest 5 seconds. +fn format_plot_duration(seconds: f64) -> String { + let total = (((seconds / 5.).round() * 5.).max(5.)) as u64; + let minutes = total / 60; + let seconds = total % 60; + + if minutes == 0 { + format!("About {seconds} sec") + } else if seconds == 0 { + format!("About {minutes} min") + } else { + format!("About {minutes} min {seconds} sec") + } +} diff --git a/editor/src/messages/dialog/simple_dialogs/mod.rs b/editor/src/messages/dialog/simple_dialogs/mod.rs index aa2e8103c3..feee3607eb 100644 --- a/editor/src/messages/dialog/simple_dialogs/mod.rs +++ b/editor/src/messages/dialog/simple_dialogs/mod.rs @@ -8,6 +8,7 @@ mod failed_to_load_documents_dialog; mod failed_to_open_document_dialog; mod licenses_dialog; mod licenses_third_party_dialog; +mod send_to_plotter_success_dialog; pub use about_graphite_dialog::AboutGraphiteDialog; pub use close_all_documents_dialog::CloseAllDocumentsDialog; @@ -20,3 +21,4 @@ pub use failed_to_load_documents_dialog::FailedToLoadDocumentsDialog; pub use failed_to_open_document_dialog::FailedToOpenDocumentDialog; pub use licenses_dialog::LicensesDialog; pub use licenses_third_party_dialog::LicensesThirdPartyDialog; +pub use send_to_plotter_success_dialog::SendToPlotterSuccessDialog; diff --git a/editor/src/messages/dialog/simple_dialogs/send_to_plotter_success_dialog.rs b/editor/src/messages/dialog/simple_dialogs/send_to_plotter_success_dialog.rs new file mode 100644 index 0000000000..b6dbc2203b --- /dev/null +++ b/editor/src/messages/dialog/simple_dialogs/send_to_plotter_success_dialog.rs @@ -0,0 +1,34 @@ +use crate::messages::layout::utility_types::widget_prelude::*; +use crate::messages::prelude::*; + +/// A dialog to confirm that a job was successfully queued on the pen plotter print server. +pub struct SendToPlotterSuccessDialog { + pub job_name: String, +} + +impl DialogLayoutHolder for SendToPlotterSuccessDialog { + const ICON: &'static str = "CheckboxChecked"; + const TITLE: &'static str = "Send to Plotter"; + + fn layout_buttons(&self) -> Layout { + let widgets = vec![TextButton::new("OK").emphasized(true).on_update(|_| FrontendMessage::DialogClose.into()).widget_instance()]; + + Layout(vec![LayoutGroup::row(widgets)]) + } +} + +impl LayoutHolder for SendToPlotterSuccessDialog { + fn layout(&self) -> Layout { + Layout(vec![ + LayoutGroup::row(vec![TextLabel::new("Sent to the plotter queue").bold(true).widget_instance()]), + LayoutGroup::row(vec![ + TextLabel::new(format!( + "The job \"{}\" was added to the queue.\nA booth attendant will start the plot from the dashboard.", + self.job_name + )) + .multiline(true) + .widget_instance(), + ]), + ]) + } +} diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 5d9e73e765..9ca61a8b07 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -106,6 +106,11 @@ pub enum FrontendMessage { mime: String, size: (f64, f64), }, + TriggerSendToPlotter { + name: String, + svg: String, + address: String, + }, TriggerFetchAndOpenDocument { name: String, filename: String, diff --git a/editor/src/messages/input_mapper/input_mappings.rs b/editor/src/messages/input_mapper/input_mappings.rs index 8023d0f926..8a1ad797aa 100644 --- a/editor/src/messages/input_mapper/input_mappings.rs +++ b/editor/src/messages/input_mapper/input_mappings.rs @@ -453,6 +453,7 @@ pub fn input_mappings(zoom_with_scroll: bool) -> Mapping { // DialogMessage entry!(KeyDown(KeyE); modifiers=[Accel], action_dispatch=DialogMessage::RequestExportDialog), entry!(KeyDown(KeyN); modifiers=[Accel], action_dispatch=DialogMessage::RequestNewDocumentDialog), + entry!(KeyDown(KeyP); modifiers=[Accel], action_dispatch=DialogMessage::RequestSendToPlotterDialog), entry!(KeyDown(Comma); modifiers=[Accel], action_dispatch=DialogMessage::RequestPreferencesDialog), // // DebugMessage diff --git a/editor/src/messages/menu_bar/menu_bar_message_handler.rs b/editor/src/messages/menu_bar/menu_bar_message_handler.rs index 43946b2b7d..86baef018f 100644 --- a/editor/src/messages/menu_bar/menu_bar_message_handler.rs +++ b/editor/src/messages/menu_bar/menu_bar_message_handler.rs @@ -172,6 +172,12 @@ impl LayoutHolder for MenuBarMessageHandler { .tooltip_shortcut(action_shortcut!(DialogMessageDiscriminant::RequestExportDialog)) .on_commit(|_| DialogMessage::RequestExportDialog.into()) .disabled(no_active_document), + MenuListEntry::new("Send to Plotter…") + .label("Send to Plotter…") + .icon("FileExport") + .tooltip_shortcut(action_shortcut!(DialogMessageDiscriminant::RequestSendToPlotterDialog)) + .on_commit(|_| DialogMessage::RequestSendToPlotterDialog.into()) + .disabled(no_active_document), ], #[cfg(not(target_os = "macos"))] vec![preferences], diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index a7c57f4659..6ef52d76aa 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -1323,6 +1323,8 @@ impl MessageHandler> for DocumentMes DocumentMessage::SetRenderMode { render_mode } => { self.render_mode = render_mode; responses.add_front(NodeGraphMessage::RunDocumentGraph); + // Keep the document bar's render mode radio buttons in sync when the mode is set programmatically + responses.add(PortfolioMessage::UpdateDocumentWidgets); } DocumentMessage::AddTransaction => { // Reverse order since they are added to the front diff --git a/editor/src/messages/portfolio/portfolio_message.rs b/editor/src/messages/portfolio/portfolio_message.rs index 8edca5950d..2a4407141a 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -197,6 +197,10 @@ pub enum PortfolioMessage { artboard_name: Option, artboard_count: usize, }, + SubmitPlotterExport { + job_name: String, + estimate_only: bool, + }, SubmitActiveGraphRender, SubmitGraphRender { document_id: DocumentId, diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 5dbd6fb55c..7f7f410a01 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -5,7 +5,7 @@ use crate::application::{Editor, generate_uuid}; use crate::consts::{DEFAULT_DOCUMENT_NAME, FILE_EXTENSION, GDD_FILE_EXTENSION}; use crate::messages::animation::TimingInformation; use crate::messages::dialog::simple_dialogs; -use crate::messages::frontend::utility_types::{DocumentInfo, PersistedState}; +use crate::messages::frontend::utility_types::{DocumentInfo, ExportBounds, FileType, PersistedState}; use crate::messages::input_mapper::utility_types::input_keyboard::Key; use crate::messages::input_mapper::utility_types::macros::{action_shortcut, action_shortcut_manual}; use crate::messages::layout::utility_types::widget_prelude::*; @@ -1461,6 +1461,32 @@ impl MessageHandler> for Portfolio }); } } + PortfolioMessage::SubmitPlotterExport { job_name, estimate_only } => { + let document_id = self.active_document_id.expect("Tried to render non-existent document"); + let document = self.documents.get_mut(&document_id).expect("Tried to render non-existent document"); + let export_config = ExportConfig { + name: job_name, + file_type: FileType::Svg, + scale_factor: 1., + bounds: ExportBounds::AllArtwork, + for_plotter: true, + plotter_estimate_only: estimate_only, + ..Default::default() + }; + let result = self.executor.submit_document_export(document, document_id, export_config); + + if let Err(description) = result { + // A failed estimate render (e.g. an empty document) just marks the estimate as unavailable + if estimate_only { + responses.add(DialogMessage::SendToPlotterDialog(SendToPlotterDialogMessage::UpdateTimeEstimate { seconds: None })); + } else { + responses.add(DialogMessage::DisplayDialogError { + title: "Unable to send to the plotter".to_string(), + description, + }); + } + } + } PortfolioMessage::SubmitActiveGraphRender => { if let Some(document_id) = self.active_document_id { responses.add(PortfolioMessage::SubmitGraphRender { document_id, ignore_hash: false }); diff --git a/editor/src/messages/prelude.rs b/editor/src/messages/prelude.rs index 3b99ad1afb..c66df2aa9f 100644 --- a/editor/src/messages/prelude.rs +++ b/editor/src/messages/prelude.rs @@ -14,6 +14,7 @@ pub use crate::messages::defer::{DeferMessage, DeferMessageDiscriminant, DeferMe pub use crate::messages::dialog::export_dialog::{ExportDialogMessage, ExportDialogMessageContext, ExportDialogMessageDiscriminant, ExportDialogMessageHandler}; pub use crate::messages::dialog::new_document_dialog::{NewDocumentDialogMessage, NewDocumentDialogMessageDiscriminant, NewDocumentDialogMessageHandler}; pub use crate::messages::dialog::preferences_dialog::{PreferencesDialogMessage, PreferencesDialogMessageContext, PreferencesDialogMessageDiscriminant, PreferencesDialogMessageHandler}; +pub use crate::messages::dialog::send_to_plotter_dialog::{SendToPlotterDialogMessage, SendToPlotterDialogMessageContext, SendToPlotterDialogMessageDiscriminant, SendToPlotterDialogMessageHandler}; pub use crate::messages::dialog::{DialogMessage, DialogMessageContext, DialogMessageDiscriminant, DialogMessageHandler}; pub use crate::messages::frontend::{FrontendMessage, FrontendMessageDiscriminant}; pub use crate::messages::future::{FutureMessage, FutureMessageContext, FutureMessageDiscriminant, FutureMessageHandler, MessageFuture, MessageSpawner, Wake}; diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index dec56ecd8a..15f102a9bf 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -196,6 +196,8 @@ impl NodeGraphExecutor { render_mode: document.render_mode, for_export: false, for_eyedropper: false, + hide_artboard_background: false, + bake_stroke_dashes: false, }; // Execute the node graph @@ -267,6 +269,8 @@ impl NodeGraphExecutor { render_mode, for_export: false, for_eyedropper: true, + hide_artboard_background: false, + bake_stroke_dashes: false, }; // Execute the node graph @@ -315,15 +319,25 @@ impl NodeGraphExecutor { ..Default::default() }; + // The plotter flow switches the viewport to Outline mode as a preview, so pin its export to the normal render + // mode for a deterministic serialization regardless of what the viewport is showing + let render_mode = if export_config.for_plotter { + graphene_std::vector::style::RenderMode::Normal + } else { + document.render_mode + }; + let render_config = RenderConfig { viewport, scale: export_config.scale_factor, time: Default::default(), pointer: DVec2::ZERO, export_format, - render_mode: document.render_mode, + render_mode, for_export: true, for_eyedropper: false, + hide_artboard_background: export_config.for_plotter, + bake_stroke_dashes: export_config.for_plotter, }; export_config.size = resolution; @@ -568,6 +582,8 @@ impl NodeGraphExecutor { render_mode: document.render_mode, for_export: false, for_eyedropper: false, + hide_artboard_background: false, + bake_stroke_dashes: false, }; let execution_id = self.queue_execution(render_config); self.futures.push_back(( @@ -733,6 +749,8 @@ impl NodeGraphExecutor { size, artboard_name, artboard_count, + for_plotter, + plotter_estimate_only, .. } = export_config; @@ -753,7 +771,18 @@ impl NodeGraphExecutor { data: RenderOutputType::Svg { svg, .. }, .. }) => { - if file_type == FileType::Svg { + if plotter_estimate_only { + // Measure the SVG the plotter would receive and report the time estimate back to the dialog + let seconds = + graphene_std::renderer::plot_statistics::svg_plot_statistics(&svg).map(|statistics| crate::messages::dialog::send_to_plotter_dialog::estimated_plot_seconds(&statistics)); + responses.add(DialogMessage::SendToPlotterDialog(SendToPlotterDialogMessage::UpdateTimeEstimate { seconds })); + } else if for_plotter { + responses.add(FrontendMessage::TriggerSendToPlotter { + name: base_name, + svg, + address: crate::consts::PLOTTER_SERVER_ADDRESS.to_string(), + }); + } else if file_type == FileType::Svg { responses.add(FrontendMessage::TriggerSaveFile { name, folder, diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index d4be4156de..e7b736f86a 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -91,6 +91,10 @@ pub struct ExportConfig { pub size: UVec2, pub artboard_name: Option, pub artboard_count: usize, + /// Send the exported SVG to the pen plotter print server instead of saving it as a file. + pub for_plotter: bool, + /// Only measure the plotter SVG for a time estimate instead of sending it to the print server. + pub plotter_estimate_only: bool, } #[derive(Clone)] diff --git a/frontend/src/stores/portfolio.ts b/frontend/src/stores/portfolio.ts index 3de2524d18..9e004d4359 100644 --- a/frontend/src/stores/portfolio.ts +++ b/frontend/src/stores/portfolio.ts @@ -29,6 +29,9 @@ const initialState: PortfolioStoreState = { let subscriptionsRouter: SubscriptionsRouter | undefined = undefined; +// Prevents double-submitting a plotter job while one upload is still in flight +let plotterUploadInFlight = false; + // Store state persisted across HMR to maintain reactive subscriptions in the component tree const store: Writable = import.meta.hot?.data?.store || writable(initialState); if (import.meta.hot) import.meta.hot.data.store = store; @@ -129,6 +132,34 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor: } }); + subscriptions.subscribeFrontendMessage("TriggerSendToPlotter", async (data) => { + const { name, svg, address } = data; + + if (plotterUploadInFlight) return; + plotterUploadInFlight = true; + + try { + // POST the SVG to the print server's job queue endpoint, with the job name in the query string + const endpoint = `${address}/api/jobs?name=${encodeURIComponent(name)}`; + let response; + try { + response = await fetch(endpoint, { method: "POST", headers: { "content-type": "image/svg+xml" }, body: svg }); + } catch { + editor.errorDialog("Send to Plotter failed", `Could not reach the print server. Is it running at ${address} and connected to the same network?`); + return; + } + + if (response.status === 201) { + editor.sendToPlotterSuccessDialog(name); + } else { + const serverError = (await response.json().catch(() => undefined))?.error; + editor.errorDialog("Send to Plotter failed", `The print server rejected the job${serverError ? `: ${serverError}` : ` (HTTP ${response.status})`}.`); + } + } finally { + plotterUploadInFlight = false; + } + }); + subscriptions.subscribeFrontendMessage("UpdateWorkspacePanelLayout", (data) => { update((state) => { state.panelLayout = data.panelLayout; @@ -196,6 +227,7 @@ export function destroyPortfolioStore() { subscriptions.unsubscribeFrontendMessage("TriggerSaveDocument"); subscriptions.unsubscribeFrontendMessage("TriggerSaveFile"); subscriptions.unsubscribeFrontendMessage("TriggerExportImage"); + subscriptions.unsubscribeFrontendMessage("TriggerSendToPlotter"); subscriptions.unsubscribeFrontendMessage("UpdateWorkspacePanelLayout"); subscriptions.unsubscribeLayoutUpdate("WelcomeScreenButtons"); subscriptions.unsubscribeLayoutUpdate("PropertiesPanel"); diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index 81843a6f6d..5e3803b31d 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -318,6 +318,13 @@ impl EditorWrapper { self.dispatch(message); } + /// Displays a dialog confirming that a job was successfully queued on the pen plotter print server + #[wasm_bindgen(js_name = sendToPlotterSuccessDialog)] + pub fn send_to_plotter_success_dialog(&self, job_name: String) { + let message = DialogMessage::DisplaySendToPlotterSuccess { job_name }; + self.dispatch(message); + } + /// Answer whether or not the editor has crashed #[wasm_bindgen(js_name = hasCrashed)] pub fn has_crashed(&self) -> bool { diff --git a/node-graph/libraries/application-io/src/lib.rs b/node-graph/libraries/application-io/src/lib.rs index b3df3314d3..144b5b3ef0 100644 --- a/node-graph/libraries/application-io/src/lib.rs +++ b/node-graph/libraries/application-io/src/lib.rs @@ -83,6 +83,10 @@ pub struct RenderConfig { pub export_format: ExportFormat, pub for_export: bool, pub for_eyedropper: bool, + /// Skip rendering artboard background rectangles, used for pen plotter output. + pub hide_artboard_background: bool, + /// Cut dashed strokes into their visible dash segments so the path geometry itself carries the dash pattern, used for pen plotter output. + pub bake_stroke_dashes: bool, } impl RenderConfig { diff --git a/node-graph/libraries/rendering/src/lib.rs b/node-graph/libraries/rendering/src/lib.rs index 418f4e9c0c..4b16bde280 100644 --- a/node-graph/libraries/rendering/src/lib.rs +++ b/node-graph/libraries/rendering/src/lib.rs @@ -1,4 +1,5 @@ pub mod convert_usvg_path; +pub mod plot_statistics; pub mod render_ext; mod renderer; pub mod to_peniko; diff --git a/node-graph/libraries/rendering/src/plot_statistics.rs b/node-graph/libraries/rendering/src/plot_statistics.rs new file mode 100644 index 0000000000..bb6e536f3c --- /dev/null +++ b/node-graph/libraries/rendering/src/plot_statistics.rs @@ -0,0 +1,138 @@ +use kurbo::{BezPath, ParamCurveArclen, PathEl, PathSeg, Point, Rect, Shape}; + +/// Pen movement totals for plotting an SVG document with a pen plotter, which draws every path as its outline. +/// Distances are in SVG user units; scale by the paper fit before converting to wall-clock time. +/// +/// Pen-up travel between subpaths is deliberately not measured: the print server reorders paths (and rotates the +/// start points of closed ones) to minimize travel, so document-order travel distance is meaningless. Its cost is +/// captured as part of the constant time per pen lift instead. +pub struct PlotStatistics { + /// Total distance drawn with the pen down, following every path's geometry. + pub pen_down_distance: f64, + /// Number of pen lift/reposition/lower cycles (one per subpath). + pub pen_lift_count: usize, + /// Width of the artwork's bounding box, which the print server scales to fit the paper (the document size is ignored). + pub width: f64, + /// Height of the artwork's bounding box. + pub height: f64, +} + +#[derive(Default)] +struct MeasureState { + pen_down_distance: f64, + pen_lift_count: usize, + bounds: Option, +} + +/// Measures the pen plotter movement statistics of an SVG document by walking every path in document order. +/// Returns `None` if the SVG cannot be parsed or contains no path geometry. +pub fn svg_plot_statistics(svg: &str) -> Option { + let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).ok()?; + + let mut state = MeasureState::default(); + accumulate_group(tree.root(), &mut state); + + let bounds = state.bounds?; + Some(PlotStatistics { + pen_down_distance: state.pen_down_distance, + pen_lift_count: state.pen_lift_count, + width: bounds.width(), + height: bounds.height(), + }) +} + +fn accumulate_group(group: &usvg::Group, state: &mut MeasureState) { + for node in group.children() { + match node { + usvg::Node::Group(group) => accumulate_group(group, state), + usvg::Node::Path(path) => accumulate_bezpath(&usvg_path_to_bezpath(path), state), + _ => {} + } + } +} + +fn accumulate_bezpath(bezpath: &BezPath, state: &mut MeasureState) { + const ARC_LENGTH_ACCURACY: f64 = 0.1; + + if bezpath.elements().is_empty() { + return; + } + + for segment in bezpath.segments() { + state.pen_down_distance += match segment { + PathSeg::Line(line) => line.p0.distance(line.p1), + segment => segment.arclen(ARC_LENGTH_ACCURACY), + }; + } + + state.pen_lift_count += bezpath.elements().iter().filter(|element| matches!(element, PathEl::MoveTo(_))).count(); + + let bounds = bezpath.bounding_box(); + if !bounds.is_nan() { + state.bounds = Some(state.bounds.map_or(bounds, |existing| existing.union(bounds))); + } +} + +/// Converts a usvg path into a kurbo path with its absolute transform applied. +fn usvg_path_to_bezpath(path: &usvg::Path) -> BezPath { + let transform = path.abs_transform(); + let to_point = |point: &usvg::tiny_skia_path::Point| { + let (x, y) = (point.x as f64, point.y as f64); + Point::new( + transform.sx as f64 * x + transform.kx as f64 * y + transform.tx as f64, + transform.ky as f64 * x + transform.sy as f64 * y + transform.ty as f64, + ) + }; + + let mut bezpath = BezPath::new(); + let mut points = path.data().points().iter(); + + for verb in path.data().verbs() { + match verb { + usvg::tiny_skia_path::PathVerb::Move => { + let Some(point) = points.next().map(to_point) else { continue }; + bezpath.move_to(point); + } + usvg::tiny_skia_path::PathVerb::Line => { + let Some(end) = points.next().map(to_point) else { continue }; + bezpath.line_to(end); + } + usvg::tiny_skia_path::PathVerb::Quad => { + let Some(handle) = points.next().map(to_point) else { continue }; + let Some(end) = points.next().map(to_point) else { continue }; + bezpath.quad_to(handle, end); + } + usvg::tiny_skia_path::PathVerb::Cubic => { + let Some(first_handle) = points.next().map(to_point) else { continue }; + let Some(second_handle) = points.next().map(to_point) else { continue }; + let Some(end) = points.next().map(to_point) else { continue }; + bezpath.curve_to(first_handle, second_handle, end); + } + usvg::tiny_skia_path::PathVerb::Close => bezpath.close_path(), + } + } + + bezpath +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn measures_lines_squares_and_bounds() { + // A 30x30 square (120 units drawn, 1 lift) followed by a vertical line (100 units drawn, 1 lift), + // with an artwork bounding box spanning (10,10) to (40,150) + let svg = r##" + + + "##; + + let statistics = svg_plot_statistics(svg).unwrap(); + + assert_eq!(statistics.pen_lift_count, 2); + assert!((statistics.pen_down_distance - 220.).abs() < 1e-6, "pen down was {}", statistics.pen_down_distance); + assert!((statistics.width - 30.).abs() < 1e-6, "width was {}", statistics.width); + assert!((statistics.height - 140.).abs() < 1e-6, "height was {}", statistics.height); + } +} diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 1113e5816c..2e3526ff31 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -179,10 +179,11 @@ impl RenderExt for Stroke { let default_weight = if self.align != StrokeAlign::Center && render_params.aligned_strokes { 1. / 2. } else { 1. }; - // Set to None if the value is the SVG default + // Set to None if the value is the SVG default. When dashes are baked into the path geometry, the dash attributes must be + // omitted so the pattern isn't applied a second time to the already-cut dash segments. let weight = (self.weight != default_weight).then_some(self.weight); - let dash_array = (!self.dash_lengths.is_empty()).then_some(self.dash_lengths()); - let dash_offset = (self.dash_offset != 0.).then_some(self.dash_offset); + let dash_array = (!self.dash_lengths.is_empty() && !render_params.bake_stroke_dashes).then_some(self.dash_lengths()); + let dash_offset = (self.dash_offset != 0. && !render_params.bake_stroke_dashes).then_some(self.dash_offset); let stroke_cap = (self.cap != StrokeCap::Butt).then_some(self.cap); let stroke_join = (self.join != StrokeJoin::Miter).then_some(self.join); let stroke_join_miter_limit = (self.join_miter_limit != 4.).then_some(self.join_miter_limit); diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index a3b86ea6ec..46262710f8 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -230,6 +230,10 @@ pub struct RenderParams { pub artboard_background: Option, /// Viewport zoom level (document-space scale). Used to compute constant viewport-pixel stroke widths in Outline mode. pub viewport_zoom: f64, + /// Skip rendering artboard background rectangles, used for pen plotter output where a background would be traced as a giant filled contour. + pub hide_artboard_background: bool, + /// Cut dashed strokes into their visible dash segments so the path geometry itself carries the dash pattern, used for pen plotter output where `stroke-dasharray` would be ignored. + pub bake_stroke_dashes: bool, } impl RenderParams { @@ -718,16 +722,18 @@ impl Render for List { let height = dimensions.y.abs(); // Background - render.leaf_tag("rect", |attributes| { - attributes.push("fill", format!("#{}", SRGBA8::from(background).to_rgb_hex())); - if background.a() < 1. { - attributes.push("fill-opacity", ((background.a() * 1000.).round() / 1000.).to_string()); - } - attributes.push("x", x.to_string()); - attributes.push("y", y.to_string()); - attributes.push("width", width.to_string()); - attributes.push("height", height.to_string()); - }); + if !render_params.hide_artboard_background { + render.leaf_tag("rect", |attributes| { + attributes.push("fill", format!("#{}", SRGBA8::from(background).to_rgb_hex())); + if background.a() < 1. { + attributes.push("fill-opacity", ((background.a() * 1000.).round() / 1000.).to_string()); + } + attributes.push("x", x.to_string()); + attributes.push("y", y.to_string()); + attributes.push("width", width.to_string()); + attributes.push("height", height.to_string()); + }); + } // Artwork render.parent_tag( @@ -775,10 +781,12 @@ impl Render for List { let artboard_transform = kurbo::Affine::new(transform.to_cols_array()); - let color = SRGBA8::from(background).to_peniko_color(); - scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., artboard_transform, &rect); - scene.fill(peniko::Fill::NonZero, artboard_transform, color, None, &rect); - scene.pop_layer(); + if !render_params.hide_artboard_background { + let color = SRGBA8::from(background).to_peniko_color(); + scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., artboard_transform, &rect); + scene.fill(peniko::Fill::NonZero, artboard_transform, color, None, &rect); + scene.pop_layer(); + } if clip { scene.push_clip_layer(peniko::Fill::NonZero, kurbo::Affine::new(transform.to_cols_array()), &rect); @@ -1114,7 +1122,33 @@ impl Render for List { let override_paint_order = can_draw_aligned_stroke && can_use_paint_order; let use_face_fill = vector.use_face_fill(); - if needs_separate_alignment_fill && !wants_stroke_below { + // When baking dashes, cut each stroked path into its visible dash segments so the geometry itself carries the dash + // pattern, letting consumers that ignore `stroke-dasharray` (like a pen plotter) still draw the dashes. + let baked_dash_path = (render_params.bake_stroke_dashes && stroke_graphic.is_some_and(|graphic| !graphic.is_fully_transparent())) + .then_some(vector.stroke.as_ref()) + .flatten() + .filter(|stroke| stroke.has_renderable_stroke() && !stroke.dash_lengths.is_empty()) + .and_then(|stroke| { + let dash_pattern: Vec = stroke.dash_lengths.iter().map(|length| length.max(0.)).collect(); + if dash_pattern.iter().sum::() <= 0. { + return None; + } + + let mut dashed_path = String::new(); + for mut bezpath in vector.stroke_bezpath_iter() { + bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); + let dashed_bezpath: BezPath = kurbo::dash(bezpath.iter(), stroke.dash_offset, &dash_pattern).collect(); + dashed_path.push_str(dashed_bezpath.to_svg().as_str()); + } + Some(dashed_path) + }); + + // When dashes are baked, the fill is dropped entirely rather than emitted as a separate path: the pen plotter + // draws every path as its outline, and a fill's contour is the same uncut geometry the dashes were cut from, + // so it would be drawn as a solid line right over the dashes. + let emit_separate_fill = needs_separate_alignment_fill && baked_dash_path.is_none(); + + if emit_separate_fill && !wants_stroke_below { emit_svg_fill_path( render, path.clone(), @@ -1142,7 +1176,8 @@ impl Render for List { (id, mask_type, vector_item) }); - if use_face_fill { + // Face fills are dropped when dashes are baked for the same reason as above: their boundaries retrace the dashed edges + if use_face_fill && baked_dash_path.is_none() { for mut face_path in vector.construct_faces().filter(|face| face.area() >= 0.) { face_path.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); let face_d = face_path.to_svg(); @@ -1161,7 +1196,7 @@ impl Render for List { } render.leaf_tag("path", |attributes| { - attributes.push("d", path.clone()); + attributes.push("d", baked_dash_path.clone().unwrap_or_else(|| path.clone())); let matrix = format_transform_matrix(element_transform); if !matrix.is_empty() { attributes.push(ATTR_TRANSFORM, matrix); @@ -1229,7 +1264,7 @@ impl Render for List { String::new() }; - let fill_attribute = if needs_separate_alignment_fill || use_face_fill { + let fill_attribute = if needs_separate_alignment_fill || use_face_fill || baked_dash_path.is_some() { r#" fill="none""#.to_string() } else { fill_graphic_list @@ -1261,7 +1296,7 @@ impl Render for List { }); // When splitting passes and stroke is below, draw the fill after the stroke. - if needs_separate_alignment_fill && wants_stroke_below { + if emit_separate_fill && wants_stroke_below { emit_svg_fill_path( render, path.clone(), @@ -1473,7 +1508,16 @@ impl Render for List { // Render the path match render_params.render_mode { RenderMode::Outline => { - let (outline_stroke, outline_color_peniko) = get_outline_styles(render_params); + let (mut outline_stroke, outline_color_peniko) = get_outline_styles(render_params); + + // Show the stroke's dash pattern in the outline preview so dashed strokes read the same as they will be plotted + if let Some(stroke) = stroke.filter(|stroke| stroke.has_renderable_stroke()) { + let dash_pattern: kurbo::Dashes = stroke.dash_lengths.iter().map(|length| length.max(0.)).collect(); + if dash_pattern.iter().sum::() > 0. { + outline_stroke.dash_pattern = dash_pattern; + outline_stroke.dash_offset = stroke.dash_offset; + } + } scene.stroke(&outline_stroke, kurbo::Affine::new(element_transform.to_cols_array()), outline_color_peniko, None, &path); } diff --git a/node-graph/nodes/gstd/src/render_background.rs b/node-graph/nodes/gstd/src/render_background.rs index dd2c0f3646..24fb79ded6 100644 --- a/node-graph/nodes/gstd/src/render_background.rs +++ b/node-graph/nodes/gstd/src/render_background.rs @@ -7,6 +7,7 @@ use core_types::{Ctx, ExtractFootprint}; use glam::{Affine2, UVec2, Vec2}; use graph_craft::document::value::{RenderOutput, RenderOutputType}; use graphic_types::raster_types::Texture; +use graphic_types::vector_types::vector::style::RenderMode; use rendering::{RenderParams, SvgRender, SvgRenderOutput}; use std::fmt::Write; use wgpu::util::DeviceExt; @@ -33,6 +34,9 @@ async fn render_background<'a: 'n>( let mut render_params = render_params.clone(); render_params.footprint = *footprint; + // Outline mode previews the artwork as it would be drawn on paper, so transparency shows as solid white instead of the checkerboard + let solid_white_background = render_params.render_mode == RenderMode::Outline; + let data = match foreground_data { RenderOutputType::Texture(foreground_texture) => { let doc_to_screen = render_params.footprint.transform.as_affine2(); @@ -43,6 +47,7 @@ async fn render_background<'a: 'n>( backgrounds: &metadata.backgrounds, document_to_screen: doc_to_screen, zoom: render_params.viewport_zoom.to_f32(), + solid_white: solid_white_background, }) .await; @@ -58,6 +63,17 @@ async fn render_background<'a: 'n>( if render_params.viewport_zoom > 0. { let draw_checkerboard = |render: &mut SvgRender, rect: vello::kurbo::Rect, pattern_origin: glam::DVec2, checker_id_prefix: &str| { + if solid_white_background { + render.leaf_tag("rect", |attributes| { + attributes.push("x", rect.x0.to_string()); + attributes.push("y", rect.y0.to_string()); + attributes.push("width", rect.width().to_string()); + attributes.push("height", rect.height().to_string()); + attributes.push("fill", "#ffffff".to_string()); + }); + return; + } + let checker_id = format!("{checker_id_prefix}-{}", generate_uuid()); let cell_size = 8. / render_params.viewport_zoom; let pattern_size = cell_size * 2.; @@ -148,6 +164,8 @@ pub struct CompositeBackgroundArgs<'a> { backgrounds: &'a [rendering::Background], document_to_screen: Affine2, zoom: f32, + /// Draw solid white instead of the transparency checkerboard, used in Outline mode. + solid_white: bool, } impl AsyncWgpuPipeline for CompositeBackground { @@ -339,6 +357,7 @@ impl AsyncWgpuPipeline for CompositeBackground { backgrounds, document_to_screen, zoom, + solid_white, } = args; let foreground_size = foreground.size(); @@ -362,7 +381,7 @@ impl AsyncWgpuPipeline for CompositeBackground { let checker_draws = if backgrounds.is_empty() { vec![( 3, - self.create_checker_bind_group(device, CompositeUniforms::fullscreen(viewport_size, screen_to_document, checker_size_doc)), + self.create_checker_bind_group(device, CompositeUniforms::fullscreen(viewport_size, screen_to_document, checker_size_doc, solid_white)), )] } else { backgrounds @@ -378,7 +397,7 @@ impl AsyncWgpuPipeline for CompositeBackground { return None; } - let uniforms = CompositeUniforms::rect(min, max, document_to_screen, viewport_size, checker_size_doc); + let uniforms = CompositeUniforms::rect(min, max, document_to_screen, viewport_size, checker_size_doc, solid_white); Some((6, self.create_checker_bind_group(device, uniforms))) }) .collect() @@ -474,19 +493,19 @@ struct CompositeUniforms { viewport_size: [f32; 2], pattern_origin: [f32; 2], checker_size: f32, - _pad: f32, + solid_white: f32, } impl CompositeUniforms { - fn fullscreen(viewport_size: Vec2, screen_to_document: Affine2, checker_size_doc: f32) -> Self { - Self::new(screen_to_document, Vec2::ZERO, Vec2::ZERO, viewport_size, Vec2::ZERO, checker_size_doc) + fn fullscreen(viewport_size: Vec2, screen_to_document: Affine2, checker_size_doc: f32, solid_white: bool) -> Self { + Self::new(screen_to_document, Vec2::ZERO, Vec2::ZERO, viewport_size, Vec2::ZERO, checker_size_doc, solid_white) } - fn rect(rect_min: Vec2, rect_max: Vec2, document_to_screen: Affine2, viewport_size: Vec2, checker_size_doc: f32) -> Self { - Self::new(document_to_screen, rect_min, rect_max, viewport_size, rect_min, checker_size_doc) + fn rect(rect_min: Vec2, rect_max: Vec2, document_to_screen: Affine2, viewport_size: Vec2, checker_size_doc: f32, solid_white: bool) -> Self { + Self::new(document_to_screen, rect_min, rect_max, viewport_size, rect_min, checker_size_doc, solid_white) } - fn new(transform: Affine2, rect_min: Vec2, rect_max: Vec2, viewport_size: Vec2, pattern_origin: Vec2, checker_size: f32) -> Self { + fn new(transform: Affine2, rect_min: Vec2, rect_max: Vec2, viewport_size: Vec2, pattern_origin: Vec2, checker_size: f32, solid_white: bool) -> Self { Self { transform_x: transform.matrix2.x_axis.to_array(), transform_y: transform.matrix2.y_axis.to_array(), @@ -496,7 +515,7 @@ impl CompositeUniforms { viewport_size: viewport_size.to_array(), pattern_origin: pattern_origin.to_array(), checker_size, - _pad: 0., + solid_white: if solid_white { 1. } else { 0. }, } } } diff --git a/node-graph/nodes/gstd/src/render_background_checker_rect.wgsl b/node-graph/nodes/gstd/src/render_background_checker_rect.wgsl index 7dce58c939..4a543a5760 100644 --- a/node-graph/nodes/gstd/src/render_background_checker_rect.wgsl +++ b/node-graph/nodes/gstd/src/render_background_checker_rect.wgsl @@ -7,7 +7,7 @@ struct CompositeUniforms { viewport_size: vec2, pattern_origin: vec2, checker_size: f32, - _pad: f32, + solid_white: f32, }; @group(0) @binding(0) @@ -44,7 +44,10 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { fn fs_main(in: VertexOutput) -> @location(0) vec4 { let tile = floor((in.document_position - uniforms.pattern_origin) / uniforms.checker_size); let parity = i32(tile.x + tile.y) & 1; - let luminance = select(1.0, 0.8, parity == 1); + var luminance = select(1.0, 0.8, parity == 1); + if uniforms.solid_white != 0.0 { + luminance = 1.0; + } let fw = fwidthFine(in.document_position); let coverage_max = 1.0 - smoothstep(uniforms.rect_max - fw, uniforms.rect_max, in.document_position); diff --git a/node-graph/nodes/gstd/src/render_background_checker_viewport.wgsl b/node-graph/nodes/gstd/src/render_background_checker_viewport.wgsl index c583efcc38..d0705ab2c9 100644 --- a/node-graph/nodes/gstd/src/render_background_checker_viewport.wgsl +++ b/node-graph/nodes/gstd/src/render_background_checker_viewport.wgsl @@ -7,7 +7,7 @@ struct CompositeUniforms { viewport_size: vec2, pattern_origin: vec2, checker_size: f32, - _pad: f32, + solid_white: f32, }; @group(0) @binding(0) @@ -40,6 +40,9 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { fn fs_main(in: VertexOutput) -> @location(0) vec4 { let tile = floor((in.document_position - uniforms.pattern_origin) / uniforms.checker_size); let parity = i32(tile.x + tile.y) & 1; - let luminance = vec3(select(1.0, 0.8, parity == 1)); + var luminance = vec3(select(1.0, 0.8, parity == 1)); + if uniforms.solid_white != 0.0 { + luminance = vec3(1.0); + } return vec4(luminance, 1.0); } diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index fc9eb9551b..6e54835a99 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -174,6 +174,8 @@ async fn create_context<'a: 'n>( render_output_type, scale: render_config.scale, viewport_zoom: logical_viewport.scale_magnitudes().x, + hide_artboard_background: render_config.hide_artboard_background, + bake_stroke_dashes: render_config.bake_stroke_dashes, ..Default::default() }; diff --git a/plotter-upload-spec.md b/plotter-upload-spec.md new file mode 100644 index 0000000000..d933d3929e --- /dev/null +++ b/plotter-upload-spec.md @@ -0,0 +1,139 @@ +# Spec: "Send to Plotter" — upload the current document as SVG to a LAN print server + +## What this is + +We are running a conference booth where visitors draw vector artwork in Graphite at demo +stations, and their drawing gets physically plotted with a pen on letter paper by a Cricut +machine as a takeaway. A small HTTP print server (a separate, already-finished project) drives +the Cricut; it accepts SVG jobs over the LAN and manages a queue with a live dashboard. + +The missing piece, and the task for you, is on the Graphite side: an in-app action that exports +the current document as SVG and POSTs it to that print server. Today we have to save an SVG file +and manually upload it via the dashboard; visitors should instead be able to click one thing +inside Graphite. + +## Booth context (why the design looks like this) + +- Four Windows 11 demo stations run Graphite. The booth LAN is an isolated switch with **no + internet**: static IPs on `192.168.77.0/24` (stations are `.10`, `.20`, `.30`, `.40`), blank + gateway. Because there is no internet, each station runs Graphite from a **local dev server** + (`npm start`, i.e. an `http://localhost` origin), not from the production website. +- The print server runs on the station connected to the Cricut, listening on port `4747`. At + the booth it is expected at `http://192.168.77.10:4747`, but the host may change, so the + endpoint URL must be user-configurable and persisted. For development, the natural default is + `http://localhost:4747`. +- Sending a job is safe and non-destructive: the queue is human-gated twice (a booth attendant + resumes the paused queue from the dashboard, and each plot additionally waits for a physical + Go button press on the machine). Graphite only needs to fire the upload and report success or + failure; queue management, previews, and status all already exist on the server's dashboard. + +## The endpoint + +`POST http://:4747/api/jobs` + +Two accepted body forms; **use the raw form** (simplest): + +1. **Raw SVG body** — any `content-type` that is not `application/json` (use + `image/svg+xml`). The job name is taken from the `?name=` query parameter. +2. JSON body (`content-type: application/json`): `{ "svg": "", "name": "…", "options": { … } }`. + +Example of the raw form: + +``` +POST http://192.168.77.10:4747/api/jobs?name=Alices%20drawing +content-type: image/svg+xml + + +``` + +Server behavior: + +- **Success**: `201` with JSON `{ "id": "", "name": "", "status": "queued" }`. +- **Validation failure**: `400` with JSON `{ "error": "request body must contain an " }` + (the body must match `/]/i`). Other errors return `500` with `{ "error": "…" }`. +- **Body limit**: 25 MB. +- **CORS**: fully open. Every response carries `access-control-allow-origin: *`, and `OPTIONS` + preflights are answered with `access-control-allow-methods: GET,POST,DELETE,OPTIONS` and + `access-control-allow-headers: content-type` (plus `access-control-allow-private-network: + true` for Chrome's private-network-access preflight). A plain browser `fetch` works. +- If a `name` is omitted the server invents one; still, always send the document name so the + attendant can tell jobs apart on the dashboard. +- Do **not** send job options from Graphite (paper size, rotation, etc. exist as query + parameters, but the booth-wide defaults are configured on the server; the client staying + dumb is a feature). + +## What the plotter does with the SVG (sets expectations for the export) + +- The artwork is auto-scaled to fit 7.5×10" (letter paper minus margin) preserving aspect + ratio, and auto-rotated to portrait when clearly landscape. Absolute units and document size + in the SVG are irrelevant; only the aspect ratio and the shapes matter. +- Everything is drawn with a **pen**: every path renders as its outline. Fills are not filled + in; a filled shape plots as its contour. +- The server already strips Graphite's artboard background: an exported artboard produces a + background `` immediately before a `` group, and the + server removes exactly that rect. So exporting a document with an artboard is fine as-is. + Known limitation: a solid background drawn as anything else (a giant ``, a path) + is NOT stripped and would be plotted, but that is a server concern, not yours. +- Use the same SVG serialization as the existing file export (File > Export); the server is + known to handle that output. Do not invent a new export path. + +## What to build in Graphite + +1. A menu action (e.g. **File > Send to Plotter…**, near Export) that opens a small dialog: + - **Server address** text field, persisted across sessions (default `http://localhost:4747`). + Accept a bare `host:port` or full origin; normalize to `/api/jobs` internally. + - **Job name** text field, defaulting to the document name. + - A **Send** button. +2. On send: export the current document to an SVG string exactly as File > Export SVG would + (whole document / all artboards, default settings), then + `fetch(endpoint, { method: 'POST', headers: { 'content-type': 'image/svg+xml' }, body: svg })` + with the name in the query string. +3. Feedback: + - Success (`201`): a brief confirmation (e.g. a toast/dialog: "Sent to plotter queue as + ''"). + - Failure: show the reason. Distinguish "could not reach the server" (network error — + wrong address, server not running) from a server-reported error (`400`/`500` JSON + `error` field). Booth attendants are non-experts under time pressure; the message should + say what to check ("Is the print server running at
?"). + - A pending state on the button while in flight; sends should not be double-fireable. +4. Non-goals: no queue status display, no job management, no auth, no retry logic, no + settings beyond the address field. The server dashboard covers all of that. + +## Testing without the plotter + +The print server is a zero-dependency Node ≥ 20 project; run `node bin/cricut-print-server.mjs +serve` from its repo and it listens on `:4747` even with no Cricut hardware or Design Space +running — submitted jobs simply sit in the paused queue, visible with previews at +`http://127.0.0.1:4747/`. That is the ideal end-to-end check: send from Graphite, see the job +card appear with the right name and a correct preview. + +If you don't have that repo, a sufficient mock is: + +```js +require('node:http').createServer((req, res) => { + let b = ''; + req.on('data', (c) => (b += c)); + req.on('end', () => { + const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type', 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS', 'access-control-allow-private-network': 'true' }; + if (req.method === 'OPTIONS') { res.writeHead(204, cors); return res.end(); } + const ok = /]/i.test(b); + res.writeHead(ok ? 201 : 400, { ...cors, 'content-type': 'application/json' }); + res.end(JSON.stringify(ok ? { id: 'test1', name: new URL(req.url, 'http://x').searchParams.get('name'), status: 'queued' } : { error: 'request body must contain an ' })); + console.log(req.method, req.url, b.length, 'bytes'); + }); +}).listen(4747); +``` + +## Gotchas + +- **Mixed content**: a Graphite instance served over HTTPS (the production site) cannot + `fetch` an `http://` LAN address — the browser blocks it silently-ish. This is fine for the + booth (stations run `http://localhost` dev builds, and localhost is a secure context allowed + to reach private hosts), but don't be confused if a test from the production site fails; + consider mentioning it in the failure message if `location.protocol === 'https:'` and the + target is `http:`. +- **Chrome private network access**: Chrome sends a special preflight when a page reaches into + a private network. The server answers it (`access-control-allow-private-network: true`), so + this should just work; noted here in case a future Chrome version tightens behavior. +- The SVG can be large (procedural documents); it is sent as one POST body, well under the + 25 MB limit in practice. No chunking or compression needed.