Rust on the web and an “ECS lite” architecture

layout-viewer is my tiny web app for viewing integrated circuit layouts. It’s an exercise in simplicity. There’s no usage of Cow or Rc, only minimal reliance on lifetime specifiers, etc. My goal is to demonstrate a clear & comprehensible Rust codebase.

Demo of layout-viewer

The app’s data model requires quite a bit of cross-referencing due to the heavy use of instancing in layout files. Since strong ownership semantics would be difficult to manage, I decided to use an ID-based architecture, where various objects refer to each other without any ownership.

ID’s are fundamental to Entity-Component-System architectures, so I settled on using the ECS in Bevy. Bevy’s ECS lives in an isolated crate, which is nice since I do not need any of the game engine stuff.

The app needs to load large files and report progress while keeping the UI responsive. However I wanted to do this without web workers. While I do appreciate web workers (we use them at Arcol), this project is an exercise in minimalism. To skip the ECS stuff and read about my async loader, jump here.

  1. ECS Lite
  2. Data model for hardware layout
  3. Bevy’s query objects
  4. Progress reporting and state machine
  5. Addendum: hierarchical instancing

ECS Lite

You might already know what an ECS is. En bref: entities are IDs representing objects, components are pure data bundles that can be dynamically attached to entities, and systems are functions that process entities with specific component combinations. ECS architectures really shine for certain types of games and simulations.

The systems in Bevy’s ECS also have sophisticated features for scheduling and parallelism. They support commands, events, and more. This felt like more than what I needed. My app is not a simulation or a game and there’s only one thread since it targets WASM. Plus I wanted to have tight control over scheduling and async behavior myself. So I ended up using only entities, components, and queries.

Data model for hardware layout

Here’s some vocabulary used in the world of integrated circuit design. You’ll find these terms in specifications like GDSII and OASIS.

The hierarchical instancing implied by this data model is similar to what’s common in BIM and the AEC industry, like what we deal with at Arcol. For example, a doorknob is positioned within a door component, and a set of instanced doors and windows are positioned within a story, and a set of stories are stacked to form a building. (More on this in the addendum at the bottom of this post.)

Here’s a simplified version of my data model. I’m using the excellent geo crate for low-level geometric representations.

pub type Transform = geo::AffineTransform;
pub type GeoPolygon = geo::Polygon::<f64>;

#[derive(Component)]
pub struct Selected;

#[derive(Component)]
pub struct RootCell;

#[derive(Component)]
pub struct CellDefinition {
    pub name: String,
    pub polygon_definitions: Vec<Entity>,
    pub child_cell_definitions: Vec<(Entity, Transform)>,
}

#[derive(Component)]
pub struct PolygonDefinition {
    pub object_space_polygon: GeoPolygon,
}

#[derive(Component)]
pub struct CellInstance {
    pub cell_definition: Entity,
    pub polygon_instances: Vec<Entity>,
    pub child_instances: Vec<Entity>,
    pub object_space_to_world_space: Transform,
}

#[derive(Component)]
pub struct PolygonInstance {
    pub cell_instance: Entity,
    pub world_space_polygon: GeoPolygon,
}

You might be noticing a proliferation of the Entity type. Each entity is really just a 64-bit ID.

One downside to ECS architectures is the lack of type safety. That’s because components can be attached or detached dynamically at run time, so there’s no guarantee at compile time. For this reason, retrieving component data from an entity (without using a proper Query object) returns an Option:

let Some(comp) = world.get::<CellDefinition>(entity) else {
    log::error!("Expected component missing.");
    return;
};
// do stuff with comp here...

Note that some of my components, like Selected, have no data at all; these are marker components. They’re useful because they can be queried for efficiently. There’s no need to explicitly store a selection set anywhere in my app.

I’ve found that using an ECS frees me from the burden of thinking about ownership and where things belong. It turns all my in-app data into a simple lightweight database.

An interesting crate is moonshine_kind, which lets you substitute Bevy’s Entity with Instance<MyComponent> in situations where you know for certain that the lifetime of a certain component is exactly the same as the entity it is attached to.

Bevy’s query objects

I’ve known about ECS architectures for a long time, but I don’t think I really “got them” until I understood how queries work. Before I explain why they’re so efficient, let me first show some examples. Here’s a simple one:

let cell_query: QueryState<CellDefinition> = QueryState::new(&mut world);

for cell in cell_query.iter(&world) {
    log::info!("Cell name: {}", cell.name);
}

