Skip to main content

Creating New Projects

Creating new projects is a large undertaking, but this guide should help advise how to start.

When refering to a 'project', I mean an instance of the OpenCircuits API (currently 'digital' or 'analog').

Directory setup

First you must create the project directories, you'll need:

src/  projects/   myproject/    api/     circuit/     circuitdesigner/    site/

and add src/projects/myproject/api/* and src/projects/myproject/site to package.json "workspaces".

Circuit API Setup

The first step to setting up the project code is to create the API for your project in src/projects/myproject/api/circuit.

Component Definitions

Create a file named MyProjectComponents.ts in your API directory. This file will define all the components, wires, and ports available in your new project.

There are a few main classes you'll need to create:

  • Configuration Info Classes: You'll create classes that extend BaseComponentConfigurationInfo, BaseWireConfigurationInfo, and BasePortConfigurationInfo. These define the properties and connection logic for your project's objects.
  • An Object Info Provider class (e.g., MyProjectObjInfoProvider) that extends BaseObjInfoProvider. This class acts as a registry for all of your configuration info classes.

Let's look at each in detail.

1. Component Configuration Info

This class defines the fundamental rules for how components in your project interact. You can create a single class for all components, or multiple for different categories of components if their behavior differs significantly.

A simple implementation, similar to the one used in the 'analog' project, allows any port to connect to any other port, with no connection limits; except to prevent self-connections.

export class MyProjectComponentInfo extends BaseComponentConfigurationInfo {
protected override getPortInfo(_p: PortConfig, _group: string, _i: number): Pick<Schema.Port, "kind" | "props"> {
return {
kind: "MyProjectPort",
props: {},
};
}

// There are no max connections for ports
public override isPortAvailable(_port: Schema.Port, _curConnections: Schema.Port[]): boolean {
return true;
}

// All ports can connect to all other ports (except themselves)
public override checkPortConnectivity(
port: Schema.Port,
newConnection: Schema.Port,
_curConnections: Schema.Port[],
): Result {
if (port.id === newConnection.id)
return ErrE(`MyProjectComponentInfo: Illegal connection of port to itself ${port.id}`);
return OkVoid();
}
}

For more complex projects, like the 'digital' one, you might need more specific rules. For example, distinguishing between input and output ports to enforce fan-in limits and prevent input-to-input connections.

Here are the key methods you'll typically override:

  • getPortInfo(portConfig, group, index): Returns the kind and props for a specific port. This is how you specify that your components should have MyProjectPorts.
  • isPortAvailable(port, currentConnections): Determines if a new connection can be made to a port. Return false to prevent a connection. For example, digital input ports only allow one connection.
  • checkPortConnectivity(port, newConnection, currentConnections): Validates if newConnection is a valid connection for port. Return an ErrE to prevent the connection and provide an error message. For example, digital circuits prevent connecting two output ports together.

2. Wire and Port Configuration Info

You also need to define the behavior for your project's wires and ports.

The WireInfo class specifies what kind of Node to create when a wire is split, along with the port(s) of the Node to connect to the given existing ports. The PortInfo class specifies what kind of Wire to create when a connection is made.

export class MyProjectWireInfo extends BaseWireConfigurationInfo {
public override getSplitConnections(p1: Schema.Port, p2: Schema.Port, wire: Schema.Wire): Result</*...*/> {
return Ok({
nodeKind: "MyProjectNode", // The node to create when splitting a wire
// The port of the new node to connect to the given `p1` port
p1Group: "",
p1Idx: 0,
// The port of the new node to connect to the given `p2` port
p2Group: "",
p2Idx: 0,
});
}
}

export class MyProjectPortInfo extends BasePortConfigurationInfo {
// No methods needed, just pass the wire kind to the constructor
}

3. The Object Info Provider

This class is a factory and registry for all objects in your project. You'll instantiate it once and provide it to the circuit.

First, you need to create instances of your configuration info classes for each component, wire, and port type.

After defining all your object types, you create the provider class and register them in the constructor. The following block shows a complete example of the remaining parts of the file.

// ... (MyProjectComponentInfo, MyProjectWireInfo, MyProjectPortInfo classes from above)

