Skip to content

Commit c41a80b

Browse files
authored
Merge pull request #305 from pathsim/feature/engine-brand-modularization
Engine + brand extension points (default pathsim/PathView unchanged)
2 parents bf127c9 + 700ce72 commit c41a80b

16 files changed

Lines changed: 231 additions & 73 deletions

File tree

scripts/capture-screenshots.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,23 @@
88

99
import puppeteer from 'puppeteer-core';
1010
import { readFileSync, existsSync, mkdirSync } from 'fs';
11-
import { dirname, join } from 'path';
11+
import { dirname, join, resolve } from 'path';
1212
import { fileURLToPath } from 'url';
1313

1414
const __dirname = dirname(fileURLToPath(import.meta.url));
1515
const STATIC_DIR = join(__dirname, '..', 'static', 'examples');
16-
const SCREENSHOTS_DIR = join(STATIC_DIR, 'screenshots');
1716
const MANIFEST_PATH = join(STATIC_DIR, 'manifest.json');
1817

19-
const BASE_URL = 'https://view.pathsim.org';
18+
// Where to write the PNGs. Override (SCREENSHOT_OUT_DIR) so a fastsim build can
19+
// capture straight into its build output instead of the default static dir.
20+
const SCREENSHOTS_DIR = process.env.SCREENSHOT_OUT_DIR
21+
? resolve(process.env.SCREENSHOT_OUT_DIR)
22+
: join(STATIC_DIR, 'screenshots');
23+
24+
// Origin (+ base path) to screenshot. Defaults to the public blue pathview.
25+
// The fastsim build points this at a local `vite preview` of the red /app
26+
// build so the tiles match its styling (SCREENSHOT_BASE_URL=http://localhost:PORT/app).
27+
const BASE_URL = process.env.SCREENSHOT_BASE_URL || 'https://view.pathsim.org';
2028
const VIEWPORT = { width: 1000, height: 600 };
2129
const DEVICE_SCALE_FACTOR = 1;
2230
const SETTLE_DELAY = 5000;

