Skip to content
3 changes: 1 addition & 2 deletions desktop/src/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ use winit::window::{Window as WinitWindow, WindowAttributes};

use crate::consts::APP_NAME;
use crate::event::AppEventScheduler;
use crate::window::mac::NativeWindowImpl;
use crate::wrapper::messages::MenuItem;

pub(crate) trait NativeWindow {
Expand Down Expand Up @@ -37,7 +36,7 @@ pub(crate) struct Window {

impl Window {
pub(crate) fn init() {
NativeWindowImpl::init();
native::NativeWindowImpl::init();
}

pub(crate) fn new(event_loop: &dyn ActiveEventLoop, app_event_scheduler: AppEventScheduler) -> Self {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -882,70 +882,6 @@ fn static_nodes() -> Vec<DocumentNodeDefinition> {
properties: None,
},
#[cfg(feature = "gpu")]
DocumentNodeDefinition {
identifier: "Create GPU Surface",
category: "Debug: GPU",
node_template: NodeTemplate {
document_node: DocumentNode {
implementation: DocumentNodeImplementation::Network(NodeNetwork {
exports: vec![NodeInput::node(NodeId(1), 0)],
nodes: [
DocumentNode {
inputs: vec![NodeInput::scope("editor-api")],
implementation: DocumentNodeImplementation::ProtoNode(wgpu_executor::create_gpu_surface::IDENTIFIER),
..Default::default()
},
DocumentNode {
inputs: vec![NodeInput::node(NodeId(0), 0)],
implementation: DocumentNodeImplementation::ProtoNode(memo::memo::IDENTIFIER),
..Default::default()
},
]
.into_iter()
.enumerate()
.map(|(id, node)| (NodeId(id as u64), node))
.collect(),
..Default::default()
}),
..Default::default()
},
persistent_node_metadata: DocumentNodePersistentMetadata {
output_names: vec!["GPU Surface".to_string()],
network_metadata: Some(NodeNetworkMetadata {
persistent_metadata: NodeNetworkPersistentMetadata {
node_metadata: [
DocumentNodeMetadata {
persistent_metadata: DocumentNodePersistentMetadata {
display_name: "Create GPU Surface".to_string(),
node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(0, 0)),
..Default::default()
},
..Default::default()
},
DocumentNodeMetadata {
persistent_metadata: DocumentNodePersistentMetadata {
display_name: "Cache".to_string(),
node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(7, 0)),
..Default::default()
},
..Default::default()
},
]
.into_iter()
.enumerate()
.map(|(id, node)| (NodeId(id as u64), node))
.collect(),
..Default::default()
},
..Default::default()
}),
..Default::default()
},
},
description: Cow::Borrowed("TODO"),
properties: None,
},
#[cfg(feature = "gpu")]
DocumentNodeDefinition {
identifier: "Upload Texture",
category: "Debug: GPU",
Expand Down
11 changes: 7 additions & 4 deletions editor/src/messages/portfolio/portfolio_message_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ use crate::messages::prelude::*;
use crate::messages::tool::common_functionality::graph_modification_utils;
use crate::messages::tool::common_functionality::utility_functions::make_path_editable_is_allowed;
use crate::messages::tool::utility_types::{HintData, HintGroup, ToolType};
use crate::messages::viewport::ToPhysical;
use crate::node_graph_executor::{ExportConfig, NodeGraphExecutor};
use derivative::*;
use glam::{DAffine2, DVec2};
Expand Down Expand Up @@ -364,12 +365,13 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
let node_to_inspect = self.node_to_inspect();

let scale = viewport.scale();
let resolution = viewport.size().into_dvec2().round().as_uvec2();
// Use exact physical dimensions from browser (via ResizeObserver's devicePixelContentBoxSize)
let physical_resolution = viewport.size().to_physical().into_dvec2().round().as_uvec2();

if let Ok(message) = self.executor.submit_node_graph_evaluation(
self.documents.get_mut(document_id).expect("Tried to render non-existent document"),
*document_id,
resolution,
physical_resolution,
scale,
timing_information,
node_to_inspect,
Expand Down Expand Up @@ -970,11 +972,12 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
};

let scale = viewport.scale();
let resolution = viewport.size().into_dvec2().round().as_uvec2();
// Use exact physical dimensions from browser (via ResizeObserver's devicePixelContentBoxSize)
let physical_resolution = viewport.size().to_physical().into_dvec2().round().as_uvec2();

let result = self
.executor
.submit_node_graph_evaluation(document, document_id, resolution, scale, timing_information, node_to_inspect, ignore_hash);
.submit_node_graph_evaluation(document, document_id, physical_resolution, scale, timing_information, node_to_inspect, ignore_hash);

match result {
Err(description) => {
Expand Down
4 changes: 2 additions & 2 deletions editor/src/node_graph_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -421,8 +421,8 @@ impl NodeGraphExecutor {
let matrix = format_transform_matrix(frame.transform);
let transform = if matrix.is_empty() { String::new() } else { format!(" transform=\"{matrix}\"") };
let svg = format!(
r#"<svg><foreignObject width="{}" height="{}"{transform}><div data-canvas-placeholder="{}"></div></foreignObject></svg>"#,
frame.resolution.x, frame.resolution.y, frame.surface_id.0
r#"<svg><foreignObject width="{}" height="{}"{transform}><div data-canvas-placeholder="{}" data-is-viewport="true"></div></foreignObject></svg>"#,
frame.resolution.x, frame.resolution.y, frame.surface_id.0,
);
self.last_svg_canvas = Some(frame);
responses.add(FrontendMessage::UpdateDocumentArtwork { svg });
Expand Down
82 changes: 82 additions & 0 deletions editor/src/node_graph_executor/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ pub struct NodeRuntime {
/// The current renders of the thumbnails for layer nodes.
thumbnail_renders: HashMap<NodeId, Vec<SvgSegment>>,
vector_modify: HashMap<NodeId, Vector>,

/// Cached surface for WASM viewport rendering (reused across frames)
#[cfg(all(target_family = "wasm", feature = "gpu"))]
wasm_viewport_surface: Option<wgpu_executor::WgpuSurface>,
}

/// Messages passed from the editor thread to the node runtime thread.
Expand Down Expand Up @@ -131,6 +135,8 @@ impl NodeRuntime {
thumbnail_renders: Default::default(),
vector_modify: Default::default(),
inspect_state: None,
#[cfg(all(target_family = "wasm", feature = "gpu"))]
wasm_viewport_surface: None,
}
}

Expand Down Expand Up @@ -259,6 +265,82 @@ impl NodeRuntime {
None,
)
}
#[cfg(all(target_family = "wasm", feature = "gpu"))]
Ok(TaggedValue::RenderOutput(RenderOutput {
data: RenderOutputType::Texture(image_texture),
metadata,
})) if !render_config.for_export => {
// On WASM, for viewport rendering, blit the texture to a surface and return a CanvasFrame
let app_io = self.editor_api.application_io.as_ref().unwrap();
let executor = app_io.gpu_executor().expect("GPU executor should be available when we receive a texture");

// Get or create the cached surface
if self.wasm_viewport_surface.is_none() {
let surface_handle = app_io.create_window();
let wasm_surface = executor
.create_surface(graphene_std::wasm_application_io::WasmSurfaceHandle {
surface: surface_handle.surface.clone(),
window_id: surface_handle.window_id,
})
.expect("Failed to create surface");
self.wasm_viewport_surface = Some(Arc::new(wasm_surface));
}

let surface = self.wasm_viewport_surface.as_ref().unwrap();

// Use logical resolution for CSS sizing, physical resolution for the actual surface/texture
let physical_resolution = render_config.viewport.resolution;
let logical_resolution = physical_resolution.as_dvec2() / render_config.scale;

// Blit the texture to the surface
let mut encoder = executor.context.device.create_command_encoder(&vello::wgpu::CommandEncoderDescriptor {
label: Some("Texture to Surface Blit"),
});

// Configure the surface at physical resolution (for HiDPI displays)
let surface_inner = &surface.surface.inner;
let surface_caps = surface_inner.get_capabilities(&executor.context.adapter);
surface_inner.configure(
&executor.context.device,
&vello::wgpu::SurfaceConfiguration {
usage: vello::wgpu::TextureUsages::RENDER_ATTACHMENT | vello::wgpu::TextureUsages::COPY_DST,
format: vello::wgpu::TextureFormat::Rgba8Unorm,
width: physical_resolution.x,
height: physical_resolution.y,
present_mode: surface_caps.present_modes[0],
alpha_mode: vello::wgpu::CompositeAlphaMode::Opaque,
view_formats: vec![],
desired_maximum_frame_latency: 2,
},
);

let surface_texture = surface_inner.get_current_texture().expect("Failed to get surface texture");

// Blit the rendered texture to the surface
surface.surface.blitter.copy(
&executor.context.device,
&mut encoder,
&image_texture.texture.create_view(&vello::wgpu::TextureViewDescriptor::default()),
&surface_texture.texture.create_view(&vello::wgpu::TextureViewDescriptor::default()),
);

executor.context.queue.submit([encoder.finish()]);
surface_texture.present();

let frame = graphene_std::application_io::SurfaceFrame {
surface_id: surface.window_id,
resolution: logical_resolution,
transform: glam::DAffine2::IDENTITY,
};

(
Ok(TaggedValue::RenderOutput(RenderOutput {
data: RenderOutputType::CanvasFrame(frame),
metadata,
})),
None,
)
}
Ok(TaggedValue::RenderOutput(RenderOutput {
data: RenderOutputType::Texture(texture),
metadata,
Expand Down
38 changes: 29 additions & 9 deletions frontend/src/components/panels/Document.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { getContext, onMount, tick } from "svelte";
import { getContext, onMount, onDestroy, tick } from "svelte";

import type { Editor } from "@graphite/editor";
import {
Expand All @@ -20,7 +20,7 @@
import type { DocumentState } from "@graphite/state-providers/document";
import { textInputCleanup } from "@graphite/utility-functions/keyboard-entry";
import { extractPixelData, rasterizeSVGCanvas } from "@graphite/utility-functions/rasterization";
import { updateBoundsOfViewports as updateViewport } from "@graphite/utility-functions/viewports";
import { setupViewportResizeObserver, cleanupViewportResizeObserver } from "@graphite/utility-functions/viewports";

import EyedropperPreview, { ZOOM_WINDOW_DIMENSIONS } from "@graphite/components/floating-menus/EyedropperPreview.svelte";
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
Expand Down Expand Up @@ -203,9 +203,18 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let canvas = (window as any).imageCanvases[canvasName];

if (canvasName !== "0" && canvas.parentElement) {
var newCanvas = window.document.createElement("canvas");
var context = newCanvas.getContext("2d");
// Get logical dimensions from foreignObject parent (set by backend)
const foreignObject = placeholder.parentElement;
if (!foreignObject) return;
const logicalWidth = parseFloat(foreignObject.getAttribute("width") || "0");
const logicalHeight = parseFloat(foreignObject.getAttribute("height") || "0");

// Clone canvas for repeated instances (layers that appear multiple times)
// Viewport canvas is marked with data-is-viewport and should never be cloned
const isViewport = placeholder.hasAttribute("data-is-viewport");
if (!isViewport && canvas.parentElement) {
const newCanvas = window.document.createElement("canvas");
const context = newCanvas.getContext("2d");

newCanvas.width = canvas.width;
newCanvas.height = canvas.height;
Expand All @@ -215,6 +224,10 @@
canvas = newCanvas;
}

// Set CSS size to logical resolution (for correct display size)
canvas.style.width = `${logicalWidth}px`;
canvas.style.height = `${logicalHeight}px`;

placeholder.replaceWith(canvas);
});
}
Expand Down Expand Up @@ -393,8 +406,8 @@
rulerHorizontal?.resize();
rulerVertical?.resize();

// Send the new bounds of the viewports to the backend
if (viewport.parentElement) updateViewport(editor);
// Note: Viewport bounds are now sent to the backend by the ResizeObserver in viewports.ts
// which provides pixel-perfect physical dimensions via devicePixelContentBoxSize
}

onMount(() => {
Expand Down Expand Up @@ -473,14 +486,21 @@
displayRemoveEditableTextbox();
});

// Once this component is mounted, we want to resend the document bounds to the backend via the resize event handler which does that
window.dispatchEvent(new Event("resize"));
// Setup ResizeObserver for pixel-perfect viewport tracking with physical dimensions
// This must happen in onMount to ensure the viewport container element exists
setupViewportResizeObserver(editor);

// Also observe the inner viewport for canvas sizing and ruler updates
const viewportResizeObserver = new ResizeObserver(() => {
updateViewportInfo();
});
if (viewport) viewportResizeObserver.observe(viewport);
});

onDestroy(() => {
// Cleanup the viewport resize observer
cleanupViewportResizeObserver();
});
</script>

<LayoutCol class="document" on:dragover={(e) => e.preventDefault()} on:drop={dropFile}>
Expand Down
4 changes: 0 additions & 4 deletions frontend/src/io-managers/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { makeKeyboardModifiersBitfield, textInputCleanup, getLocalizedScanCode }
import { operatingSystem } from "@graphite/utility-functions/platform";
import { extractPixelData } from "@graphite/utility-functions/rasterization";
import { stripIndents } from "@graphite/utility-functions/strip-indents";
import { updateBoundsOfViewports } from "@graphite/utility-functions/viewports";

const BUTTON_LEFT = 0;
const BUTTON_MIDDLE = 1;
Expand Down Expand Up @@ -43,7 +42,6 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const listeners: { target: EventListenerTarget; eventName: EventName; action: (event: any) => void; options?: AddEventListenerOptions }[] = [
{ target: window, eventName: "resize", action: () => updateBoundsOfViewports(editor) },
{ target: window, eventName: "beforeunload", action: (e: BeforeUnloadEvent) => onBeforeUnload(e) },
{ target: window, eventName: "keyup", action: (e: KeyboardEvent) => onKeyUp(e) },
{ target: window, eventName: "keydown", action: (e: KeyboardEvent) => onKeyDown(e) },
Expand Down Expand Up @@ -529,8 +527,6 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli

// Bind the event listeners
bindListeners();
// Resize on creation
updateBoundsOfViewports(editor);

// Return the destructor
return unbindListeners;
Expand Down
Loading