Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions scripts/capture-screenshots.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,23 @@

import puppeteer from 'puppeteer-core';
import { readFileSync, existsSync, mkdirSync } from 'fs';
import { dirname, join } from 'path';
import { dirname, join, resolve } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const STATIC_DIR = join(__dirname, '..', 'static', 'examples');
const SCREENSHOTS_DIR = join(STATIC_DIR, 'screenshots');
const MANIFEST_PATH = join(STATIC_DIR, 'manifest.json');

const BASE_URL = 'https://view.pathsim.org';
// Where to write the PNGs. Override (SCREENSHOT_OUT_DIR) so a fastsim build can
// capture straight into its build output instead of the default static dir.
const SCREENSHOTS_DIR = process.env.SCREENSHOT_OUT_DIR
? resolve(process.env.SCREENSHOT_OUT_DIR)
: join(STATIC_DIR, 'screenshots');

// Origin (+ base path) to screenshot. Defaults to the public blue pathview.
// The fastsim build points this at a local `vite preview` of the red /app
// build so the tiles match its styling (SCREENSHOT_BASE_URL=http://localhost:PORT/app).
const BASE_URL = process.env.SCREENSHOT_BASE_URL || 'https://view.pathsim.org';
const VIEWPORT = { width: 1000, height: 600 };
const DEVICE_SCALE_FACTOR = 1;
const SETTLE_DELAY = 5000;
Expand Down
7 changes: 4 additions & 3 deletions src/lib/components/WelcomeModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { cubicOut } from 'svelte/easing';
import Icon from '$lib/components/icons/Icon.svelte';
import { PATHVIEW_VERSION, EXTRACTED_VERSIONS } from '$lib/constants/dependencies';
import { BRAND } from '$lib/constants/brand';
import { startGuidedTour, type TourId } from '$lib/tours';

interface Example {
Expand Down Expand Up @@ -102,8 +103,8 @@
</div>

<div class="header">
<img src="{base}/pathview_logo.png" alt="PathView" class="logo" />
<p class="tagline">Visual block-diagram editor for the PathSim simulation framework</p>
<img src="{base}/{BRAND.logo}" alt="{BRAND.name}" class="logo" />
<p class="tagline">Visual block-diagram editor for the {BRAND.framework} simulation framework</p>
</div>

<div class="actions">
Expand All @@ -112,7 +113,7 @@
<span class="action-label">New</span>
</button>

<a href="https://pathsim.org" target="_blank" class="action-card">
<a href={BRAND.home} target="_blank" class="action-card">
<Icon name="home" size={20} />
<span class="action-label">Home</span>
</a>
Expand Down
25 changes: 25 additions & 0 deletions src/lib/constants/brand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Visible product branding, configurable at build time.
*
* Defaults to PathView (PathSim blue). A re-branded distribution overrides any
* of these via `VITE_BRAND_*` env vars at build time, without touching the
* components that read them. `key` is set as `data-brand` on <html> so CSS can
* key an accent override off it; the JS accent (`accent` / `keywordColor`) feeds
* the canvas default color and the CodeMirror palette.
*/
export const BRAND = {
/** Short key, set as `data-brand` on <html> for CSS overrides. */
key: import.meta.env.VITE_BRAND_KEY || 'pathsim',
/** Display name (window title, logo alt, autosave prompt, welcome header). */
name: import.meta.env.VITE_BRAND_NAME || 'PathView',
/** Logo asset filename under static/. */
logo: import.meta.env.VITE_BRAND_LOGO || 'pathview_logo.png',
/** Primary accent (matches the CSS `--accent` default). */
accent: import.meta.env.VITE_BRAND_ACCENT || '#0070C0',
/** CodeMirror keyword color (control flow / imports). */
keywordColor: import.meta.env.VITE_BRAND_KEYWORD || '#E57373',
/** Home link target (welcome modal). */
home: import.meta.env.VITE_BRAND_HOME || 'https://pathsim.org',
/** Simulation framework name (welcome tagline). */
framework: import.meta.env.VITE_BRAND_FRAMEWORK || 'PathSim'
};
34 changes: 34 additions & 0 deletions src/lib/constants/engine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Simulation engine selection.
*
* pathview generates Python that imports from the `pathsim` package tree. The
* engine is parameterised so a drop-in replacement with the same module layout
* (`<engine>`, `<engine>.blocks`, `<engine>.solvers`, `<engine>.events`) and
* class names can be selected at build time via the `VITE_ENGINE` env var.
*
* Defaults to `pathsim`, so an unconfigured build behaves exactly as before:
* `ENGINE_MODULE` is `pathsim` and `enginePath()` is the identity.
* (Uses the VITE_ prefix to match the repo's existing import.meta.env usage.)
*/

/** Active engine module name, fixed at build time. Defaults to pathsim. */
export const ENGINE: string = import.meta.env.VITE_ENGINE || 'pathsim';

/** Root import module for the active engine (alias of {@link ENGINE}). */
export const ENGINE_MODULE: string = ENGINE;

/**
* Map a `pathsim` package import path to the active engine's package tree.
*
* Core paths (`pathsim`, `pathsim.blocks`, `pathsim.solvers`, ...) are rewritten
* to the engine module; everything else (e.g. toolbox import paths like
* `pathsim_chem.blocks`) is left untouched. In the default pathsim build this is
* the identity function.
*/
export function enginePath(path: string): string {
if (ENGINE === 'pathsim') return path;
if (path === 'pathsim' || path.startsWith('pathsim.')) {
return ENGINE_MODULE + path.slice('pathsim'.length);
}
return path;
}
7 changes: 5 additions & 2 deletions src/lib/pyodide/backend/pyodide/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { get } from 'svelte/store';
import type { BackendState, REPLRequest, REPLResponse, REPLErrorResponse } from '../types';
import { AbstractBackend } from '../abstract';
import { enginePreInit } from './engineHooks';
import { backendState } from '../state';
import { TIMEOUTS } from '$lib/constants/python';
import { PROGRESS_MESSAGES, STATUS_MESSAGES } from '$lib/constants/messages';
Expand Down Expand Up @@ -82,8 +83,10 @@ export class PyodideBackend extends AbstractBackend {
}));
};

