Skip to content

Commit cd648a0

Browse files
Merge pull request #208 from bhavyanjain3004/feature/modular-graph-canvas-web
feat:Validate and fix modular graph canvas for Emscripten web demo
2 parents 70011b1 + 8a5837c commit cd648a0

11 files changed

Lines changed: 428 additions & 152 deletions

File tree

.github/workflows/ci.yml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,13 +389,22 @@ jobs:
389389
run: npx playwright test
390390

391391
- name: Upload Playwright HTML report
392-
if: failure()
392+
if: always()
393393
uses: actions/upload-artifact@v7
394394
with:
395395
name: playwright-report
396396
path: tests/web/playwright-report/
397397
retention-days: 7
398398

399+
- name: Upload Playwright test results
400+
if: always()
401+
uses: actions/upload-artifact@v7
402+
with:
403+
name: playwright-test-results
404+
path: tests/web/test-results/
405+
retention-days: 7
406+
407+
399408
test-windows:
400409
name: Test on Windows
401410
runs-on: windows-latest

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,13 @@ desktop.ini
3535
external/
3636
node_modules/
3737
package-lock.json
38+
# Playwright test results & reports
3839
test-results/
40+
playwright-report/
41+
blob-report/
42+
tests/web/test-results/
43+
tests/web/playwright-report/
44+
tests/web/blob-report/
3945

4046
# Compiled
4147
*.o

