Parametron

BuilderPanel is @parametron/ui's primary editing surface: one Domphy component that renders a live editor for a single placed model — parameters, the operation fold, an error trace, and a header with save/menu actions. It never touches the network or the DOM outside its own root; every side effect (persistence, viewport highlighting, geometry import) goes through a BuilderHost the caller injects. This page covers the panel's rendered structure and its host contract; the underlying graph it edits is Model & pull graph, and the geometry it previews is Render contract.

What it renders

BuilderPanel(host, selected, opts) takes a State<PlacedBlock | null> — when it holds null the panel shows a placeholder ("Select a placed block to build."); once a block is selected it renders, top to bottom:

  • Header row — the block's name (a drag/toggle gutter placeholder keeps it aligned with the rows below), a Basic/Advanced DisplayToggle (progressive disclosure: Basic shows friendly labels, Advanced shows expression keys — nothing is hidden by the toggle, only relabeled), an asset-lane badge, an optional Save button (only when opts.onSave is set), and a "..." header menu.
  • Error trace — one alert block collecting every node/expression error under the header; each entry jumps to the offending node on click.
  • Parameter section — the model's parameters lane, one row per named value binding.
  • Operation section — the model's operations lane: root-level containers (each its own fold) followed by root-level bare operations (the model-wide root chain), both drag-reorderable.
  • Footer — a global AddNodesBar: producers on the left, transforms on the right, driven by registry.nodes.

