This commit is contained in:
Keavon Chambers
2026-07-18 00:15:36 -07:00
parent 751a6ca77d
commit 9537a4ae03
30 changed files with 766 additions and 39 deletions
+17
View File
@@ -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;
@@ -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<String>,
},
@@ -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<DialogMessage, DialogMessageContext<'_>> 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<DialogMessage, DialogMessageContext<'_>> 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<DialogMessage, DialogMessageContext<'_>> 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<DialogMessage, DialogMessageContext<'_>> for DialogMessageHa
RequestExportDialog,
RequestNewDocumentDialog,
RequestPreferencesDialog,
RequestSendToPlotterDialog,
);
}
+1
View File
@@ -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)]
@@ -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};
@@ -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<f64> },
Submit,
}
@@ -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<SendToPlotterDialogMessage, SendToPlotterDialogMessageContext<'_>> for SendToPlotterDialogMessageHandler {
fn process_message(&mut self, message: SendToPlotterDialogMessage, responses: &mut VecDeque<Message>, 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")
}
}
@@ -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;
@@ -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(),
]),
])
}
}
@@ -106,6 +106,11 @@ pub enum FrontendMessage {
mime: String,
size: (f64, f64),
},
TriggerSendToPlotter {
name: String,
svg: String,
address: String,
},
TriggerFetchAndOpenDocument {
name: String,
filename: String,
@@ -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
@@ -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],
@@ -1323,6 +1323,8 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> 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
@@ -197,6 +197,10 @@ pub enum PortfolioMessage {
artboard_name: Option<String>,
artboard_count: usize,
},
SubmitPlotterExport {
job_name: String,
estimate_only: bool,
},
SubmitActiveGraphRender,
SubmitGraphRender {
document_id: DocumentId,
@@ -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<PortfolioMessage, PortfolioMessageContext<'_>> 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 });
+1
View File
@@ -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};
+31 -2
View File
@@ -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,
@@ -91,6 +91,10 @@ pub struct ExportConfig {
pub size: UVec2,
pub artboard_name: Option<String>,
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)]