// --- Configuration Info Instances ---

// Example Node component: A special component that is often used to join wires.
const NodeInfo = new MyProjectComponentInfo("MyProjectNode", {}, [""], [{ "": 1 }], true);

// Example Wire Info
const WireInfo = new MyProjectWireInfo("MyProjectWire", {});

// Example Port Info
const PortInfo = new MyProjectPortInfo("MyProjectPort", {}, "MyProjectWire");

// Example custom component (e.g., a Resistor with 2 ports)
const ResistorInfo = new MyProjectComponentInfo("Resistor", { "resistance": "number" }, [""], [{ "": 2 }], false);

// --- Object Info Provider ---

export class MyProjectObjInfoProvider extends BaseObjInfoProvider {
public constructor() {
super(
[
// Components
NodeInfo,
ResistorInfo,
// ... add all your component infos here
],
[WireInfo], // Wires
[PortInfo], // Ports
);
}

public override createIC(ic: Schema.IntegratedCircuit): void {
const ports = ic.metadata.pins.reduce<Record<string, Schema.IntegratedCircuitPin[]>>(
(prev, pin) => ({
...prev,
[pin.group]: [...(prev[pin.group] ?? []), pin],
}),
{},
);

const portConfig: PortConfig = MapObj(ports, ([_, pins]) => pins.length);

this.ics.set(
ic.metadata.id,
new MyProjectComponentInfo(
ic.metadata.id,
{},
Object.keys(ports),
[portConfig],
false,
MapObj(ports, ([_, pins]) => pins.map((p) => p.name)),
),
);
}
}

The createIC method is used to dynamically generate component information for Integrated Circuits (ICs) when they are created. The logic shown here is a standard implementation that groups the IC's pins and creates a corresponding ComponentInfo for it. You can likely reuse this implementation as-is.

Component Assemblers

Once you've defined the data and behavior of your components, you need to define how they look. This is the job of Component Assemblers.

Each component kind in your project needs a corresponding ComponentAssembler. This class is responsible for:

  1. Defining the component's size and shape.
  2. Positioning the component's ports.
  3. Assembling the visual primitives (rectangles, polygons, SVGs, text, etc.) that make up the component's appearance.
  4. Updating the appearance based on state changes (e.g., selection, simulation state).

1. The Circuit Assembler Factory

First, you need a factory function that creates a CircuitAssembler for your project. This CircuitAssembler holds a map of all your component assemblers. This is typically done in a file like MyProjectCircuitAssembler.ts.

export function MakeMyProjectCircuitAssembler(
circuit: CircuitInternal,
// If your project has a simulation, you'll pass it here and to your assemblers
// sim: MyProjectSim,
options: RenderOptions,
): CircuitAssembler {
// The CircuitAssembler takes a function that returns a map of assemblers.
// This allows the \`params\` object (containing circuit, options, etc.)
// to be passed to each assembler's constructor.
return new CircuitAssembler(circuit, options, (params: AssemblerParams) => ({
// Every project needs these basic assemblers for built-in types
"IC": new ICComponentAssembler(params),
"MyProjectWire": new WireAssembler(params),
"MyProjectNode": new NodeAssembler(params, {
// Port placement for the node (a single port in the center)
"": () => ({ origin: V(0, 0), target: V(0, 0) }),
}),

// Your custom component assemblers go here
"Resistor": new ResistorAssembler(params),
// ... other assemblers
}));
}

2. Creating a Custom Component Assembler

Now let's create the ResistorAssembler we referenced above. This class will extend ComponentAssembler. For our example, we'll create a simple 2x1 rectangle with a port on the left and a port on the right, matching the ResistorInfo we defined previously.