src/gui/gui_graph_state.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,15 @@ class GuiGraphState {
2020

2121
// Canvas panning and zoom/grid configurations
2222
ImVec2 scrolling = ImVec2(0.0f, 0.0f);
23+
ImVec2 target_scrolling = ImVec2(0.0f, 0.0f);
2324
bool show_grid = true;
2425
bool is_fullscreen = false;
2526
bool hand_tool_active = false;
2627
float zoom = 1.0f;
28+
float target_zoom = 1.0f;
29+
float dpi_scale = 1.0f;
30+
ImVec2 last_canvas_pos = ImVec2(0.0f, 0.0f);
31+
bool canvas_hovered = false;
2732

2833
// Node positioning registry mapped by Node ID
2934
std::unordered_map<int, NodeLayoutState> node_positions;

src/gui/gui_manager.cpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#include "gui/theme.h"
44
#include "gui/file_dialog.h"
55
#include "gui/command.h"
6+
#include "gui/gui_graph_state.h"
67
#include "preset_manager.h"
78

89
#include "gui/gl_setup.h"
@@ -123,6 +124,8 @@ bool GuiManager::initialize(int width, int height) {
123124
}
124125
#endif
125126

127+
GuiGraphState::get_instance().dpi_scale = dpi_scale;
128+
126129
{
127130
const float base_font_size = 14.0f;
128131
const float scaled_size = base_font_size * dpi_scale;

src/gui/gui_manager.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ class GuiManager {
4646
bool run_frame();
4747

4848
MidiManager& midi_manager() { return midi_manager_; }
49+
AudioEngine& audio_engine() { return engine_; }
4950

5051
private:
5152
void render_menu_bar();

src/gui/pedal_board_chain.cpp

Lines changed: 68 additions & 63 deletions
Large diffs are not rendered by default.

src/main.cpp

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#include "common.h"
22
#include "audio/audio_engine.h"
33
#include "gui/gui_manager.h"
4+
#include "gui/gui_graph_state.h"
45
#include "preset_manager.h"
56
#include "cli.h"
67

@@ -88,6 +89,110 @@ extern "C" EMSCRIPTEN_KEEPALIVE void on_midi_device_connected(const char* device
8889

8990
emscripten_log(EM_LOG_INFO, "[MIDI] Device connected: %s", device_name);
9091
}
92+
93+
extern "C" EMSCRIPTEN_KEEPALIVE void on_canvas_touch_gesture(float dx, float dy, float dscale, float local_x, float local_y) {
94+
auto& ui = Amplitron::GuiGraphState::get_instance();
95+
if (dscale != 0.0f) {
96+
float factor = 1.0f + dscale;
97+
float old_zoom = ui.target_zoom;
98+
float new_zoom = old_zoom * factor;
99+
if (new_zoom < 0.2f) new_zoom = 0.2f;
100+
if (new_zoom > 5.0f) new_zoom = 5.0f;
101+
float actual_factor = new_zoom / old_zoom;
102+
ui.target_scrolling.x = local_x - (local_x - ui.target_scrolling.x) * actual_factor;
103+
ui.target_scrolling.y = local_y - (local_y - ui.target_scrolling.y) * actual_factor;
104+
ui.target_zoom = new_zoom;
105+
}
106+
ui.target_scrolling.x += dx;
107+
ui.target_scrolling.y += dy;
108+
}
109+
110+
extern "C" EMSCRIPTEN_KEEPALIVE bool is_canvas_hovered() {
111+
return Amplitron::GuiGraphState::get_instance().canvas_hovered;
112+
}
113+
114+
extern "C" EMSCRIPTEN_KEEPALIVE float get_canvas_zoom() {
115+
return Amplitron::GuiGraphState::get_instance().target_zoom;
116+
}
117+
118+
extern "C" EMSCRIPTEN_KEEPALIVE float get_canvas_scroll_x() {
119+
return Amplitron::GuiGraphState::get_instance().target_scrolling.x;
120+
}
121+
122+
extern "C" EMSCRIPTEN_KEEPALIVE float get_canvas_scroll_y() {
123+
return Amplitron::GuiGraphState::get_instance().target_scrolling.y;
124+
}
125+
126+
extern "C" EMSCRIPTEN_KEEPALIVE int get_node_count() {
127+
if (!g_gui) return 0;
128+
return static_cast<int>(g_gui->audio_engine().graph().get_nodes().size());
129+
}
130+
131+
extern "C" EMSCRIPTEN_KEEPALIVE int get_link_count() {
132+
if (!g_gui) return 0;
133+
return static_cast<int>(g_gui->audio_engine().graph().get_links().size());
134+
}
135+
136+
extern "C" EMSCRIPTEN_KEEPALIVE bool has_node_of_type(int routing_type) {
137+
if (!g_gui) return false;
138+
for (const auto& n : g_gui->audio_engine().graph().get_nodes()) {
139+
if (static_cast<int>(n.routing_type) == routing_type) return true;
140+
}
141+
return false;
142+
}
143+
144+
extern "C" EMSCRIPTEN_KEEPALIVE int trigger_add_splitter_node() {
145+
if (!g_gui) return -1;
146+
auto& graph = g_gui->audio_engine().graph();
147+
int id = graph.add_node("Splitter", Amplitron::NodeRoutingType::Splitter);
148+
g_gui->audio_engine().commit_graph_changes();
149+
return id;
150+
}
151+
152+
extern "C" EMSCRIPTEN_KEEPALIVE int trigger_add_link(int src_pin, int dst_pin) {
153+
if (!g_gui) return -1;
154+
auto& graph = g_gui->audio_engine().graph();
155+
int id = graph.add_link(src_pin, dst_pin);
156+
g_gui->audio_engine().commit_graph_changes();
157+
return id;
158+
}
159+
160+
extern "C" EMSCRIPTEN_KEEPALIVE int get_node_output_pin_by_index(int node_index, int pin_index) {
161+
if (!g_gui) return -1;
162+
const auto& nodes = g_gui->audio_engine().graph().get_nodes();
163+
if (node_index < 0 || node_index >= static_cast<int>(nodes.size())) return -1;
164+
const auto& node = nodes[node_index];
165+
if (pin_index < 0 || pin_index >= static_cast<int>(node.output_pin_ids.size())) return -1;
166+
return node.output_pin_ids[pin_index];
167+
}
168+
169+
extern "C" EMSCRIPTEN_KEEPALIVE int get_node_input_pin_by_index(int node_index, int pin_index) {
170+
if (!g_gui) return -1;
171+
const auto& nodes = g_gui->audio_engine().graph().get_nodes();
172+
if (node_index < 0 || node_index >= static_cast<int>(nodes.size())) return -1;
173+
const auto& node = nodes[node_index];
174+
if (pin_index < 0 || pin_index >= static_cast<int>(node.input_pin_ids.size())) return -1;
175+
return node.input_pin_ids[pin_index];
176+
}
177+
178+
extern "C" EMSCRIPTEN_KEEPALIVE bool trigger_delete_last_node() {
179+
if (!g_gui) return false;
180+
auto& graph = g_gui->audio_engine().graph();
181+
const auto& nodes = graph.get_nodes();
182+
// Walk backwards to find the last deletable node (mirrors GUI rules: Input and Amp Sim are protected)
183+
for (int i = static_cast<int>(nodes.size()) - 1; i >= 0; --i) {
184+
const auto& node = nodes[i];
185+
if (node.name == "Input" || node.name == "Amp Sim") continue;
186+
bool ok = graph.remove_node(node.id);
187+
if (ok) {
188+
Amplitron::GuiGraphState::get_instance().node_positions.erase(node.id);
189+
g_gui->audio_engine().commit_graph_changes();
190+
}
191+
return ok;
192+
}
193+
return false; // No deletable node found
194+
}
195+
91196
#endif
92197

93198
void signal_handler(int /*signal*/) {

tests/web/amplitron.spec.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@
1414

1515
import { test, expect, Page, ConsoleMessage } from '@playwright/test';
1616

17+
// Emscripten injects `Module` into the page's global scope at runtime.
18+
// Declare it here so TypeScript doesn't report ts(2304) errors.
19+
// Overloads narrow the return type based on the `returnType` string literal.
20+
declare const Module: {
21+
ccall(ident: string, returnType: 'number', argTypes: string[], args: (number | string | boolean)[]): number;
22+
ccall(ident: string, returnType: 'boolean', argTypes: string[], args: (number | string | boolean)[]): boolean;
23+
ccall(ident: string, returnType: 'string', argTypes: string[], args: (number | string | boolean)[]): string;
24+
ccall(ident: string, returnType: null, argTypes: string[], args: (number | string | boolean)[]): void;
25+
};
26+
27+
1728
// ---------------------------------------------------------------------------
1829
// Helpers
1930
// ---------------------------------------------------------------------------
@@ -465,4 +476,139 @@ test.describe('Web MIDI Support', () => {
465476
timeout: 5000
466477
});
467478
});
479+
});
480+
test.describe('Modular Graph Canvas Interactions', () => {
481+
async function waitForRuntime(page: Page) {
482+
page.on('console', msg => console.log('BROWSER LOG:', msg.text()));
483+
page.on('pageerror', err => console.error('BROWSER ERROR:', err.message));
484+
await page.goto('/');
485+
await page.waitForSelector('#loading.hidden', { timeout: 60_000 });
486+
const overlay = page.locator('#audio-unlock');
487+
if (await overlay.isVisible()) await overlay.click();
488+
await page.waitForTimeout(500);
489+
}
490+
491+
test('canvas pan via right-click drag shifts scrolling', async ({ page }) => {
492+
await waitForRuntime(page);
493+
494+
const before = await page.evaluate(() => ({
495+
x: Module.ccall('get_canvas_scroll_x', 'number', [], []),
496+
y: Module.ccall('get_canvas_scroll_y', 'number', [], []),
497+
}));
498+
499+
const canvas = page.locator('#canvas');
500+
const box = await canvas.boundingBox();
501+
if (!box) throw new Error('canvas not visible');
502+
503+
const cx = box.x + box.width / 2;
504+
const cy = box.y + box.height / 2;
505+
await page.mouse.click(cx, cy, { button: 'right' });
506+
await page.mouse.move(cx, cy);
507+
await page.mouse.down({ button: 'right' });
508+
await page.mouse.move(cx + 80, cy + 60, { steps: 10 });
509+
await page.mouse.up({ button: 'right' });
510+
511+
await page.waitForTimeout(200);
512+
513+
const after = await page.evaluate(() => ({
514+
x: Module.ccall('get_canvas_scroll_x', 'number', [], []),
515+
y: Module.ccall('get_canvas_scroll_y', 'number', [], []),
516+
}));
517+
518+
expect(after.x).not.toBeCloseTo(before.x, 0);
519+
expect(after.y).not.toBeCloseTo(before.y, 0);
520+
});
521+
522+
test('two-finger touch gesture pans and zooms the canvas', async ({ page }) => {
523+
await waitForRuntime(page);
524+
525+
const before = await page.evaluate(() => ({
526+
zoom: Module.ccall('get_canvas_zoom', 'number', [], []),
527+
sx: Module.ccall('get_canvas_scroll_x', 'number', [], []),
528+
}));
529+
530+
await page.evaluate(() => {
531+
Module.ccall('on_canvas_touch_gesture', null, ['number','number','number','number','number'], [30, 20, 0.15, 640, 360]);
532+
});
533+
534+
const after = await page.evaluate(() => ({
535+
zoom: Module.ccall('get_canvas_zoom', 'number', [], []),
536+
sx: Module.ccall('get_canvas_scroll_x', 'number', [], []),
537+
}));
538+
539+
expect(after.zoom).toBeGreaterThan(before.zoom);
540+
expect(after.sx).not.toBeCloseTo(before.sx, 0);
541+
});
542+
543+
test('adding a Splitter node increases the node count', async ({ page }) => {
544+
await waitForRuntime(page);
545+
546+
const countBefore: number = await page.evaluate(() =>
547+
Module.ccall('get_node_count', 'number', [], [])
548+
);
549+
550+
await page.evaluate(() =>
551+
Module.ccall('trigger_add_splitter_node', 'number', [], [])
552+
);
553+
await page.waitForTimeout(200);
554+
555+
const countAfter: number = await page.evaluate(() =>
556+
Module.ccall('get_node_count', 'number', [], [])
557+
);
558+
559+
expect(countAfter).toBe(countBefore + 1);
560+
561+
const hasSplitter: boolean = await page.evaluate(() =>
562+
Module.ccall('has_node_of_type', 'boolean', ['number'], [1])
563+
);
564+
expect(hasSplitter).toBe(true);
565+
});
566+
567+
test('drawing a cable between two nodes increases link count', async ({ page }) => {
568+
await waitForRuntime(page);
569+
570+
const linksBefore: number = await page.evaluate(() =>
571+
Module.ccall('get_link_count', 'number', [], [])
572+
);
573+
574+
await page.evaluate(() => {
575+
Module.ccall('trigger_add_splitter_node', 'number', [], []);
576+
});
577+
await page.waitForTimeout(100);
578+
579+
const result: number = await page.evaluate(() => {
580+
const srcPin = Module.ccall('get_node_output_pin_by_index', 'number', ['number', 'number'], [2, 0]);
581+
const dstPin = Module.ccall('get_node_input_pin_by_index', 'number', ['number', 'number'], [3, 0]);
582+
return Module.ccall('trigger_add_link', 'number', ['number', 'number'], [srcPin, dstPin]);
583+
});
584+
585+
const linksAfter: number = await page.evaluate(() =>
586+
Module.ccall('get_link_count', 'number', [], [])
587+
);
588+
589+
expect(linksAfter).toBeGreaterThan(linksBefore);
590+
});
591+
592+
test('deleting a node decreases the node count', async ({ page }) => {
593+
await waitForRuntime(page);
594+
595+
await page.evaluate(() =>
596+
Module.ccall('trigger_add_splitter_node', 'number', [], [])
597+
);
598+
await page.waitForTimeout(100);
599+
600+
const countBefore: number = await page.evaluate(() =>
601+
Module.ccall('get_node_count', 'number', [], [])
602+
);
603+
604+
const deleted: boolean = await page.evaluate(() =>
605+
Module.ccall('trigger_delete_last_node', 'boolean', [], [])
606+
);
607+
expect(deleted).toBe(true);
608+
609+
const countAfter: number = await page.evaluate(() =>
610+
Module.ccall('get_node_count', 'number', [], [])
611+
);
612+
expect(countAfter).toBe(countBefore - 1);
613+
});
468614
});

tests/web/playwright-report/index.html

Lines changed: 0 additions & 85 deletions
This file was deleted.

tests/web/playwright.config.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,7 @@ export default defineConfig({
4343
'--use-fake-audio-capture',
4444
'--use-fake-device-for-media-stream',
4545
'--use-fake-ui-for-media-stream',
46-
// SwiftShader software rasteriser for WebGL in CI (no GPU)
47-
'--use-gl=swiftshader',
48-
'--disable-gpu',
46+
...(process.env.CI ? ['--use-gl=swiftshader', '--disable-gpu'] : ['--use-gl=angle']),
4947
'--no-sandbox',
5048
],
5149
},

0 commit comments

Comments
 (0)