src/lib/components/WelcomeModal.svelte

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { cubicOut } from 'svelte/easing';
66
import Icon from '$lib/components/icons/Icon.svelte';
77
import { PATHVIEW_VERSION, EXTRACTED_VERSIONS } from '$lib/constants/dependencies';
8+
import { BRAND } from '$lib/constants/brand';
89
import { startGuidedTour, type TourId } from '$lib/tours';
910
1011
interface Example {
@@ -102,8 +103,8 @@
102103
</div>
103104

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

109110
<div class="actions">
@@ -112,7 +113,7 @@
112113
<span class="action-label">New</span>
113114
</button>
114115

115-
<a href="https://pathsim.org" target="_blank" class="action-card">
116+
<a href={BRAND.home} target="_blank" class="action-card">
116117
<Icon name="home" size={20} />
117118
<span class="action-label">Home</span>
118119
</a>

src/lib/constants/brand.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Visible product branding, configurable at build time.
3+
*
4+
* Defaults to PathView (PathSim blue). A re-branded distribution overrides any
5+
* of these via `VITE_BRAND_*` env vars at build time, without touching the
6+
* components that read them. `key` is set as `data-brand` on <html> so CSS can
7+
* key an accent override off it; the JS accent (`accent` / `keywordColor`) feeds
8+
* the canvas default color and the CodeMirror palette.
9+
*/
10+
export const BRAND = {
11+
/** Short key, set as `data-brand` on <html> for CSS overrides. */
12+
key: import.meta.env.VITE_BRAND_KEY || 'pathsim',
13+
/** Display name (window title, logo alt, autosave prompt, welcome header). */
14+
name: import.meta.env.VITE_BRAND_NAME || 'PathView',
15+
/** Logo asset filename under static/. */
16+
logo: import.meta.env.VITE_BRAND_LOGO || 'pathview_logo.png',
17+
/** Primary accent (matches the CSS `--accent` default). */
18+
accent: import.meta.env.VITE_BRAND_ACCENT || '#0070C0',
19+
/** CodeMirror keyword color (control flow / imports). */
20+
keywordColor: import.meta.env.VITE_BRAND_KEYWORD || '#E57373',
21+
/** Home link target (welcome modal). */
22+
home: import.meta.env.VITE_BRAND_HOME || 'https://pathsim.org',
23+
/** Simulation framework name (welcome tagline). */
24+
framework: import.meta.env.VITE_BRAND_FRAMEWORK || 'PathSim'
25+
};

src/lib/constants/engine.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Simulation engine selection.
3+
*
4+
* pathview generates Python that imports from the `pathsim` package tree. The
5+
* engine is parameterised so a drop-in replacement with the same module layout
6+
* (`<engine>`, `<engine>.blocks`, `<engine>.solvers`, `<engine>.events`) and
7+
* class names can be selected at build time via the `VITE_ENGINE` env var.
8+
*
9+
* Defaults to `pathsim`, so an unconfigured build behaves exactly as before:
10+
* `ENGINE_MODULE` is `pathsim` and `enginePath()` is the identity.
11+
* (Uses the VITE_ prefix to match the repo's existing import.meta.env usage.)
12+
*/
13+
14+
/** Active engine module name, fixed at build time. Defaults to pathsim. */
15+
export const ENGINE: string = import.meta.env.VITE_ENGINE || 'pathsim';
16+
17+
/** Root import module for the active engine (alias of {@link ENGINE}). */
18+
export const ENGINE_MODULE: string = ENGINE;
19+
20+
/**
21+
* Map a `pathsim` package import path to the active engine's package tree.
22+
*
23+
* Core paths (`pathsim`, `pathsim.blocks`, `pathsim.solvers`, ...) are rewritten
24+
* to the engine module; everything else (e.g. toolbox import paths like
25+
* `pathsim_chem.blocks`) is left untouched. In the default pathsim build this is
26+
* the identity function.
27+
*/
28+
export function enginePath(path: string): string {
29+
if (ENGINE === 'pathsim') return path;
30+
if (path === 'pathsim' || path.startsWith('pathsim.')) {
31+
return ENGINE_MODULE + path.slice('pathsim'.length);
32+
}
33+
return path;
34+
}

src/lib/pyodide/backend/pyodide/backend.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import { get } from 'svelte/store';
77
import type { BackendState, REPLRequest, REPLResponse, REPLErrorResponse } from '../types';
88
import { AbstractBackend } from '../abstract';
9+
import { enginePreInit } from './engineHooks';
910
import { backendState } from '../state';
1011
import { TIMEOUTS } from '$lib/constants/python';
1112
import { PROGRESS_MESSAGES, STATUS_MESSAGES } from '$lib/constants/messages';
@@ -82,8 +83,10 @@ export class PyodideBackend extends AbstractBackend {
8283
}));
8384
};
8485

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

