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
1 change: 1 addition & 0 deletions src/lib/components/ConfirmationModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
font-size: var(--font-sm);
color: var(--text-muted);
line-height: 1.5;
white-space: pre-wrap;
}
.dialog-actions {
Expand Down
17 changes: 16 additions & 1 deletion src/lib/components/dialogs/ToolboxManagerDialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
// Build artifact across the add-toolbox flow
let resolvedSource = $state<ToolboxSource | null>(null);
let resolvedImportPath = $state('');
let resolvedInstalledVersion = $state<string | null>(null);
let resolvedDisplayName = $state('');
let resolvedEventsImportPath = $state<string | undefined>(undefined);
let toolboxId = $state('');
Expand Down Expand Up @@ -97,6 +98,7 @@
eventsImportPathInput = '';
resolvedSource = null;
resolvedImportPath = '';
resolvedInstalledVersion = null;
resolvedDisplayName = '';
resolvedEventsImportPath = undefined;
toolboxId = '';
Expand Down Expand Up @@ -200,6 +202,7 @@
installMessage = describeInstall(resolvedSource);
const result = await performInstall(resolvedSource, resolvedImportPath || undefined);
resolvedImportPath = result.importPath;
resolvedInstalledVersion = result.installedVersion;

installStatus = 'discovering';
installMessage = `Inspecting ${resolvedImportPath}…`;
Expand Down Expand Up @@ -301,6 +304,7 @@
source: resolvedSource,
importPath: resolvedImportPath,
eventsImportPath: resolvedEventsImportPath,
installedVersion: resolvedInstalledVersion,
blocks: blockSelections,
events: eventSelections
};
Expand Down Expand Up @@ -387,7 +391,10 @@
{#each installed as t (t.id)}
<div class="installed-row">
<div class="installed-meta">
<div class="installed-name">{t.displayName}</div>
<div class="installed-name">
{t.displayName}
{#if t.installedVersion}<span class="installed-version">v{t.installedVersion}</span>{/if}
</div>
<div class="installed-source">
{#if t.source.type === 'pypi'}
pip · {t.source.pkg}{t.source.version ? `==${t.source.version}` : ''}
Expand Down Expand Up @@ -782,6 +789,14 @@
color: var(--text-muted);
}

.installed-version {
margin-left: 6px;
font-family: var(--font-mono);
font-size: var(--font-sm);
font-weight: 400;
color: var(--text-disabled);
}

.installed-source {
font-size: var(--font-sm);
color: var(--text-muted);
Expand Down
49 changes: 38 additions & 11 deletions src/lib/components/nodes/BaseNode.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { Handle, Position, useUpdateNodeInternals } from '@xyflow/svelte';
import { nodeRegistry, type NodeInstance } from '$lib/nodes';
import { nodeRegistry, registryVersion, type NodeInstance } from '$lib/nodes';
import { getShapeCssClass, isSubsystem } from '$lib/nodes/shapes/index';
import { NODE_TYPES } from '$lib/constants/nodeTypes';
import { openNodeDialog } from '$lib/stores/nodeDialog';
Expand Down Expand Up @@ -33,8 +33,16 @@
// Get SvelteFlow hook to trigger re-measurement when node size changes
const updateNodeInternals = useUpdateNodeInternals();

// Get type definition
const typeDef = $derived(nodeRegistry.get(data.type));
// Get type definition. The registry isn't a reactive store on its own,
// so we tick on registryVersion bumps (toolbox install/uninstall) and
// re-read here. Without this, blocks loaded before their toolbox finished
// bootstrapping would stay stuck rendering as (missing).
let registryTick = $state(0);
const unsubRegistry = registryVersion.subscribe((v) => (registryTick = v));
const typeDef = $derived.by(() => {
registryTick; // dependency: re-read whenever the registry version bumps
return nodeRegistry.get(data.type);
});
const category = $derived(typeDef?.category || 'Algebraic');

// Get valid pinned params (filter out any that no longer exist in the type definition)
Expand Down Expand Up @@ -109,6 +117,7 @@
});

onDestroy(() => {
unsubRegistry();
unsubscribePinned();
unsubscribePlotData();
unsubscribePortLabels();
Expand Down Expand Up @@ -333,7 +342,13 @@
const shapeClass = $derived(() => typeDef ? getShapeCssClass(typeDef) : 'shape-default');

// Custom node color (defaults to pathsim-blue)
const nodeColor = $derived(data.color || 'var(--accent)');
// Missing blocks (type not registered) override any custom color so the
// whole block — name, handles, hover state — picks up the error red.
const nodeColor = $derived(
!typeDef && data.type !== NODE_TYPES.SUBSYSTEM && data.type !== NODE_TYPES.INTERFACE
? 'var(--error)'
: data.color || 'var(--accent)'
);

// Handle pinned param change
function handlePinnedParamChange(paramName: string, value: string) {
Expand Down Expand Up @@ -697,6 +712,9 @@
font-size: 8px;
color: var(--text-muted);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.node-content.has-icon {
Expand Down Expand Up @@ -727,19 +745,28 @@
}

.node-type.missing {
color: var(--warning);
color: var(--error);
font-weight: 500;
}

/* Visual marker for nodes whose block type isn't registered (e.g. file
loaded with a toolbox dependency the user hasn't installed). */
* loaded with a toolbox dependency the user hasn't installed). Same
* shape as a normal block, just dressed in error red so it's obvious
* something is wrong. */
.node.missing-type {
--node-color: var(--warning);
opacity: 0.85;
--node-color: var(--error);
border-color: var(--error);
background: var(--error-bg);
}

.node.missing-type .node-content,
.node.missing-type :global(.node-shape) {
border-style: dashed;
/* Port handles: paint the outer pentagon red so the missing block
* carries its error state out to its connections. The inner cutout
* picks up the red-tinted body so it visually merges with the block. */
:global(.node.missing-type .svelte-flow__handle::before) {
background: var(--error);
}
:global(.node.missing-type .svelte-flow__handle::after) {
background: color-mix(in srgb, var(--error-bg) 60%, var(--surface-raised));
}

/* Pinned parameters - rectangular, clipped by node-clip's overflow:hidden */
Expand Down
6 changes: 6 additions & 0 deletions src/lib/nodes/defineNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ interface DefineNodeOptions {
category: NodeCategory;
description?: string;
blockClass: string; // PathSim class name
/** Python module to import `blockClass` from. Optional — built-ins
* resolve via the static map in `blocks.ts`. Toolbox-registered
* blocks pass their toolbox `importPath` here. */
importPath?: string;

// Port configuration
inputs?: string[]; // Named input ports
Expand Down Expand Up @@ -47,6 +51,7 @@ export function defineNode(options: DefineNodeOptions): NodeTypeDefinition {
category,
description = `${name} block`,
blockClass,
importPath,
inputs = ['in 0'],
outputs = ['out 0'],
minInputs = 1,
Expand Down Expand Up @@ -75,6 +80,7 @@ export function defineNode(options: DefineNodeOptions): NodeTypeDefinition {
category,
description,
blockClass,
importPath,
shape,

ports: {
Expand Down
5 changes: 4 additions & 1 deletion src/lib/pyodide/pathsimRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,10 @@ function collectBlockImportGroups(nodes: NodeInstance[]): Map<string, Set<string
const typeDef = nodeRegistry.get(node.type);
if (!typeDef) continue;

const importPath = blockImportPaths[typeDef.blockClass] || 'pathsim.blocks';
// 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';
if (!groups.has(importPath)) groups.set(importPath, new Set());
groups.get(importPath)!.add(typeDef.blockClass);
}
Expand Down
14 changes: 12 additions & 2 deletions src/lib/schema/fileOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
upsertToolbox,
getCatalogEntry
} from '$lib/toolbox';
import { getCachedPathsimVersion } from '$lib/toolbox/pathsimVersion';
import type { ToolboxRequirement } from '$lib/types/schema';
import { requestAssemblyAnimation } from '$lib/animation/assemblyAnimation';
import { downloadJson } from '$lib/utils/download';
Expand Down Expand Up @@ -97,13 +98,15 @@ export function createGraphFile(name?: string): GraphFile {
) as SimulationSettings;

const requiredToolboxes = collectRequiredToolboxes(nodes);
const pathsimVersion = getCachedPathsimVersion();

return {
version: GRAPH_FILE_VERSION,
metadata: {
created: new Date().toISOString(),
modified: new Date().toISOString(),
name: name || 'Untitled'
name: name || 'Untitled',
...(pathsimVersion ? { pathsimVersion } : {})
},
graph: {
nodes: cleanedNodes,
Expand Down Expand Up @@ -172,7 +175,13 @@ async function installRequiredToolboxes(reqs: ToolboxRequirement[]): Promise<voi
const missing = findMissingRequirements(reqs);
if (missing.length === 0) return;

const list = missing.map((r) => `· ${r.displayName}`).join('\n');
// List missing toolboxes with the version recorded in the file as info —
// purely transparency so the user knows which version the model was
// authored against. Install resolves to whatever's latest; if a user
// needs to pin, they can do it manually in the toolbox manager.
const list = missing
.map((r) => `· ${r.displayName}${r.installedVersion ? ` (saved with v${r.installedVersion})` : ''}`)
.join('\n');
const ok = await confirmationStore.show({
title: 'Install required toolboxes?',
message: `This file uses ${missing.length} toolbox${missing.length === 1 ? '' : 'es'} that ${missing.length === 1 ? 'is' : 'are'} not installed:\n\n${list}\n\nInstall now?`,
Expand Down Expand Up @@ -204,6 +213,7 @@ async function installRequiredToolboxes(reqs: ToolboxRequirement[]): Promise<voi
source: req.source,
importPath: updated.importPath,
eventsImportPath: updated.eventsImportPath,
installedVersion: installResult.installedVersion,
blocks: discovered.blocks.map((b) => ({ className: b.className, enabled: true })),
events: discovered.events.map((e) => ({ className: e.className, enabled: true }))
};
Expand Down
6 changes: 6 additions & 0 deletions src/lib/toolbox/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { get } from 'svelte/store';
import { toolboxes, upsertToolbox, seedPreloadedToolboxes } from './store';
import { performInstall, discoverToolbox, registerToolbox } from './register';
import { getCatalogEntry } from './catalog';
import { primePathsimVersion } from './pathsimVersion';
import type { ToolboxConfig } from './types';

let bootstrapped = false;
Expand All @@ -23,6 +24,10 @@ export async function bootstrapToolboxes(): Promise<void> {
if (bootstrapped) return;
bootstrapped = true;

// Cache pathsim's version once so createGraphFile (which is sync) can
// stamp it into saved files without needing an async hop.
await primePathsimVersion();

seedPreloadedToolboxes();

const list = get(toolboxes);
Expand All @@ -43,6 +48,7 @@ export async function bootstrapToolboxes(): Promise<void> {
const reconciled: ToolboxConfig = {
...config,
importPath: installResult.importPath,
installedVersion: installResult.installedVersion,
blocks: discovered.blocks.map(
(b) =>
config.blocks.find((s) => s.className === b.className) ?? {
Expand Down
3 changes: 2 additions & 1 deletion src/lib/toolbox/dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ function toRequirement(t: ToolboxConfig): ToolboxRequirement {
displayName: t.displayName,
source: t.source,
importPath: t.importPath,
eventsImportPath: t.eventsImportPath
eventsImportPath: t.eventsImportPath,
installedVersion: t.installedVersion ?? null
};
}

Expand Down
26 changes: 19 additions & 7 deletions src/lib/toolbox/installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,23 +36,21 @@ export interface IntrospectedEvent {
params: IntrospectedParam[];
}

let helpersLoaded = false;

/**
* Make sure Pyodide is initialized (so `json` and `_to_json` are available
* for `evaluate(...)`) and that our toolbox helpers are defined.
*
* No JS-side cache: pathview wipes Python globals on simulation reset, so
* a cached "loaded" flag goes stale and the next call hits a NameError.
* The sentinel check is a single evaluate — cheap enough to run every time.
*/
async function ensureHelpers(): Promise<void> {
if (helpersLoaded) return;
// initPyodide is idempotent and also runs REPL_SETUP_CODE which imports
// `json` and defines `_to_json` — both needed by evaluate().
await initPyodide();
const present = await evaluate<boolean>(TOOLBOX_HELPERS_SENTINEL);
if (!present) {
await exec(TOOLBOX_PYTHON_HELPERS);
await ensureDocutils();
}
await ensureDocutils();
helpersLoaded = true;
}

/**
Expand Down Expand Up @@ -146,6 +144,20 @@ export async function introspectEvents(importPath: string): Promise<Introspected
return result.events;
}

/**
* Best-effort version lookup for an installed module. Reads
* `module.__version__` first, falls back to `importlib.metadata`. Returns
* null when neither is available (typical for inline modules).
*/
export async function getModuleVersion(importPath: string): Promise<string | null> {
await ensureHelpers();
try {
return await evaluate<string | null>(`_pv_module_version(${pyStr(importPath)})`);
} catch {
return null;
}
}

/**
* Drop a module from sys.modules. micropip has no real uninstall, so the
* package files stay cached in the Pyodide FS until reload, but importing
Expand Down
28 changes: 28 additions & 0 deletions src/lib/toolbox/pathsimVersion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Cached pathsim version. Read once via `getModuleVersion('pathsim')` after
* bootstrap, then exposed synchronously so `createGraphFile` (which can't
* be async because it's called from autoSave) can stamp the version into
* saved files.
*/

import { getModuleVersion } from './installer';

let cached: string | null = null;
let primed = false;

/** Read pathsim's version from Python and cache it. Call once after the
* Python runtime is up (bootstrap or first save). Idempotent. */
export async function primePathsimVersion(): Promise<void> {
if (primed) return;
try {
cached = await getModuleVersion('pathsim');
} catch {
cached = null;
}
primed = true;
}

/** Synchronous accessor. Returns null until `primePathsimVersion` has run. */
export function getCachedPathsimVersion(): string | null {
return cached;
}
Loading
Loading