export class ResistorAssembler extends ComponentAssembler {
public constructor(params: AssemblerParams) {
// The super constructor takes 3-4 arguments:
// 1. params: The assembler parameters (circuit, options, etc.)
// 2. portPlacements: A map of functions to position ports.
// 3. assemblyParts: An array of objects that define the visual parts.
// 4. options (optional): Extra configuration.
super(
params,
{
// The "Resistor" component has one port group ("") with two ports.
// We can access them by index.
"": (_comp, index, _total) => ({
// Port 0 is on the left, Port 1 is on the right.
// The origin is at the center-left/right edge of the component.
origin: V(index === 0 ? -1 : 1, 0),
dir: V(index === 0 ? -1 : 1, 0),
}),
},
[
{
kind: "BaseShape",

// This part should be re-assembled when the component moves/rotates.
dependencies: new Set([AssemblyReason.TransformChanged]),

// This function returns the actual visual primitive.
assemble: (comp) => ({
kind: "Rectangle",
transform: this.getTransform(comp),
}),

// These two properties handle selection highlighting.
styleChangesWhenSelected: true,
getStyle: (comp) => this.options.fillStyle(this.isSelected(comp.id)),
},
],
);
}

// Override getSize to define the component's bounding box.
// The size is in world units, centered on the component's position.
protected override getSize(_comp: Schema.Component): Vector {
return V(2, 1); // A 2x1 rectangle
}
}

With these two files, you have a complete, albeit simple, visual representation for a custom component.

3. Advanced Assemblers

The digital project contains many examples of more complex assemblers:

  • Dynamic Sizing: ComparatorAssembler and MultiplexerAssembler change their size based on the number of ports. This is done by overriding getSize and reading the component's port count from this.circuit.
  • SVG Shapes: ConstantLowAssembler uses an SVG file for its visual, which is great for complex, static icons.
  • State-Dependent Visuals: OscilloscopeAssembler reads the component's state from the DigitalSim and draws a waveform. This uses the AssemblyReason.StateUpdated dependency to trigger re-assembly when the simulation state changes.

Defining Your API

With the internal data structures (ObjInfoProvider) and visual representations (CircuitAssembler) defined, the final step is to create the public-facing API.

The setup is centered around a CircuitContext class, which acts as the central hub for your circuit's state and API object creation.

1. The Project's Circuit Context

First, create a MyProjectCircuitContext.ts file. This class extends the abstract CircuitContext and is responsible for wiring together all the major parts of your circuit's backend.

In its constructor, it will:

  1. Call super() to initialize the core internal state, passing in your project's ObjInfoProvider.
  2. Initialize the assembler property by calling your MakeMyProjectCircuitAssembler function.
  3. Initialize the factory property. This factory is responsible for creating the public-facing API objects (like Component, Wire, etc.). For a simple project, you can use the base implementation classes from shared.
export class MyProjectCircuitContext extends CircuitContext<CircuitAPITypes> {
public readonly assembler: CircuitAssembler;
public readonly factory: CircuitAPIFactory<CircuitAPITypes>;

public constructor(id: GUID) {
super(id, new MyProjectObjInfoProvider());

this.assembler = MakeMyProjectCircuitAssembler(this.internal, this.renderOptions);
this.factory = new CachedCircuitAPIFactoryImpl({
constructComponent: (id, icId) => new ComponentImpl(this, id, icId),
constructWire: (id, icId) => new WireImpl(this, id, icId),
constructPort: (id, icId) => new PortImpl(this, id, icId),
constructIC: (id) => new IntegratedCircuitImpl(this, id),
constructComponentInfo: (kind) => new ComponentInfoImpl(this, kind),
constructObjContainer: (objs, icId) => new ObjContainerImpl(this, objs, icId),
});
}
}

2. The Project's Circuit Implementation

Next, create your project's main Circuit class. This class extends CircuitImpl and is very simple. Its only job is to create an instance of your MyProjectCircuitContext and pass it to its own super() constructor.

export class MyProjectCircuitImpl extends CircuitImpl<CircuitAPITypes> {
public constructor(id: GUID) {
const ctx = new MyProjectCircuitContext(id);
super(ctx, new SelectionsImpl(ctx));
}
}

3. The CreateCircuit Entry Point

Finally, create the public entry point for your API in an index.ts file. This function is now just a one-liner that instantiates your MyProjectCircuitImpl and is a bridge so that you can safely revoke access from importing into src/projects/myproject/api/circuit/public/impl.

import { uuid } from "shared/api/circuit/public";
import { MyProjectCircuitImpl } from "./impl/MyProjectCircuit";