The header menu ("...") holds: a Title field (renames the block), a Visibility row (Draft/Private/Public, only when opts.visibility + opts.onVisibilityChange are set), Translate/Clone/Embed actions (each gated by its own opts.on* + optional opts.can* State), Create-New/Clone place-actions (only when opts.canPlace), inline Unit Settings (length/area/volume/mass dropdowns — switching a unit calls the engine's convertModelUnit on placed.nodes, which recurses every group entry and converts every dimensional arg, then re-evaluates), and a confirm-gated Remove action.

BuilderPanel

function BuilderPanel(
    host: BuilderHost,
    selected: State<PlacedBlock | null>,
    opts?: BuilderPanelOptions,
): DomphyElement<"div">

opts.width defaults to "540px"; passing "100%" or "flex" switches the panel to fill mode (no hard width — the host's column is responsible for the minimum). BUILDER_PANEL_MIN_WIDTH ("450px") is the non-fill-mode minimum width — reference this constant from host layout code instead of hardcoding the number.

BuilderPanelOptions:

  • width?: string
  • onSave?: () => void | Promise<void> — shows the Save button; its promise drives an idle → saving → saved/error → idle cycle (2s hold on saved/error), with failures surfaced on the button itself, never window.alert.
  • canSave?: State<boolean> — gates the Save button; defaults to always-visible once onSave is set.
  • onTranslate?: () => void, canTranslate?: State<boolean> — adds a "Translate…" menu item.
  • onFork?: () => void, canFork?: State<boolean> — adds a "Clone" menu item that copies the model into the current user's account.
  • onEmbed?: () => void, canEmbed?: State<boolean> — adds an "Embed settings…" menu item.
  • onRemove?: () => void, canRemove?: State<boolean> — adds a confirm-gated "Remove" menu item.
  • visibility?: State<ModelVisibility>, onVisibilityChange?: (v: ModelVisibility) => void, canVisibility?: State<boolean> — adds the Draft/Private/Public row.
  • canPlace?: boolean — shows "Create New" / "Clone" place-actions (multi-block hosts, e.g. the SketchUp bridge; hidden by default because a single-model host like a /model page just reloads the same model in place).

ModelVisibility = "draft" | "private" | "public".

BuilderHost — the host contract

interface BuilderHost {
    placeBlock(modelId: string): Promise<PlacedBlock>
    updateBlock(placedId: string, params: Record<string, unknown>): Promise<void>
    importEntities?: (file: File) => Promise<{ entities: unknown[]; materials?: { name: string }[] }>
    viewport?: ViewportBridge
    translations?: Record<string, string>
}
  • placeBlock — adds another instance of the given model to the host's scene and returns its PlacedBlock. Called by the header menu's Create-New/Clone actions (canPlace) and by the Fork/clone flow.
  • updateBlock — pushes new parameter values for an already-placed block back to the host (e.g. write-through to the host's own scene state).
  • importEntities — optional. Parses a .glb/.gltf/.json file into a baked EntityJSON[] + document-lane material records; hosts without an import pipeline simply omit it and the panel's Import action doesn't appear.
  • viewport — optional ViewportBridge, injected (never a window global) and threaded down to the arg-rendering blocks for hover-preview feedback. A host with no canvas overlay leaves this undefined and every preview call no-ops.
  • translations — translated parameter labels for the current locale, key → plain text.

@parametron/ui's reference consumers are the web app's Three.js-backed Studio (implements BuilderHost against a Three.js scene) and the SketchUp adapter (dispatches the same calls to the Ruby bridge) — same panel, two hosts.

PlacedBlock

type PlacedBlock = {
    id: string
    modelId: string
    name: string
    params: Record<string, unknown>
    paramTypes: Record<string, string>
    nodes: NodeCollection<ChildNode>
}

Opaque data from the panel's point of view — it reads nodes (the live NodeCollection it renders/edits) and calls BuilderHost methods to mutate geometry; it never derives params/paramTypes itself. NodeCollection is the graph type documented in Model & pull graph.

Viewport preview — ViewportBridge / PreviewJSON

Hover feedback is described as data, never as draw calls, so every host renders it with its own canvas:

interface ViewportBridge {
    preview: (preview: PreviewJSON) => void
    clearPreview: () => void
}

interface PreviewJSON {
    items: PreviewItem[]
}

PreviewItem is a tagged union — { kind: "point"; position }, { kind: "points"; positions; color? }, { kind: "segments"; segments; color? }, { kind: "vector"; direction; anchorKey? }, { kind: "axis"; origin; direction }, { kind: "plane"; origin; normal }, and the escape hatch { kind: "entities"; entities: EntityJSON[]; color? } for previewing arbitrary engine entities as a transient ghost through the same render primitives the real scene uses. PreviewPoint = [number, number, number].

Icons

NodeIcons.ts is an auto-generated Tabler icon export (SVG strings, never re-fetched) the panel's own rows and menus render from — exposed so a host building its own toolbar can reuse the same set instead of shipping a second one:

  • genericIcons — chrome icons (menu, drag, remove, config, chevron, plus, wireframe, play, hand, …).
  • typeIcons — one per value type (boolean, number, text, length, area, volume, mass, currency, color, angle, model, group, layer, material).
  • namespaceIcons — one per scene-entity namespace (block, curveEntity, faceEntity, entity).
  • generatorSectionIcons — the producer-section subset (curveEntity, faceEntity, entity).
  • nodeIcons — one per registry method name (rectangle, curveExtrude, solidUnion, move, arrayLinear, noise, …) — the same keys a RegistryInput's methods use.

All five are Record<string, string>; an unmatched key just renders nothing, so a host-added method without an icon entry degrades gracefully.

Usage

import { toState } from "@domphy/core"
import { createRegistry, Model } from "@parametron/parametric"
import { nodeRegistry } from "@parametron/parametric/nodes"
import { BuilderPanel, type BuilderHost, type PlacedBlock } from "@parametron/ui"

const registry = createRegistry(nodeRegistry)
const model = Model.fromJSON(modelJson, { registry })
model.evaluate()

const placed: PlacedBlock = {
    id: "block-1",
    modelId: "plate",
    name: model.name,
    params: {},
    paramTypes: {},
    nodes: model.nodes,
}

const host: BuilderHost = {
    placeBlock: async modelId => placed,           // multi-block hosts return a NEW PlacedBlock
    updateBlock: async (placedId, params) => {      // persist params against your own store
        Object.assign(placed.params, params)
    },
}

const selected = toState<PlacedBlock | null>(placed)

BuilderPanel(host, selected, { onSave: () => persistBlock(placed) })

See also

  • UI overview & chrome — the rail/router/theme shell BuilderPanel is mounted inside.
  • Configurator panel — the read-only sibling that renders the same params as end-user controls.
  • i18n catalog — the string catalog behind every label/tooltip in this panel.
  • Model & pull graphNodeCollection, evaluate(), and the node classes PlacedBlock.nodes exposes.
  • Render contract — the EntityJSON shape used by importEntities and the entities preview kind.
📖 6 min read