mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 14:18:04 +08:00
Plotter
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
7
editor/src/messages/dialog/send_to_plotter_dialog/mod.rs
Normal file
7
editor/src/messages/dialog/send_to_plotter_dialog/mod.rs
Normal file
@@ -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 });
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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<PortfolioStoreState> = import.meta.hot?.data?.store || writable<PortfolioStoreState>(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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod convert_usvg_path;
|
||||
pub mod plot_statistics;
|
||||
pub mod render_ext;
|
||||
mod renderer;
|
||||
pub mod to_peniko;
|
||||
|
||||
138
node-graph/libraries/rendering/src/plot_statistics.rs
Normal file
138
node-graph/libraries/rendering/src/plot_statistics.rs
Normal file
@@ -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<Rect>,
|
||||
}
|
||||
|
||||
/// 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<PlotStatistics> {
|
||||
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##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 200" width="100" height="200">
|
||||
<path d="M10,10 L40,10 L40,40 L10,40 Z" fill="black" />
|
||||
<path d="M10,50 L10,150" stroke="black" fill="none" />
|
||||
</svg>"##;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -230,6 +230,10 @@ pub struct RenderParams {
|
||||
pub artboard_background: Option<Color>,
|
||||
/// 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<Artboard> {
|
||||
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<Artboard> {
|
||||
|
||||
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<Vector> {
|
||||
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<f64> = stroke.dash_lengths.iter().map(|length| length.max(0.)).collect();
|
||||
if dash_pattern.iter().sum::<f64>() <= 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<Vector> {
|
||||
(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<Vector> {
|
||||
}
|
||||
|
||||
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<Vector> {
|
||||
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<Vector> {
|
||||
});
|
||||
|
||||
// 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<Vector> {
|
||||
// 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::<f64>() > 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);
|
||||
}
|
||||
|
||||
@@ -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. },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ struct CompositeUniforms {
|
||||
viewport_size: vec2<f32>,
|
||||
pattern_origin: vec2<f32>,
|
||||
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<f32> {
|
||||
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);
|
||||
|
||||
@@ -7,7 +7,7 @@ struct CompositeUniforms {
|
||||
viewport_size: vec2<f32>,
|
||||
pattern_origin: vec2<f32>,
|
||||
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<f32> {
|
||||
let tile = floor((in.document_position - uniforms.pattern_origin) / uniforms.checker_size);
|
||||
let parity = i32(tile.x + tile.y) & 1;
|
||||
let luminance = vec3<f32>(select(1.0, 0.8, parity == 1));
|
||||
var luminance = vec3<f32>(select(1.0, 0.8, parity == 1));
|
||||
if uniforms.solid_white != 0.0 {
|
||||
luminance = vec3<f32>(1.0);
|
||||
}
|
||||
return vec4<f32>(luminance, 1.0);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
|
||||
139
plotter-upload-spec.md
Normal file
139
plotter-upload-spec.md
Normal file
@@ -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://<host>: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": "<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
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" …>…</svg>
|
||||
```
|
||||
|
||||
Server behavior:
|
||||
|
||||
- **Success**: `201` with JSON `{ "id": "<job id>", "name": "<name>", "status": "queued" }`.
|
||||
- **Validation failure**: `400` with JSON `{ "error": "request body must contain an <svg>" }`
|
||||
(the body must match `/<svg[\s>]/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 `<rect>` immediately before a `<g clip-path="url(#artboard-…)">` 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 `<polyline>`, 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 `<origin>/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
|
||||
'<name>'").
|
||||
- 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 <address>?").
|
||||
- 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 = /<svg[\s>]/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 <svg>' }));
|
||||
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.
|
||||
Reference in New Issue
Block a user