export function CreateCircuit(id = uuid()) {
return new MyProjectCircuitImpl(id);
}

From here, you can move onto the next section to define your CircuitDesigner API object(s), and then finally how to plug it into your frontend.

4. Advanced API Setup

The digital project has more complex needs. It introduces a simulation (DigitalSim) and adds new properties and methods to the core API objects (e.g., component.inputs, port.signal, circuit.sim).

If we used the simple approach, a call like circuit.getComponents() would return an array of base Component objects, not DigitalComponent objects. We would lose all the new methods and type safety.

To solve this, the digital project uses advanced TypeScript features (specifically, mapped and conditional types) to create a new set of API types. It defines a ToDigital<T> utility type that recursively traverses the entire base API and replaces every Component, Port, Circuit, etc., with its Digital counterpart.

This ensures that when you use the DigitalCircuit API, every method returns the correct, extended Digital type, providing a seamless and type-safe developer experience.

This approach is powerful but significantly more complex to set up. It's recommended only for projects that need to fundamentally extend the core API surface. For most new projects, the simple API is the recommended starting point.

CircuitDesigner API Setup

With your Circuit API defined, the next step is to make it interactive. This is the role of the CircuitDesigner.

The CircuitDesigner is a wrapper around your Circuit that connects it to the user interface. It is responsible for:

  1. Rendering: Drawing the circuit onto an HTML <canvas> element. It manages a Viewport that handles panning, zooming, and converting between screen and world coordinates.
  2. User Input: Capturing mouse and keyboard events from the canvas and translating them into actions.
  3. Tools & Handlers: Providing a system for interactive behaviors like selecting, moving, wiring, and deleting components.

Setting up the CircuitDesigner is much simpler than the Circuit API. It primarily involves creating a factory function that instantiates the CircuitDesignerImpl with your project's specific configuration.

1. The CreateDesigner Factory

In your src/projects/myproject/api/circuitdesigner directory, create a MyProjectCircuitDesigner.ts file. This file will contain your CreateDesigner function.

This function takes a ToolConfig (which defines the set of available tools and handlers) and wires everything together.

import { CreateCircuit } from "myproject/api/circuit/public";

import { CircuitDesigner, ToolConfig } from "shared/api/circuitdesigner/public/CircuitDesigner";
import { CircuitDesignerImpl } from "shared/api/circuitdesigner/public/impl/CircuitDesigner";
import { CanvasTextMeasurer } from "shared/api/circuitdesigner/public/impl/rendering/CanvasTextMeasurer";
import { ToolRenderer } from "shared/api/circuitdesigner/tools/renderers/ToolRenderer";

// This file will contain any SVGs your project uses for component rendering.
// For a new project, it can be an empty map.
import { SVGs } from "./rendering/svgs";

export interface MyProjectCircuitDesigner extends CircuitDesigner {}

export function CreateDesigner(
toolConfig: ToolConfig,
renderers: ToolRenderer[],
dragTime?: number,
circuit = CreateCircuit(),
) {
// The text measurer is required for rendering text labels on components.
circuit.getContext().renderOptions.textMeasurer = new CanvasTextMeasurer();

const designer = new CircuitDesignerImpl(circuit, circuit.getContext(), SVGs, { dragTime, toolConfig });

// This connects any custom tool renderers to the viewport's render loop.
designer.viewport.subscribe("onrender", (ev) => {
renderers.forEach((toolRenderer) =>
toolRenderer.render({
designer,
renderer: ev.renderer,
}),
);
});

return designer;
}

You'll also need a corresponding rendering/svgs/index.ts file. If your project doesn't use any SVGs for component rendering, this can just export an empty map.

import "shared/api/circuitdesigner/types/declarations";
import { SVGDrawing } from "svg2canvas";

export const SVGs: Map<string, SVGDrawing> = new Map([]);

With these two files, your CircuitDesigner API is ready to be used in your frontend application.

2. Custom Tools and Handlers