// Send init message
this.sendRequest({ type: 'init' });
// Engine pre-init seam (default no-op → null). An alternate engine
// can obtain an auth token here before the worker installs it.
const token = await enginePreInit();
this.sendRequest({ type: 'init', token });

// Wait for ready
await new Promise<void>((resolve, reject) => {
Expand Down
13 changes: 13 additions & 0 deletions src/lib/pyodide/backend/pyodide/engineHooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Engine hooks (main thread).
*
* `enginePreInit` runs right before the Pyodide worker is initialized. The
* default is a no-op that returns no token. A dedicated, stable seam so an
* alternate-engine build can swap *only* this module to, for example, obtain an
* auth token (and open a sign-in UI) before the engine install. The returned
* token is forwarded in the worker's `init` message and handed to the engine
* install seam ({@link ./engineInstall}).
*/
export async function enginePreInit(): Promise<string | null> {
return null;
}
52 changes: 52 additions & 0 deletions src/lib/pyodide/backend/pyodide/engineInstall.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Engine install seam (worker side).
*
* Installs the simulation engine into the Pyodide runtime. The default is the
* configured PyPI packages (pathsim). This is a dedicated, stable seam so an
* alternate-engine build can swap *only* this module (e.g. to install a wasm
* wheel, optionally gated behind `ctx.token`) without touching the worker's
* lifecycle code. `PYODIDE_PRELOAD` is loaded by the caller before this runs.
*/

import { PYTHON_PACKAGES } from '$lib/constants/dependencies';
import { PROGRESS_MESSAGES } from '$lib/constants/messages';
import type { PyodideInterface } from 'https://cdn.jsdelivr.net/pyodide/v0.29.4/full/pyodide.mjs';

export interface EngineInstallContext {
/** Emit a progress message to the UI. */
send: (msg: { type: 'progress'; value: string }) => void;
/** Auth token for a gated engine download (unused by the pathsim default). */
token?: string | null;
}

export async function installEngine(
pyodide: PyodideInterface,
ctx: EngineInstallContext
): Promise<void> {
for (const pkg of PYTHON_PACKAGES) {
const progressKey = `INSTALLING_${pkg.import.toUpperCase()}` as keyof typeof PROGRESS_MESSAGES;
ctx.send({
type: 'progress',
value: PROGRESS_MESSAGES[progressKey] ?? `Installing ${pkg.import}...`
});

try {
const preFlag = pkg.pre ? ', pre=True' : '';
await pyodide.runPythonAsync(`
import micropip
await micropip.install('${pkg.pip}'${preFlag})
`);

// Verify installation
await pyodide.runPythonAsync(`
import ${pkg.import}
print(f"${pkg.import} {${pkg.import}.__version__} loaded successfully")
`);
} catch (error) {
if (pkg.required) {
throw new Error(`Failed to install required package ${pkg.pip}: ${error}`);
}
console.warn(`Optional package ${pkg.pip} failed to install:`, error);
}
}
}
42 changes: 7 additions & 35 deletions src/lib/pyodide/backend/pyodide/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,9 @@
* Executes Python code via Pyodide in a separate thread
*/

import {
PYODIDE_CDN_URL,
PYODIDE_PRELOAD,
PYTHON_PACKAGES,
type PackageConfig
} from '$lib/constants/dependencies';
import { PYODIDE_CDN_URL, PYODIDE_PRELOAD } from '$lib/constants/dependencies';
import { PROGRESS_MESSAGES, ERROR_MESSAGES } from '$lib/constants/messages';
import { installEngine } from './engineInstall';
import type { REPLRequest, REPLResponse } from '../types';

import type { PyodideInterface } from 'https://cdn.jsdelivr.net/pyodide/v0.29.4/full/pyodide.mjs';
Expand All @@ -29,7 +25,7 @@ function send(response: REPLResponse): void {
/**
* Initialize Pyodide and install packages
*/
async function initialize(): Promise<void> {
async function initialize(token?: string | null): Promise<void> {
if (isInitialized) {
send({ type: 'ready' });
return;
Expand All @@ -56,33 +52,9 @@ async function initialize(): Promise<void> {
send({ type: 'progress', value: PROGRESS_MESSAGES.INSTALLING_DEPS });
await pyodide.loadPackage([...PYODIDE_PRELOAD]);

// Install packages from config
for (const pkg of PYTHON_PACKAGES) {
const progressKey = `INSTALLING_${pkg.import.toUpperCase()}` as keyof typeof PROGRESS_MESSAGES;
send({
type: 'progress',
value: PROGRESS_MESSAGES[progressKey] ?? `Installing ${pkg.import}...`
});

try {
const preFlag = pkg.pre ? ', pre=True' : '';
await pyodide.runPythonAsync(`
import micropip
await micropip.install('${pkg.pip}'${preFlag})
`);

// Verify installation
await pyodide.runPythonAsync(`
import ${pkg.import}
print(f"${pkg.import} {${pkg.import}.__version__} loaded successfully")
`);
} catch (error) {
if (pkg.required) {
throw new Error(`Failed to install required package ${pkg.pip}: ${error}`);
}
console.warn(`Optional package ${pkg.pip} failed to install:`, error);
}
}
// Install the simulation engine (default: configured PyPI packages). The
// engineInstall seam lets an alternate-engine build swap this step.
await installEngine(pyodide, { send, token });

// Import numpy as np and gc globally
await pyodide.runPythonAsync(`import numpy as np`);
Expand Down Expand Up @@ -236,7 +208,7 @@ self.onmessage = async (event: MessageEvent<REPLRequest>) => {
try {
switch (type) {
case 'init':
await initialize();
await initialize('token' in event.data ? event.data.token : undefined);
break;

case 'exec':
Expand Down
2 changes: 1 addition & 1 deletion src/lib/pyodide/backend/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* Request messages (main thread → backend)
*/
export type REPLRequest =
| { type: 'init' }
| { type: 'init'; token?: string | null }
| { type: 'exec'; id: string; code: string }
| { type: 'eval'; id: string; expr: string }
| { type: 'stream-start'; id: string; expr: string }
Expand Down
28 changes: 16 additions & 12 deletions src/lib/pyodide/pathsimRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { NODE_TYPES } from '$lib/constants/nodeTypes';
import { BLOCK_CATEGORY_ORDER } from '$lib/constants/python';
import { isSubsystem, isInterface } from '$lib/nodes/shapes';
import { blockImportPaths } from '$lib/nodes/generated/blocks';
import { ENGINE_MODULE, enginePath } from '$lib/constants/engine';
import { graphStore, findParentSubsystem } from '$lib/stores/graph';
import {
runStreamingSimulation,
Expand Down Expand Up @@ -165,8 +166,11 @@ function collectBlockImportGroups(nodes: NodeInstance[]): Map<string, Set<string

// Toolbox-registered blocks carry their own importPath; built-ins
// fall back to the static map. Last fallback is core pathsim.blocks.
const importPath =
typeDef.importPath ?? blockImportPaths[typeDef.blockClass] ?? 'pathsim.blocks';
// enginePath() rewrites core pathsim paths to the active engine module
// (identity in the default pathsim build).
const importPath = enginePath(
typeDef.importPath ?? blockImportPaths[typeDef.blockClass] ?? 'pathsim.blocks'
);
if (!groups.has(importPath)) groups.set(importPath, new Set());
groups.get(importPath)!.add(typeDef.blockClass);
}
Expand Down Expand Up @@ -395,9 +399,9 @@ export function generatePythonCode(
lines.push('# IMPORTS');
lines.push('import numpy as np');
if (hasSubsystems) {
lines.push('from pathsim import Simulation, Connection, Subsystem, Interface');
lines.push(`from ${ENGINE_MODULE} import Simulation, Connection, Subsystem, Interface`);
} else {
lines.push('from pathsim import Simulation, Connection');
lines.push(`from ${ENGINE_MODULE} import Simulation, Connection`);
}
for (const [importPath, classes] of importGroups) {
const sorted = [...classes].sort();
Expand All @@ -408,12 +412,12 @@ export function generatePythonCode(
}
}
// Ensure at least pathsim.blocks is imported even if no blocks
if (!importGroups.has('pathsim.blocks')) {
lines.push('from pathsim.blocks import *');
if (!importGroups.has(`${ENGINE_MODULE}.blocks`)) {
lines.push(`from ${ENGINE_MODULE}.blocks import *`);
}
lines.push(`from pathsim.solvers import ${getSettingOrDefault(settings, 'solver')}`);
lines.push(`from ${ENGINE_MODULE}.solvers import ${getSettingOrDefault(settings, 'solver')}`);
if (hasEvents) {
lines.push(`from pathsim.events import ${[...eventClasses].join(', ')}`);
lines.push(`from ${ENGINE_MODULE}.events import ${[...eventClasses].join(', ')}`);
}
lines.push('');

Expand Down Expand Up @@ -566,9 +570,9 @@ function generateFormattedPythonCode(
lines.push('import matplotlib.pyplot as plt');
lines.push('');
if (hasSubsystems) {
lines.push('from pathsim import Simulation, Connection, Subsystem, Interface');
lines.push(`from ${ENGINE_MODULE} import Simulation, Connection, Subsystem, Interface`);
} else {
lines.push('from pathsim import Simulation, Connection');
lines.push(`from ${ENGINE_MODULE} import Simulation, Connection`);
}

// Collect block classes grouped by import path
Expand All @@ -589,9 +593,9 @@ function generateFormattedPythonCode(
}
}

lines.push(`from pathsim.solvers import ${getSettingOrDefault(settings, 'solver')}`);
lines.push(`from ${ENGINE_MODULE}.solvers import ${getSettingOrDefault(settings, 'solver')}`);
if (hasEvents) {
lines.push(`from pathsim.events import ${[...eventClasses].join(', ')}`);
lines.push(`from ${ENGINE_MODULE}.events import ${[...eventClasses].join(', ')}`);
}
lines.push('');

Expand Down
Loading
Loading