feat(custom-chip): program lives in its own editor group, not the board sketch

A programmable custom-chip (a CPU emulator that runs a ROM/program, e.g. the
Z80 or 8080) now keeps its program (larson.s, chaser.c, ...) in a dedicated
editor file group — group-chip-<chipId> — rendered as its own collapsible
section in the file explorer, exactly like each board owns its sketch group.
Behaviour/driver chips and predefined chips carry no programFile and get no
group; they stay editable only in the chip designer.

Fixes two reported issues on the Z80 examples:
- /example/z80-larson-no-board: the board-less chip example now opens its
  program (larson.s) as the active group, editable on the left — previously
  the editor showed but no file appeared.
- /example/z80-led-chaser-c: the chip program (chaser.c) no longer shows as
  a sibling tab inside the Arduino sketch group; it sits in its own chip
  section instead. The board group shows only sketch.ino.

Details:
- useEditorStore: chipFileGroupId()/CHIP_GROUP_PREFIX helpers.
- loadExample: seedChipProgramGroups() routes each chip's programFile into its
  own group (seeded from the example files), sweeps stale chip groups, keeps
  the program OUT of the board group, and for a board-less chip example makes
  the chip group active so the program is the editable file shown.
- EditorToolbar.prepareCustomChips: resolves the program from the chip's own
  group (falls back to board files for older projects) before assembling ROM.
- FileExplorer: renders one collapsible section per programmable chip with an
  IC icon; clicking switches the editor to the chip group. Lazy-creates a
  group for chips dropped on the canvas.
- projectPayload + vlxFile: serialise chip groups alongside board groups and
  include them in the dirty-check hash, so chip-program edits persist on
  save / autosave / .vlx export and round-trip via replaceFileGroups on load.
- Regression tests for board-less + board+chip routing and stale-group sweep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
David Montero 2026-06-03 22:07:56 +02:00
parent fe001d728c
commit 5a23e89eb5
8 changed files with 343 additions and 45 deletions

View File

@ -83,3 +83,59 @@ describe('loadExample — board-less → board-based transition', () => {
expect(code, 'editor must contain the Uno example body').toContain(uno.code.slice(0, 40));
});
});
describe('loadExample — programmable-chip program lives in its own group', () => {
beforeEach(() => {
resetStores();
});
it('board-less chip example opens the chip program (larson.s) as the active group, editable', async () => {
await loadExample(findExample('z80-larson-no-board'));
const ed = useEditorStore.getState();
expect(useSimulatorStore.getState().boards.length).toBe(0);
// The chip owns a group-chip-<id> group with its program file.
const chipGroupId = 'group-chip-z80cpu';
expect(ed.fileGroups[chipGroupId], 'chip group exists').toBeDefined();
expect(ed.fileGroups[chipGroupId].map((f) => f.name)).toContain('larson.s');
// That group is the active one (program shows on the left, editable).
expect(ed.activeGroupId).toBe(chipGroupId);
const larson = ed.fileGroups[chipGroupId].find((f) => f.name === 'larson.s');
expect(larson?.content.length ?? 0, 'larson.s is non-empty').toBeGreaterThan(0);
expect(activeSketchContent(), 'editor shows the larson.s program').toBe(larson?.content);
});
it('board + chip example keeps the chip program OUT of the board sketch group', async () => {
await loadExample(findExample('z80-led-chaser-c'));
const ed = useEditorStore.getState();
const sim = useSimulatorStore.getState();
// Board group shows only the sketch — chaser.c is NOT a sibling tab.
const board = sim.boards.find((b) => b.id === sim.activeBoardId) ?? sim.boards[0];
const boardFiles = (ed.fileGroups[board.activeFileGroupId] ?? []).map((f) => f.name);
expect(boardFiles).toContain('sketch.ino');
expect(boardFiles, 'chaser.c must not pollute the board group').not.toContain('chaser.c');
// The chip program lives in its own group instead.
const chipGroupId = 'group-chip-z80cpu';
expect(ed.fileGroups[chipGroupId]?.map((f) => f.name)).toContain('chaser.c');
// With a board present the board sketch stays the active group.
expect(ed.activeGroupId).toBe(board.activeFileGroupId);
});
it('chip groups from a previous example do not leak into the next', async () => {
await loadExample(findExample('z80-led-chaser-c'));
expect(useEditorStore.getState().fileGroups['group-chip-z80cpu']).toBeDefined();
// A plain board example with no custom chip must clear the stale chip group.
await loadExample(findExample('blink-led'));
expect(
useEditorStore.getState().fileGroups['group-chip-z80cpu'],
'stale chip group swept on next load',
).toBeUndefined();
});
});

