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 whenopts.onSaveis 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
parameterslane, one row per named value binding. - Operation section — the model's
operationslane: 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 byregistry.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?: stringonSave?: () => 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, neverwindow.alert.canSave?: State<boolean>— gates the Save button; defaults to always-visible onceonSaveis 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/modelpage 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 itsPlacedBlock. 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/.jsonfile into a bakedEntityJSON[]+ document-lane material records; hosts without an import pipeline simply omit it and the panel's Import action doesn't appear.viewport— optionalViewportBridge, 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 registrymethodname (rectangle,curveExtrude,solidUnion,move,arrayLinear,noise, …) — the same keys aRegistryInput'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
BuilderPanelis mounted inside. - Configurator panel — the read-only sibling that renders the same
paramsas end-user controls. - i18n catalog — the string catalog behind every label/tooltip in this panel.
- Model & pull graph —
NodeCollection,evaluate(), and the node classesPlacedBlock.nodesexposes. - Render contract — the
EntityJSONshape used byimportEntitiesand theentitiespreview kind.