The real power of the CircuitDesigner comes from its extensible tool system. While the shared package provides a rich set of default tools (for selecting, moving, wiring, etc.), you can create custom tools and handlers to add project-specific interactions.

  • Tool Handlers: These are simple objects that respond to specific events (like click, keydown, dblclick). They are perfect for stateless actions. For example, the digital project uses an InteractionHandler to toggle switches and press buttons. It checks the kind of the clicked component and calls its setSimState method.

  • Tools: These are stateful classes that manage more complex, multi-step interactions, like dragging or resizing. A Tool has methods like shouldActivate, onActivate, onEvent, and onDeactivate to control its lifecycle. For example, the digital project has an ICResizeTool that activates when the user starts dragging the edge of an IC, updates the IC's size during the drag, and commits the change on mouse up.

To add a custom handler or tool, you would create it in your circuitdesigner's tools directory and then add it to the ToolConfig when you call CreateDesigner from your frontend application. This allows each site or application using your project's API to customize the set of available interactions.

Frontend Setup

The final step is to integrate your CircuitDesigner into a frontend application. This guide will walk you through setting up a React-based site, using the analog project as a reference for a minimal implementation.

The frontend is composed of shared UI components (Header, ItemNav, MainDesigner, etc.) that interact with the CircuitDesigner through a set of globally accessible helper functions.

1. The CircuitHelpers

The bridge between the UI components and your project's CircuitDesigner API is an object called CircuitHelpers. You must configure this object at the entry point of your application, typically in index.tsx.

The most important function to provide is CreateAndInitializeDesigner. This function is responsible for creating an instance of your project's CircuitDesigner with a specific ToolConfig.

Here's a standard setup for src/projects/myproject/site/src/index.tsx:

async function Init() {
const store = configureStore({ reducer: reducers });

SetCircuitHelpers({
CreateAndInitializeDesigner(tools) {
return CreateDesigner(
tools?.config ?? {
defaultTool: new DefaultTool(
InteractionHandler,
SelectionHandler,
DeleteHandler
),
tools: [
new PanTool(),
new WiringTool(),
new SelectionBoxTool(),
],
},
tools?.renderers ?? [],
undefined, // Use default drag time
CreateCircuit()
);
},
// TODO: Implement serialization helpers
Serialize: () => { throw new Error("Not implemented"); },
SerializeAsString: () => { throw new Error("Not implemented"); },
DeserializeCircuit: () => { throw new Error("Not implemented"); },
});

// Create the main designer instance that the app will use
const mainDesigner = CircuitHelpers.CreateAndInitializeDesigner();
setCurDesigner(mainDesigner);

const root = createRoot(document.getElementById("root")!);
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);
}

Init();

2. The Main App Component

With the helpers configured, you can now build your main App component. This component will compose the various shared UI containers to build the application layout.

A minimal App.tsx would look like this:

import {useCurDesigner} from "shared/site/utils/hooks/useDesigner";

import {Header} from "shared/site/containers/Header";
import {MainDesigner} from "shared/site/containers/MainDesigner";
import {ItemNav} from "shared/site/containers/ItemNav";
import {SelectionPopup} from "shared/site/containers/SelectionPopup";

import itemNavConfig from "myproject/site/data/ItemNavConfig";

export const App = () => {
const designer = useCurDesigner();

return (
<div className="App">
<div className="App__container">
<Header img="/assets/logo.svg" />
<main>
<MainDesigner />
<ItemNav designer={designer} config={itemNavConfig} />
<SelectionPopup designer={designer} />
</main>
</div>
</div>
);
};

This setup gives you a functional application with a header, a main canvas area for the circuit, an item navigation bar to add components, and a popup for viewing/editing selected object properties.

3. Next Steps

From here, you can continue to build out your application's UI by:

  • Creating an ItemNavConfig: Define the components that appear in the ItemNav sidebar, complete with labels and icons.
  • Configuring the SelectionPopup: Add custom property modules to the selection popup to display project-specific information or actions.
  • Adding more ToolHandlers: Enhance interactivity by adding handlers for features like copy/paste, undo/redo, and saving.
  • Implementing Serialization: Fill in the TODO in CircuitHelpers to enable saving and loading circuits, likely using Protobuf as seen in the digital project.