This commit is contained in:
Keavon Chambers
2026-07-15 21:54:17 -07:00
parent 751a6ca77d
commit 9537a4ae03
30 changed files with 766 additions and 39 deletions

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;

View File

@@ -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>,
},

View File

@@ -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,
);
}

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)]

View 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};

View File

@@ -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,
}

View File

@@ -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")
}
}

View File

@@ -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;

View File

@@ -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(),
]),
])
}
}

View File

@@ -106,6 +106,11 @@ pub enum FrontendMessage {
mime: String,
size: (f64, f64),
},
TriggerSendToPlotter {
name: String,
svg: String,
address: String,
},
TriggerFetchAndOpenDocument {
name: String,
filename: String,

View File

@@ -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

View File

@@ -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],

View File

@@ -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

View File

@@ -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,

View File

@@ -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 });

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};

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,

View File

@@ -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)]

View File

@@ -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");

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -1,4 +1,5 @@
pub mod convert_usvg_path;
pub mod plot_statistics;
pub mod render_ext;
mod renderer;
pub mod to_peniko;

View 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);
}
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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. },
}
}
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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
View 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.