8891
// Wait for ready
8992
await new Promise<void>((resolve, reject) => {
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* Engine hooks (main thread).
3+
*
4+
* `enginePreInit` runs right before the Pyodide worker is initialized. The
5+
* default is a no-op that returns no token. A dedicated, stable seam so an
6+
* alternate-engine build can swap *only* this module to, for example, obtain an
7+
* auth token (and open a sign-in UI) before the engine install. The returned
8+
* token is forwarded in the worker's `init` message and handed to the engine
9+
* install seam ({@link ./engineInstall}).
10+
*/
11+
export async function enginePreInit(): Promise<string | null> {
12+
return null;
13+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Engine install seam (worker side).
3+
*
4+
* Installs the simulation engine into the Pyodide runtime. The default is the
5+
* configured PyPI packages (pathsim). This is a dedicated, stable seam so an
6+
* alternate-engine build can swap *only* this module (e.g. to install a wasm
7+
* wheel, optionally gated behind `ctx.token`) without touching the worker's
8+
* lifecycle code. `PYODIDE_PRELOAD` is loaded by the caller before this runs.
9+
*/
10+
11+
import { PYTHON_PACKAGES } from '$lib/constants/dependencies';
12+
import { PROGRESS_MESSAGES } from '$lib/constants/messages';
13+
import type { PyodideInterface } from 'https://cdn.jsdelivr.net/pyodide/v0.29.4/full/pyodide.mjs';
14+
15+
export interface EngineInstallContext {
16+
/** Emit a progress message to the UI. */
17+
send: (msg: { type: 'progress'; value: string }) => void;
18+
/** Auth token for a gated engine download (unused by the pathsim default). */
19+
token?: string | null;
20+
}
21+
22+
export async function installEngine(
23+
pyodide: PyodideInterface,
24+
ctx: EngineInstallContext
25+
): Promise<void> {
26+
for (const pkg of PYTHON_PACKAGES) {
27+
const progressKey = `INSTALLING_${pkg.import.toUpperCase()}` as keyof typeof PROGRESS_MESSAGES;
28+
ctx.send({
29+
type: 'progress',
30+
value: PROGRESS_MESSAGES[progressKey] ?? `Installing ${pkg.import}...`
31+
});
32+
33+
try {
34+
const preFlag = pkg.pre ? ', pre=True' : '';
35+
await pyodide.runPythonAsync(`
36+
import micropip
37+
await micropip.install('${pkg.pip}'${preFlag})
38+
`);
39+
40+
// Verify installation
41+
await pyodide.runPythonAsync(`
42+
import ${pkg.import}
43+
print(f"${pkg.import} {${pkg.import}.__version__} loaded successfully")
44+
`);
45+
} catch (error) {
46+
if (pkg.required) {
47+
throw new Error(`Failed to install required package ${pkg.pip}: ${error}`);
48+
}
49+
console.warn(`Optional package ${pkg.pip} failed to install:`, error);
50+
}
51+
}
52+
}

src/lib/pyodide/backend/pyodide/worker.ts

Lines changed: 7 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,9 @@
33
* Executes Python code via Pyodide in a separate thread
44
*/
55

6-
import {
7-
PYODIDE_CDN_URL,
8-
PYODIDE_PRELOAD,
9-
PYTHON_PACKAGES,
10-
type PackageConfig
11-
} from '$lib/constants/dependencies';
6+
import { PYODIDE_CDN_URL, PYODIDE_PRELOAD } from '$lib/constants/dependencies';
127
import { PROGRESS_MESSAGES, ERROR_MESSAGES } from '$lib/constants/messages';
8+
import { installEngine } from './engineInstall';
139
import type { REPLRequest, REPLResponse } from '../types';
1410

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

59-
// Install packages from config
60-
for (const pkg of PYTHON_PACKAGES) {
61-
const progressKey = `INSTALLING_${pkg.import.toUpperCase()}` as keyof typeof PROGRESS_MESSAGES;
62-
send({
63-
type: 'progress',
64-
value: PROGRESS_MESSAGES[progressKey] ?? `Installing ${pkg.import}...`
65-
});
66-
67-
try {
68-
const preFlag = pkg.pre ? ', pre=True' : '';
69-
await pyodide.runPythonAsync(`
70-
import micropip
71-
await micropip.install('${pkg.pip}'${preFlag})
72-
`);
73-
74-
// Verify installation
75-
await pyodide.runPythonAsync(`
76-
import ${pkg.import}
77-
print(f"${pkg.import} {${pkg.import}.__version__} loaded successfully")
78-
`);
79-
} catch (error) {
80-
if (pkg.required) {
81-
throw new Error(`Failed to install required package ${pkg.pip}: ${error}`);
82-
}
83-
console.warn(`Optional package ${pkg.pip} failed to install:`, error);
84-
}
85-
}
55+
// Install the simulation engine (default: configured PyPI packages). The
56+
// engineInstall seam lets an alternate-engine build swap this step.
57+
await installEngine(pyodide, { send, token });
8658

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

242214
case 'exec':

src/lib/pyodide/backend/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
* Request messages (main thread → backend)
1212
*/
1313
export type REPLRequest =
14-
| { type: 'init' }
14+
| { type: 'init'; token?: string | null }
1515
| { type: 'exec'; id: string; code: string }
1616
| { type: 'eval'; id: string; expr: string }
1717
| { type: 'stream-start'; id: string; expr: string }

src/lib/pyodide/pathsimRunner.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { NODE_TYPES } from '$lib/constants/nodeTypes';
1212
import { BLOCK_CATEGORY_ORDER } from '$lib/constants/python';
1313
import { isSubsystem, isInterface } from '$lib/nodes/shapes';
1414
import { blockImportPaths } from '$lib/nodes/generated/blocks';
15+
import { ENGINE_MODULE, enginePath } from '$lib/constants/engine';
1516
import { graphStore, findParentSubsystem } from '$lib/stores/graph';
1617
import {
1718
runStreamingSimulation,
@@ -165,8 +166,11 @@ function collectBlockImportGroups(nodes: NodeInstance[]): Map<string, Set<string
165166

166167
// Toolbox-registered blocks carry their own importPath; built-ins
167168
// fall back to the static map. Last fallback is core pathsim.blocks.
168-
const importPath =
169-
typeDef.importPath ?? blockImportPaths[typeDef.blockClass] ?? 'pathsim.blocks';
169+
// enginePath() rewrites core pathsim paths to the active engine module
170+
// (identity in the default pathsim build).
171+
const importPath = enginePath(
172+
typeDef.importPath ?? blockImportPaths[typeDef.blockClass] ?? 'pathsim.blocks'
173+
);
170174
if (!groups.has(importPath)) groups.set(importPath, new Set());
171175
groups.get(importPath)!.add(typeDef.blockClass);
172176
}
@@ -395,9 +399,9 @@ export function generatePythonCode(
395399
lines.push('# IMPORTS');
396400
lines.push('import numpy as np');
397401
if (hasSubsystems) {
398-
lines.push('from pathsim import Simulation, Connection, Subsystem, Interface');
402+
lines.push(`from ${ENGINE_MODULE} import Simulation, Connection, Subsystem, Interface`);
399403
} else {
400-
lines.push('from pathsim import Simulation, Connection');
404+
lines.push(`from ${ENGINE_MODULE} import Simulation, Connection`);
401405
}
402406
for (const [importPath, classes] of importGroups) {
403407
const sorted = [...classes].sort();
@@ -408,12 +412,12 @@ export function generatePythonCode(
408412
}
409413
}
410414
// Ensure at least pathsim.blocks is imported even if no blocks
411-
if (!importGroups.has('pathsim.blocks')) {
412-
lines.push('from pathsim.blocks import *');
415+
if (!importGroups.has(`${ENGINE_MODULE}.blocks`)) {
416+
lines.push(`from ${ENGINE_MODULE}.blocks import *`);
413417
}
414-
lines.push(`from pathsim.solvers import ${getSettingOrDefault(settings, 'solver')}`);
418+
lines.push(`from ${ENGINE_MODULE}.solvers import ${getSettingOrDefault(settings, 'solver')}`);
415419
if (hasEvents) {
416-
lines.push(`from pathsim.events import ${[...eventClasses].join(', ')}`);
420+
lines.push(`from ${ENGINE_MODULE}.events import ${[...eventClasses].join(', ')}`);
417421
}
418422
lines.push('');
419423

@@ -566,9 +570,9 @@ function generateFormattedPythonCode(
566570
lines.push('import matplotlib.pyplot as plt');
567571
lines.push('');
568572
if (hasSubsystems) {
569-
lines.push('from pathsim import Simulation, Connection, Subsystem, Interface');
573+
lines.push(`from ${ENGINE_MODULE} import Simulation, Connection, Subsystem, Interface`);
570574
} else {
571-
lines.push('from pathsim import Simulation, Connection');
575+
lines.push(`from ${ENGINE_MODULE} import Simulation, Connection`);
572576
}
573577

574578
// Collect block classes grouped by import path
@@ -589,9 +593,9 @@ function generateFormattedPythonCode(
589593
}
590594
}
591595

592-
lines.push(`from pathsim.solvers import ${getSettingOrDefault(settings, 'solver')}`);
596+
lines.push(`from ${ENGINE_MODULE}.solvers import ${getSettingOrDefault(settings, 'solver')}`);
593597
if (hasEvents) {
594-
lines.push(`from pathsim.events import ${[...eventClasses].join(', ')}`);
598+
lines.push(`from ${ENGINE_MODULE}.events import ${[...eventClasses].join(', ')}`);
595599
}
596600
lines.push('');
597601

0 commit comments

Comments
 (0)