You can also query for sets of entities that all have the same set of components:

let selected_polygons: QueryState<(Selected, PolygonInstance)> =
    QueryState::new(&mut world);

for (_, polygon) in selected_polygons.iter(&world) {
    // do stuff with the selected polygon data...
}

If you want to get the 64-bit entity ID, you can do that too:

let all_polygons: QueryState<(Entity, PolygonInstance)> =
    QueryState::new(&mut world);

for (id, polygon) in all_polygons.iter(&world) {
    // do stuff with the id and the polygon data...
}

There are many more things you can do with Bevy queries: compose them, filter them, or even use a special get_single method for cases where you know that a given component exists only once in the world.

Please note that if you use the actual “systems” in Bevy’s ECS (which is generally considered best practice), queries become even easier to use. I’m just showing you how I used them in my project, which accesses the Bevy World directly.

Queries and archetypes

Query objects are really neat. Behind the scenes, Bevy maintains multiple lists of entities: one for each active combination of components. When a component is added or removed from an entity, the entity gets moved from one list to another. These lists incidentally are called “archetypes”. This lets you filter entities very efficiently.

Progress reporting and state machine

I’m using Yew for the UI elements in the app. The handler for the Yew button that kicks off the loading process looks a bit like this:

spawn_local(async move {
    let loader = GdsLoader::new(&content);
    for mut progress in loader {
        link.send_message(Msg::SetStatus(progress.status_message()));
        TimeoutFuture::new(0).await;
    }
});

The spawn_local call queues a browser “microtask” (see queueMicrotask on MDN) and gives us an async block from which we can call other async functions, like TimeoutFuture::new(0). This gives Yew enough time to react to input events and re-render DOM elements if necessary.

My GdsLoader struct is interesting because it has a few states that are nicely expressed using the following enum. (in TypeScript we would call this a discriminated union)

enum LoaderState {
    ParsingFile(Vec<u8>),
    GatheringNames(GdsLibrary),
    GeneratingWorld(Box<WorldGenerator>),
    YieldingWorld(Box<World>),
    Done,
}

The data associated in each variant is moved in from the previous state.

The flow between states is very simple. In the following diagram (made with Evan’s tool) the two-letter notation refers to the camel case identifiers in the above enum.

Finite state machine

The caller cares about progress, but doesn’t need to know the details of the state machine. So the state enum is a private member of the GdsLoader struct, which looks a bit like this:

pub struct GdsLoader {
    state: Option<LoaderState>,
}

impl GdsLoader {
    pub fn new(gds_content: &[u8]) -> Self {
        let state = LoaderState::ParsingFile(gds_content.to_vec());
        Self { state: Some(state) }
    }
}

impl Iterator for GdsLoader {
    type Item = GdsProgress;

    fn next(&mut self) -> Option<GdsProgress> {
        let state = self.state.take()?;
        let (progress, state) = state.next()?;
        self.state = Some(state);
        Some(progress)
    }
}

impl LoaderState {
    fn next(self) -> Option<(GdsProgress, Self)> {
        match self {
            // ...
        }
    }
}

Addendum: hierarchical instancing

GDSII and Oasis use hierarchical instancing, which is not a unique concept. This kind of instancing is also a core feature in some 3D scene descriptions like Pixar’s USD format. You won’t find hierarchical instancing in simpler data models like glTF.

glTF does have a notion of instancing; the same mesh object can be referenced by several node objects. However, local positioning cannot be shared between disparate parts of the scene graph. For example, if a user moves a doorknob within a door component, then they should see all door instances in the building automatically get updated. This kind of behavior is not a core feature in a traditional scene graph.

It is often the case that a high-level data model must be flattened into a simple scene graph of instances at run time. Note that component definitions reference each other to form a DAG, which is not necessarily a tree. However the flattened graph of instances is always a tree.

Here’s an example a of simple hierarchical building and the BIM components that make it up.

Simple building with instancing
Building          UpperFloor    LowerFloor
├── LowerFloor    ├── Window    ├── Door
├── UpperFloor    ├── ...       ├── Door
└── UpperFloor    └── Window    ├── Window
.                               ├── ...
.                               └── Window

Window            Door
├── Pane          ├── InnerHandle
├── Pane          └── OuterHandle
├── Pane
└── Pane

References

Sander Mertens has an excellent series of posts about how an ECS works behind the scenes.

The Unofficial Bevy Cheat Book has a nice overview for Bevy’s ECS.