feat: initial app code

This commit is contained in:
2026-04-24 17:34:10 +02:00
parent 0b48740a85
commit 09a9f8ce2c
8 changed files with 165 additions and 31 deletions

View File

@@ -91,7 +91,7 @@ export class GraphManager extends EventEmitter<{
currentUndoGroup: number | null = null;
// Group-related state
groups: Map<string, NodeGroupDefinition> = new Map();
groups = new SvelteMap<string, NodeGroupDefinition>();
groupNodeDefinitions: Map<string, NodeDefinition> = new Map();
currentGroupContext: string | null = null;
graphStack: { rootGraph: Graph; groupId: string; cameraPosition: [number, number, number] }[] = $state([]);
@@ -113,12 +113,15 @@ export class GraphManager extends EventEmitter<{
let merged: Graph = this.serialize();
for (let i = this.graphStack.length - 1; i >= 0; i--) {
const { rootGraph, groupId } = $state.snapshot(this.graphStack[i]);
// Prefer the live definition (may have been updated via addGroupSocket/rename)
// over the snapshot taken when we entered the group.
const currentDef = (this.graph.groups ?? rootGraph.groups)?.[groupId];
merged = {
...rootGraph,
groups: {
...rootGraph.groups,
[groupId]: {
...rootGraph.groups?.[groupId]!,
...(currentDef as NodeGroupDefinition),
graph: { nodes: merged.nodes, edges: merged.edges }
}
}
@@ -142,9 +145,13 @@ export class GraphManager extends EventEmitter<{
return {
id: `__virtual/group/${group.id}` as NodeId,
meta: { title: group.name },
inputs: Object.fromEntries(
inputs: {
// Placeholder for the group-selector dropdown — counted in height/socket math
'__virtual/groupId': { type: 'select' as const, internal: true, label: '' } as NodeInput,
...Object.fromEntries(
group.inputs.map(s => [s.name, { type: s.type, external: true } as NodeInput])
),
)
},
outputs: group.outputs.map(s => s.type),
execute(input: Int32Array): Int32Array { return input; }
};
@@ -153,7 +160,12 @@ export class GraphManager extends EventEmitter<{
buildGroupInputNodeDef(group: NodeGroupDefinition): NodeDefinition {
return {
id: '__virtual/group/input' as NodeId,
inputs: {},
meta: { title: 'Input' },
// Each group input socket gets a labeled row (external = no control widget,
// internal = no left-side socket dot; it's an output-only node).
inputs: Object.fromEntries(
group.inputs.map(s => [s.name, { type: s.type, external: true, internal: true }])
) as Record<string, NodeInput>,
outputs: group.inputs.map(s => s.type),
execute(input: Int32Array): Int32Array { return input; }
};
@@ -434,11 +446,19 @@ export class GraphManager extends EventEmitter<{
const group = this.groups.get(this.currentGroupContext);
if (!group) return;
const arr = kind === 'input' ? group.inputs : group.outputs;
const name = `${kind}_${arr.length}`;
arr.push({ name, type: socketType });
const oldArr = kind === 'input' ? group.inputs : group.outputs;
const name = `${kind}_${oldArr.length}`;
const updatedGroup: NodeGroupDefinition = {
...group,
inputs: kind === 'input' ? [...oldArr, { name, type: socketType }] : group.inputs,
outputs: kind === 'output' ? [...oldArr, { name, type: socketType }] : group.outputs
};
this._refreshGroupContext(group);
if (!this.graph.groups) this.graph.groups = {};
this.graph.groups[this.currentGroupContext] = updatedGroup;
// Reinitialize so virtual group input/output nodes pick up the new socket reactively
this._init(this.serialize());
this.save();
}
@@ -447,10 +467,17 @@ export class GraphManager extends EventEmitter<{
const group = this.groups.get(this.currentGroupContext);
if (!group) return;
const arr = kind === 'input' ? group.inputs : group.outputs;
arr.splice(index, 1);
const oldArr = kind === 'input' ? group.inputs : group.outputs;
const updatedGroup: NodeGroupDefinition = {
...group,
inputs: kind === 'input' ? oldArr.filter((_, i) => i !== index) : group.inputs,
outputs: kind === 'output' ? oldArr.filter((_, i) => i !== index) : group.outputs
};
this._refreshGroupContext(group);
if (!this.graph.groups) this.graph.groups = {};
this.graph.groups[this.currentGroupContext] = updatedGroup;
this._init(this.serialize());
this.save();
}
@@ -479,6 +506,51 @@ export class GraphManager extends EventEmitter<{
}
}
pruneUnusedGroups() {
const usedIds = new Set<string>();
// Scan nodes in the current graph
for (const node of this.nodes.values()) {
if (node.type === '__virtual/group/instance') {
const gid = node.props?.groupId as string | undefined;
if (gid) usedIds.add(gid);
}
}
// Scan nodes in every stacked (parent) graph
for (const { rootGraph } of this.graphStack) {
for (const node of rootGraph.nodes) {
if (node.type === '__virtual/group/instance' && node.props?.groupId) {
usedIds.add(node.props.groupId as string);
}
}
}
// Scan internal graphs of all known groups (nested group nodes)
for (const group of this.groups.values()) {
for (const node of group.graph.nodes) {
if (node.type === '__virtual/group/instance' && node.props?.groupId) {
usedIds.add(node.props.groupId as string);
}
}
}
let changed = false;
for (const groupId of [...this.groups.keys()]) {
if (!usedIds.has(groupId)) {
this.groups.delete(groupId);
this.groupNodeDefinitions.delete(`__virtual/group/${groupId}`);
if (this.graph.groups) delete this.graph.groups[groupId];
changed = true;
}
}
if (changed) {
this.execute();
this.save();
}
}
// --- Group navigation ---
enterGroup(nodeId: number, cameraPosition: [number, number, number] = [0, 0, 4]): boolean {
@@ -984,6 +1056,7 @@ export class GraphManager extends EventEmitter<{
}
removeNode(node: NodeInstance, { restoreEdges = false } = {}) {
if (node.type === '__virtual/group/input' || node.type === '__virtual/group/output') return;
const edgesToNode = this.edges.filter((edge) => edge[2].id === node.id);
const edgesFromNode = this.edges.filter((edge) => edge[0].id === node.id);
for (const edge of [...edgesToNode, ...edgesFromNode]) {
@@ -1211,11 +1284,9 @@ export class GraphManager extends EventEmitter<{
return;
}
// Don't emit save event while navigating inside a group
if (this.graphStack.length > 0) return;
this.emit('save', state);
logger.log('saving graphs', state);
const fullState = this.graphStack.length > 0 ? this.serializeFullGraph() : state;
this.emit('save', fullState);
logger.log('saving graphs', fullState);
}
getParentsOfNode(node: NodeInstance) {

View File

@@ -40,7 +40,7 @@
let meshRef: Mesh | undefined = $state();
const height = getNodeHeight(node.state.type!);
const height = $derived(getNodeHeight(node.state.type!));
const zoom = $derived(graphState.cameraPosition[2]);

View File

@@ -37,16 +37,38 @@
);
if (node.type === '__virtual/group/instance') {
const groupOptions = [...(manager?.groups?.entries() ?? [])].map(([id, g]) => ({
label: g.name,
value: id
}));
// Remove the static placeholder from the definition (height-only) and replace
// with a fully dynamic version that carries current names + value.
parameters = parameters.filter(([key]) => key !== '__virtual/groupId');
parameters = [['__virtual/groupId', {
type: 'select',
value: node.props?.groupId as string,
options: [...manager?.groups?.keys()]
options: groupOptions
}], ...parameters];
}
return parameters;
}
$effect(() => {
const props = node.props as Record<string, unknown> | undefined;
const virtualGroupId = props?.['__virtual/groupId'] as string | undefined;
if (!virtualGroupId) return;
const activeGroupId = props?.groupId as string | undefined;
if (virtualGroupId === activeGroupId) return;
const newGroupDef = manager?.groupNodeDefinitions.get(`__virtual/group/${virtualGroupId}`);
if (!newGroupDef) return;
const { children, parents, ref } = node.state;
node.props = { ...props, groupId: virtualGroupId, '__virtual/groupId': virtualGroupId };
node.state = { type: newGroupDef, children, parents, ref };
manager?.execute();
manager?.save();
});
const parameters = $derived(buildParameters(node, node?.state?.type?.inputs || {}));
const currentGroupId = $derived((node.props?.groupId as string) ?? '');

View File

@@ -52,7 +52,7 @@
// select input: use index into options
if ('options' in node && Array.isArray(node.options)) {
if (typeof inputValue === 'string') {
return node.options.indexOf(inputValue);
return (node.options as string[]).indexOf(inputValue);
}
return 0;
}

View File

@@ -5,8 +5,6 @@
type Props = { manager: GraphManager; groupId: string };
const { manager, groupId }: Props = $props();
$inspect({ groupId });
const group = $derived(manager.groups.get(groupId));
const COMMON_TYPES = ['plant', 'float', 'int', 'vec3', 'bool'];
@@ -34,6 +32,10 @@
function removeSocket(index: number) {
manager.removeGroupSocket('input', index);
}
function prune() {
manager.pruneUnusedGroups();
}
</script>
<div class="bg-layer-2 flex items-center h-[70px] border-b-1 border-outline pl-4">
@@ -85,6 +87,11 @@
<button class="add-btn" onclick={addSocket}>+ Add</button>
</div>
</div>
<div class="flex flex-col gap-1.5">
<span class="section-label">Maintenance</span>
<button class="danger-btn" onclick={prune}>Prune unused groups</button>
</div>
</div>
<style>
@@ -145,4 +152,24 @@
.add-btn:hover {
outline-color: var(--color-selected);
}
.danger-btn {
background: var(--color-layer-2);
color: var(--color-text);
border: none;
outline: 1px solid var(--color-outline);
border-radius: 5px;
padding: 0.4em 0.7em;
font-size: 0.8em;
cursor: pointer;
font-family: var(--font-family);
opacity: 0.7;
width: 100%;
text-align: left;
}
.danger-btn:hover {
outline-color: #e05050;
opacity: 1;
}
</style>

View File

@@ -61,7 +61,10 @@ export const NodeInputBooleanSchema = z.object({
export const NodeInputSelectSchema = z.object({
...DefaultOptionsSchema.shape,
type: z.literal('select'),
options: z.array(z.string()).optional(),
options: z.union([
z.array(z.string()),
z.array(z.object({ label: z.string(), value: z.string() }))
]).optional(),
value: z.string().optional()
});

View File

@@ -40,7 +40,7 @@
{:else if input.type === 'boolean'}
<InputCheckbox bind:value={value as boolean} {id} />
{:else if input.type === 'select'}
<InputSelect bind:value={value as number} options={input.options} {id} />
<InputSelect bind:value={value as number | string} options={input.options} {id} />
{:else if input.type === 'vec3'}
<InputVec3 bind:value={value as [number, number, number]} {id} />
{/if}

View File

@@ -1,17 +1,28 @@
<script lang="ts">
type StringOption = string;
type LabeledOption = { label: string; value: string };
interface Props {
options?: string[];
value?: number;
options?: StringOption[] | LabeledOption[];
value?: number | string;
id?: string;
}
let { options = [], value = $bindable(0), id = '' }: Props = $props();
let { options = [], value = $bindable<number | string>(0), id = '' }: Props = $props();
const isLabeled = $derived(options.length > 0 && typeof options[0] === 'object');
</script>
<select {id} bind:value class="bg-layer-2 text-text">
{#each options as label, i (label)}
<option value={i}>{label}</option>
{#if isLabeled}
{#each options as opt ((opt as LabeledOption).value)}
<option value={(opt as LabeledOption).value}>{(opt as LabeledOption).label}</option>
{/each}
{:else}
{#each options as label, i (label)}
<option value={i}>{label as string}</option>
{/each}
{/if}
</select>
<style>