diff --git a/scripts/capture-screenshots.js b/scripts/capture-screenshots.js
index 871e5de5..e8f72e64 100644
--- a/scripts/capture-screenshots.js
+++ b/scripts/capture-screenshots.js
@@ -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;
diff --git a/src/lib/components/WelcomeModal.svelte b/src/lib/components/WelcomeModal.svelte
index 689a892c..4c0eaa53 100644
--- a/src/lib/components/WelcomeModal.svelte
+++ b/src/lib/components/WelcomeModal.svelte
@@ -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 {
@@ -102,8 +103,8 @@
@@ -112,7 +113,7 @@
New
-
+
Home
diff --git a/src/lib/constants/brand.ts b/src/lib/constants/brand.ts
new file mode 100644
index 00000000..1f1c4027
--- /dev/null
+++ b/src/lib/constants/brand.ts
@@ -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 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 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'
+};
diff --git a/src/lib/constants/engine.ts b/src/lib/constants/engine.ts
new file mode 100644
index 00000000..8cbe9611
--- /dev/null
+++ b/src/lib/constants/engine.ts
@@ -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
+ * (`
`, `.blocks`, `.solvers`, `.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;
+}
diff --git a/src/lib/pyodide/backend/pyodide/backend.ts b/src/lib/pyodide/backend/pyodide/backend.ts
index 51462fdd..b06d1247 100644
--- a/src/lib/pyodide/backend/pyodide/backend.ts
+++ b/src/lib/pyodide/backend/pyodide/backend.ts
@@ -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';
@@ -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((resolve, reject) => {
diff --git a/src/lib/pyodide/backend/pyodide/engineHooks.ts b/src/lib/pyodide/backend/pyodide/engineHooks.ts
new file mode 100644
index 00000000..6278e493
--- /dev/null
+++ b/src/lib/pyodide/backend/pyodide/engineHooks.ts
@@ -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 {
+ return null;
+}
diff --git a/src/lib/pyodide/backend/pyodide/engineInstall.ts b/src/lib/pyodide/backend/pyodide/engineInstall.ts
new file mode 100644
index 00000000..cbd7f4ad
--- /dev/null
+++ b/src/lib/pyodide/backend/pyodide/engineInstall.ts
@@ -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 {
+ 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);
+ }
+ }
+}
diff --git a/src/lib/pyodide/backend/pyodide/worker.ts b/src/lib/pyodide/backend/pyodide/worker.ts
index ff36ac30..9903e5d6 100644
--- a/src/lib/pyodide/backend/pyodide/worker.ts
+++ b/src/lib/pyodide/backend/pyodide/worker.ts
@@ -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';
@@ -29,7 +25,7 @@ function send(response: REPLResponse): void {
/**
* Initialize Pyodide and install packages
*/
-async function initialize(): Promise {
+async function initialize(token?: string | null): Promise {
if (isInitialized) {
send({ type: 'ready' });
return;
@@ -56,33 +52,9 @@ async function initialize(): Promise {
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`);
@@ -236,7 +208,7 @@ self.onmessage = async (event: MessageEvent) => {
try {
switch (type) {
case 'init':
- await initialize();
+ await initialize('token' in event.data ? event.data.token : undefined);
break;
case 'exec':
diff --git a/src/lib/pyodide/backend/types.ts b/src/lib/pyodide/backend/types.ts
index a37418ad..6061a41e 100644
--- a/src/lib/pyodide/backend/types.ts
+++ b/src/lib/pyodide/backend/types.ts
@@ -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 }
diff --git a/src/lib/pyodide/pathsimRunner.ts b/src/lib/pyodide/pathsimRunner.ts
index 5bd54b0a..c13ea372 100644
--- a/src/lib/pyodide/pathsimRunner.ts
+++ b/src/lib/pyodide/pathsimRunner.ts
@@ -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,
@@ -165,8 +166,11 @@ function collectBlockImportGroups(nodes: NodeInstance[]): Map pkg
+// `pathsim-chem`, importPath `pathsim_chem`) never match these exact names.
+const ENGINE_MODULES = new Set(['pathsim', 'fastsim', ENGINE_MODULE]);
+
+function isEngineRequirement(r: ToolboxRequirement): boolean {
+ const s = (r.source ?? {}) as { pkg?: string; url?: string };
+ const candidates = [r.importPath, r.id, s.pkg, s.url].filter(Boolean).map(String);
+ return candidates.some((c) => ENGINE_MODULES.has(c) || ENGINE_MODULES.has(c.replace(/-/g, '_')));
+}
+
function walkNodeTypes(nodes: NodeInstance[], out: Set): void {
for (const node of nodes) {
if (node.type !== NODE_TYPES.SUBSYSTEM && node.type !== NODE_TYPES.INTERFACE) {
@@ -57,7 +72,8 @@ export function collectRequiredToolboxes(nodes: NodeInstance[]): ToolboxRequirem
const t = installed.find((tb) => tb.id === id);
if (t) result.push(toRequirement(t));
}
- return result;
+ // Never persist the engine itself as a requirement (see isEngineRequirement).
+ return result.filter((r) => !isEngineRequirement(r));
}
/**
@@ -74,5 +90,5 @@ export function collectRequiredToolboxes(nodes: NodeInstance[]): ToolboxRequirem
export function findMissingRequirements(reqs: ToolboxRequirement[]): ToolboxRequirement[] {
if (!reqs || reqs.length === 0) return [];
const installedKeys = new Set(get(toolboxes).map((t) => toolboxSourceKey(t.source)));
- return reqs.filter((r) => !installedKeys.has(toolboxSourceKey(r.source)));
+ return reqs.filter((r) => !isEngineRequirement(r) && !installedKeys.has(toolboxSourceKey(r.source)));
}
diff --git a/src/lib/utils/codemirror.ts b/src/lib/utils/codemirror.ts
index 47f40608..b6d1fa81 100644
--- a/src/lib/utils/codemirror.ts
+++ b/src/lib/utils/codemirror.ts
@@ -2,14 +2,17 @@
* Shared CodeMirror utilities for consistent editor setup across the app.
*/
-// Syntax highlighting colors - aligned with node color palette
+import { BRAND } from '$lib/constants/brand';
+
+// Syntax highlighting colors - aligned with node color palette. keyword and the
+// operator/function accent follow the active brand (defaults: red + PathSim blue).
export const SYNTAX_COLORS = {
- keyword: '#E57373', // Red - control flow, imports
- operator: '#0070C0', // PathSim blue - symbols, operators
+ keyword: BRAND.keywordColor, // control flow, imports
+ operator: BRAND.accent, // brand accent - symbols, operators
special: '#FFB74D', // Orange - classes, types, decorators
number: '#4DB6AC', // Teal - numeric literals
string: '#81C784', // Green - string literals
- function: '#0070C0', // PathSim blue - function names
+ function: BRAND.accent, // brand accent - function names
comment: { dark: '#505060', light: '#909098' }, // Same as line numbers
invalid: '#BA68C8', // Purple - errors
// Theme-specific values for variables and punctuation
diff --git a/src/lib/utils/colors.ts b/src/lib/utils/colors.ts
index 25ed5fe2..dd266f40 100644
--- a/src/lib/utils/colors.ts
+++ b/src/lib/utils/colors.ts
@@ -2,8 +2,11 @@
* Color definitions for PathView
*/
-// Default node/event color (matches --pathsim-blue CSS variable)
-export const DEFAULT_NODE_COLOR = '#0070C0';
+import { BRAND } from '$lib/constants/brand';
+
+// Default node/event/annotation color: the brand accent (matches the CSS
+// `--accent` default). Items without an explicit color follow the active brand.
+export const DEFAULT_NODE_COLOR = BRAND.accent;
// Port colors
export const PORT_COLORS = {
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 56a29925..cfb38377 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -1,6 +1,7 @@