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.

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.
- ECS Lite
- Data model for hardware layout
- Bevy’s query objects
- Progress reporting and state machine
- 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.
- Cell (a.k.a. Structure): Defines a building block with polygons and child cells
- Instance: Specific placement of a cell in the world
- Polygon: Closed shape representing a physical element in a certain layer
- Layer: Fabrication plane with a certain thickness and material (e.g., silicon, metal, or an insulator)
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:
async move {
spawn_local(let loader = GdsLoader::new(&content);
for mut progress in loader {
.send_message(Msg::SetStatus(progress.status_message()));
linkTimeoutFuture::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 {
Vec<u8>),
ParsingFile(,
GatheringNames(GdsLibrary)Box<WorldGenerator>),
GeneratingWorld(Box<World>),
YieldingWorld(,
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.

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 {
: Option<LoaderState>,
state}
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.

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.