View File

@ -1,6 +1,6 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useEditorStore } from '../../store/useEditorStore';
import { useEditorStore, chipFileGroupId } from '../../store/useEditorStore';
import { useSimulatorStore } from '../../store/useSimulatorStore';
import { useElectricalStore } from '../../store/useElectricalStore';
import { verifyCircuit, type VerificationResult } from '../../simulation/verify/circuitVerifier';
@ -259,12 +259,21 @@ export const EditorToolbar = ({
// when there's no ROM yet or the user edited code since last build.
const programFile = String(props.programFile ?? '').trim();
if (programFile && (!String(props.romBytes ?? '') || codeChanged)) {
const file = boardFiles.find((f) => f.name === programFile);
// The program lives in the chip's OWN editor group (its collapsible
// section in the file explorer), separate from the board sketch.
// Fall back to the board files for older projects that still carried
// the program alongside sketch.ino in the board group.
const chipGroupFiles = useEditorStore
.getState()
.getGroupFiles(chipFileGroupId(chip.id));
const file =
chipGroupFiles.find((f) => f.name === programFile) ??
boardFiles.find((f) => f.name === programFile);
if (!file) {
addLog({
timestamp: new Date(),
type: 'error',
message: `Chip "${chipLabel}": program file "${programFile}" not found in this board's files.`,
message: `Chip "${chipLabel}": program file "${programFile}" not found in the chip's files.`,
});
} else {
const target = targetForChip(chipJson);

View File

@ -1,6 +1,6 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useEditorStore } from '../../store/useEditorStore';
import { useEditorStore, chipFileGroupId } from '../../store/useEditorStore';
import { useSimulatorStore } from '../../store/useSimulatorStore';
import type { BoardKind } from '../../types/board';
import { BOARD_KIND_LABELS } from '../../types/board';
@ -129,6 +129,31 @@ const IcoChevron = ({ open }: { open: boolean }) => (
</svg>
);
// Integrated-circuit (chip) icon — a DIP package with pins. Marks a
// programmable custom-chip's program section, distinct from board sections.
const IcoChip = () => (
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="7" y="7" width="10" height="10" rx="1" />
<line x1="10" y1="3" x2="10" y2="7" />
<line x1="14" y1="3" x2="14" y2="7" />
<line x1="10" y1="17" x2="10" y2="21" />
<line x1="14" y1="17" x2="14" y2="21" />
<line x1="3" y1="10" x2="7" y2="10" />
<line x1="3" y1="14" x2="7" y2="14" />
<line x1="17" y1="10" x2="21" y2="10" />
<line x1="17" y1="14" x2="21" y2="14" />
</svg>
);
// Board emoji icons — mirrors BoardPickerModal
const BOARD_ICON: Record<BoardKind, string> = {
'arduino-uno': '⬤',
@ -254,6 +279,33 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewCl
const boards = useSimulatorStore((s) => s.boards);
const activeBoardId = useSimulatorStore((s) => s.activeBoardId);
const setActiveBoardId = useSimulatorStore((s) => s.setActiveBoardId);
const components = useSimulatorStore((s) => s.components);
// Programmable custom-chips (those with a `programFile`) own a program the
// user can edit — a ROM source / C — shown as its own section below the
// boards. Behaviour/driver chips and predefined chips carry no programFile
// and don't appear here (they're edited in the chip designer).
const programmableChips = components.filter(
(c) =>
c.metadataId === 'custom-chip' &&
String((c.properties as Record<string, unknown>)?.programFile ?? '').trim() !== '',
);
// Ensure each programmable chip has its editor group. loadExample seeds these
// from the example's files; this is the safety net for chips dropped onto the
// canvas (or older projects) — create an empty program file to edit.
useEffect(() => {
const ed = useEditorStore.getState();
for (const chip of programmableChips) {
const gid = chipFileGroupId(chip.id);
if (ed.fileGroups[gid]) continue;
const pf = String((chip.properties as Record<string, unknown>).programFile ?? '').trim();
if (!pf) continue;
const seed = String((chip.properties as Record<string, unknown>).programSource ?? '');
ed.createFileGroup(gid, [{ name: pf, content: seed }]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [components]);
const [contextMenu, setContextMenu] = useState<ContextMenu | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(null);
@ -308,6 +360,23 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewCl
[activeBoardId, switchToBoard, openFile],
);
// Chip program groups aren't tied to a board — switching to one just makes
// the chip's group active in the editor (no activeBoardId change).
const switchToChip = useCallback(
(groupId: string) => {
setActiveGroup(groupId);
},
[setActiveGroup],
);
const handleChipFileClick = useCallback(
(fileId: string, groupId: string) => {
if (groupId !== activeGroupId) switchToChip(groupId);
openFile(fileId);
},
[activeGroupId, switchToChip, openFile],
);
const handleContextMenu = (e: React.MouseEvent, fileId: string, boardGroupId: string) => {
e.preventDefault();
e.stopPropagation();
@ -536,8 +605,76 @@ export const FileExplorer: React.FC<FileExplorerProps> = ({ onSaveClick, onNewCl
);
})}
{/* Fallback: no boards yet */}
{boards.length === 0 && (
{/* Programmable custom-chip program sections one per chip, each its
own collapsible group (the chip's ROM source / C), separate from the
board sketch above. */}
{programmableChips.map((chip) => {
const groupId = chipFileGroupId(chip.id);
const groupFiles = fileGroups[groupId] ?? [];
if (groupFiles.length === 0) return null;
const isActiveGroup = activeGroupId === groupId;
const isOpen = !collapsed[chip.id];
const chipName =
String((chip.properties as Record<string, unknown>)?.chipName ?? '').trim() ||
'Custom Chip';
return (
<div key={chip.id} className="fe-board-section">
<div
className={`fe-board-header${isActiveGroup ? ' fe-board-header-active' : ''}`}
onClick={() => {
switchToChip(groupId);
if (!isOpen) toggleCollapse(chip.id);
}}
title={`${chipName}${t('editor.fileExplorer.clickToEdit')}`}
>
<button
className="fe-collapse-btn"
onClick={(e) => {
e.stopPropagation();
toggleCollapse(chip.id);
}}
title={isOpen ? t('editor.fileExplorer.collapse') : t('editor.fileExplorer.expand')}
>
<IcoChevron open={isOpen} />
</button>
<span className="fe-board-icon" style={{ color: '#c4b5fd' }}>
<IcoChip />
</span>
<span className="fe-board-label">{chipName}</span>
</div>
{isOpen && (
<div className="fe-board-files">
{groupFiles.map((file) => {
const isActiveFile = isActiveGroup && file.id === activeFileId;
return (
<div
key={file.id}
className={`file-explorer-item fe-file-item${isActiveFile ? ' file-explorer-item-active' : ''}`}
onClick={() => handleChipFileClick(file.id, groupId)}
title={`${file.name}${file.modified ? ` (${t('editor.fileExplorer.unsavedSuffix')})` : ''}`}
>
<span className="file-explorer-icon">
<FileIcon name={file.name} />
</span>
<span className="file-explorer-name">{file.name}</span>
{file.modified && (
<span className="file-explorer-dot" title={t('editor.fileExplorer.unsavedChanges')} />
)}
</div>
);
})}
</div>
)}
</div>
);
})}
{/* Fallback: nothing on the canvas yet */}
{boards.length === 0 && programmableChips.length === 0 && (
<div style={{ color: '#666', fontSize: 11, padding: '12px 12px', lineHeight: 1.5 }}>
{t('editor.fileExplorer.emptyState')}
</div>

View File

@ -625,8 +625,9 @@ export const retroIntelExamples: ExampleProject[] = [
// ── Z80 Larson Scanner — NO board (general-purpose electronics) ──
// A programmable Z80 chip + 8 LEDs + a regulated power supply, with NO
// Arduino/ESP32 on the canvas. Demonstrates that Velxio runs custom chips
// standalone. Board-less (boardFilter: 'digital'); the ROM is pre-baked so
// only the chip WASM compiles on Run.
// standalone. Board-less (boardFilter: 'digital'); larson.s is the chip's
// editable program (its own section in the file explorer) — Run assembles
// it to ROM and compiles the chip WASM.
{
id: 'z80-larson-no-board',
title: 'Z80 Larson Scanner (no board)',

View File

@ -78,6 +78,21 @@ const DEFAULT_FILE: WorkspaceFile = {
/** Default file group for the initial Arduino Uno board */
const DEFAULT_GROUP_ID = 'group-arduino-uno';
/**
* Editor file group id for a programmable custom-chip's program.
*
* A custom chip that loads a ROM / runs a user program (a CPU emulator such
* as the Z80 or 8080) keeps that program (`larson.s`, `chaser.c`, ) in its
* OWN file group, exactly like each board owns one. The file explorer renders
* it as a separate collapsible section, so the chip's program never gets
* mixed into the board's sketch. Behaviour/driver chips (a servo driver, a
* sensor) and predefined chips carry no program file and get no group they
* are edited in the chip designer instead.
*/
export const chipFileGroupId = (chipId: string): string => `group-chip-${chipId}`;
/** Prefix shared by every chip program group — used to sweep stale ones. */
export const CHIP_GROUP_PREFIX = 'group-chip-';
/**
* Editor view layout. Lets the user collapse either pane to give the chat
* (right-docked) more breathing room, or to focus on one half of the

View File

@ -6,7 +6,7 @@
import type { ExampleProject } from '../data/examples';
import type { BoardKind } from '../types/board';
import { isPiBoardKind } from '../types/board';
import { useEditorStore } from '../store/useEditorStore';
import { useEditorStore, chipFileGroupId, CHIP_GROUP_PREFIX } from '../store/useEditorStore';
import { useSimulatorStore, DEFAULT_BOARD_POSITION } from '../store/useSimulatorStore';
import { useElectricalStore } from '../store/useElectricalStore';
import { useProjectStore } from '../store/useProjectStore';
@ -50,6 +50,45 @@ export async function ensureLibraries(
}
}
/**
* Programmable custom-chips (those with a `programFile`) keep their program
* (ROM source / C) in their OWN editor file group group-chip-<chipId> so
* it shows as a separate collapsible section in the file explorer, never mixed
* into the board's sketch. This mirrors how every board owns a group.
*
* Seeds those groups from the example's `files[]` (matched by programFile
* name), clearing any chip groups left over from a previously-loaded example.
* Returns the set of program filenames (so the caller keeps them OUT of the
* board's own group) and the created group ids (so a board-less example can
* open the program as the active group).
*/
function seedChipProgramGroups(example: ExampleProject): {
programFileNames: Set<string>;
chipGroupIds: string[];
} {
const editor = useEditorStore.getState();
// Drop chip groups from a previously-loaded example so they don't linger.
Object.keys(editor.fileGroups)
.filter((g) => g.startsWith(CHIP_GROUP_PREFIX))
.forEach((g) => editor.deleteFileGroup(g));
const programFileNames = new Set<string>();
const chipGroupIds: string[] = [];
for (const comp of example.components) {
if (stripBrandPrefix(comp.type) !== 'custom-chip') continue;
const pf = String(
(comp.properties as Record<string, unknown>)?.programFile ?? '',
).trim();
if (!pf) continue; // behaviour / predefined chips have no editable program
programFileNames.add(pf);
const content = example.files?.find((f) => f.name === pf)?.content ?? '';
const gid = chipFileGroupId(comp.id);
editor.createFileGroup(gid, [{ name: pf, content }]);
chipGroupIds.push(gid);
}
return { programFileNames, chipGroupIds };
}
/**
* Load an example project into the editor + simulator stores.
* Does NOT navigate the caller is responsible for navigation.
@ -155,6 +194,10 @@ export async function loadExample(
setActiveBoardId(boardIds[firstArduinoIdx]);
}
// Programmable chips own their program in a dedicated editor group so it
// shows as its own section (the per-board code came from eb.code above).
seedChipProgramGroups(example);
const componentsWithoutBoard = example.components.filter(
(comp) =>
!comp.type.includes('arduino') &&
@ -213,7 +256,15 @@ export async function loadExample(
setActiveBoardId(newId);
}
// ── MicroPython + multi-file payloads ────────────────────────────────
// ── Program / file routing ───────────────────────────────────────────
// A programmable chip's program (larson.s, chaser.c, …) goes into the
// chip's OWN editor group — its own collapsible section — never into the
// board's sketch group. Everything else (sketch.ino) stays with the board.
const { programFileNames, chipGroupIds } = seedChipProgramGroups(example);
const boardOwnedFiles = (example.files ?? []).filter(
(f) => !programFileNames.has(f.name),
);
// When the example specifies languageMode='micropython' or ships a
// files[] array, we go through setBoardLanguageMode + loadFiles instead
// of the legacy setCode() path so the editor opens the right file
@ -227,42 +278,40 @@ export async function loadExample(
setBoardLanguageMode(liveBoard.id, 'micropython');
}
if (example.files && example.files.length > 0 && liveBoard) {
// Re-resolve the file group ID — setBoardLanguageMode replaces it.
const editorStore = useEditorStore.getState();
if (liveBoard) {
// Board present: the board group shows the sketch; chip programs sit in
// their own sections. Re-resolve the group ID — setBoardLanguageMode
// replaces it. We use `loadFiles` (not legacy `setCode`) and switch the
// editor to the board's group first: after a board-less → board
// transition the editor's `activeFileId` still points at an orphan ID
// from the deleted group, so `setCode` would silently no-op and the
// editor would appear blank. (Regression: load-example-transitions.test.ts.)
const updatedBoard = useSimulatorStore
.getState()
.boards.find((b) => b.id === liveBoard.id);
const groupId = updatedBoard?.activeFileGroupId ?? liveBoard.activeFileGroupId;
const editorStore = useEditorStore.getState();
editorStore.setActiveGroup(groupId);
editorStore.loadFiles(example.files);
} else if (liveBoard) {
// Single-file Arduino-style example. We must use `loadFiles` (not the
// legacy `setCode`) and explicitly switch the editor store to the
// board's file group: in board-less → board transitions the editor's
// `activeFileId` still points at an orphan ID from the deleted
// group, so `setCode` would silently no-op and the editor would
// appear blank. (Regression test: load-example-transitions.test.ts.)
const editorStore = useEditorStore.getState();
editorStore.setActiveGroup(liveBoard.activeFileGroupId);
const filename = isPiBoardKind(liveBoard.boardKind) ? 'main.cpp' : 'sketch.ino';
editorStore.loadFiles([{ name: filename, content: example.code }]);
} else {
// Truly board-less. There is no board file-group, so point the editor at
// the default group and load the example's files THERE — otherwise the
// editor's activeGroupId still points at a deleted board's group and
// setCode silently no-ops, leaving the editor blank/uneditable. This is
// what lets a board-less custom-chip example show its program (.s/.c) on
// the left, editable, just like the board-backed examples.
const editorStore = useEditorStore.getState();
if (example.files && example.files.length > 0) {
editorStore.setActiveGroup('group-arduino-uno'); // = DEFAULT_GROUP_ID
editorStore.loadFiles(example.files);
if (boardOwnedFiles.length > 0) {
editorStore.loadFiles(boardOwnedFiles);
} else {
// Pure analog/digital circuits ship no editable program — keep the
// legacy behaviour (write the placeholder to the current file).
editorStore.setCode(example.code);
const filename = isPiBoardKind(liveBoard.boardKind) ? 'main.cpp' : 'sketch.ino';
editorStore.loadFiles([{ name: filename, content: example.code }]);
}
} else if (chipGroupIds.length > 0) {
// Board-less custom-chip example. The chip's program IS the only code
// here, so open its group on the left, editable — just like an Arduino
// sketch. (This is what makes /example/z80-larson-no-board show larson.s.)
editorStore.setActiveGroup(chipGroupIds[0]);
} else if (boardOwnedFiles.length > 0) {
// Board-less but ships plain files (rare) — point the editor at the
// default group and load them there so it isn't blank/uneditable.
editorStore.setActiveGroup('group-arduino-uno'); // = DEFAULT_GROUP_ID
editorStore.loadFiles(boardOwnedFiles);
} else {
// Pure analog/digital circuit, no editable program — keep the legacy
// behaviour (write the placeholder to the current file).
editorStore.setCode(example.code);
}
const componentsWithoutBoard = example.components.filter(

View File

@ -9,9 +9,24 @@
import type { ProjectSaveData } from '../services/projectService';
import type { BoardInstance } from '../types/board';
import type { Wire } from '../types/wire';
import { useEditorStore } from '../store/useEditorStore';
import { useEditorStore, chipFileGroupId } from '../store/useEditorStore';
import { useSimulatorStore } from '../store/useSimulatorStore';
/**
* Editor groups owned by programmable custom-chips on the canvas (those whose
* `group-chip-<id>` group exists). A chip's program (ROM source / C) lives in
* its own group, exactly like a board sketch, so it must be serialised and
* dirty-checked alongside the board groups otherwise saving would drop it.
*/
function chipGroupIdsWithFiles(): string[] {
const sim = useSimulatorStore.getState();
const editor = useEditorStore.getState();
return sim.components
.filter((c) => c.metadataId === 'custom-chip')
.map((c) => chipFileGroupId(c.id))
.filter((gid) => (editor.fileGroups[gid]?.length ?? 0) > 0);
}
/** Strip BoardInstance down to JSON-safe fields (drop runtime state). */
function serialisableBoard(b: BoardInstance) {
return {
@ -62,13 +77,23 @@ export function buildSavePayload(meta: SnapshotInputs = {}): ProjectSaveData {
activeFiles[0]?.content ??
'';
const fileGroups = sim.boards.map((b) => ({
const boardGroups = sim.boards.map((b) => ({
groupId: b.activeFileGroupId,
files: (editor.fileGroups[b.activeFileGroupId] ?? []).map((f) => ({
name: f.name,
content: f.content,
})),
}));
// Programmable-chip program groups round-trip too — same shape, restored on
// load by replaceFileGroups (the group id is derived from the chip's id).
const chipGroups = chipGroupIdsWithFiles().map((gid) => ({
groupId: gid,
files: (editor.fileGroups[gid] ?? []).map((f) => ({
name: f.name,
content: f.content,
})),
}));
const fileGroups = [...boardGroups, ...chipGroups];
return {
name: meta.name ?? '',
@ -98,7 +123,7 @@ export function computeProjectStateHash(): string {
// Group file contents only for groups referenced by an existing board, in
// a stable order (sorted by groupId).
const referencedGroups = Array.from(
new Set(sim.boards.map((b) => b.activeFileGroupId)),
new Set([...sim.boards.map((b) => b.activeFileGroupId), ...chipGroupIdsWithFiles()]),
).sort();
const groupsForHash = referencedGroups.map((gid) => ({
g: gid,

View File

@ -32,7 +32,7 @@
import type { BoardInstance } from '../types/board';
import type { Component } from '../types/component';
import type { Wire } from '../types/wire';
import { useEditorStore } from '../store/useEditorStore';
import { useEditorStore, chipFileGroupId } from '../store/useEditorStore';
import { useSimulatorStore } from '../store/useSimulatorStore';
const VLX_FORMAT = 'velxio-project';
@ -78,9 +78,15 @@ export function buildVlxPayload(opts: { name?: string } = {}): VlxPayload {
const sim = useSimulatorStore.getState();
const editor = useEditorStore.getState();
// Only persist file groups that are actually referenced by a board.
// Stray groups left over from deleted boards don't need to round-trip.
// Persist file groups referenced by a board, plus each programmable chip's
// own program group (group-chip-<id>) — otherwise the chip's program would
// be dropped on export. Stray groups from deleted boards don't round-trip.
const referencedGroupIds = new Set(sim.boards.map((b) => b.activeFileGroupId));
for (const c of sim.components) {
if (c.metadataId !== 'custom-chip') continue;
const gid = chipFileGroupId(c.id);
if (editor.fileGroups[gid]?.length) referencedGroupIds.add(gid);
}
const fileGroups: VlxPayload['fileGroups'] = {};
for (const gid of referencedGroupIds) {
fileGroups[gid] = (editor.fileGroups[gid] ?? []).map((f) => ({