material/ is the smallest panel cluster: MaterialsPanel manages a model's materials[] lane as an accordion list, MaterialEditor renders the form for one record, and texture.ts holds the client-side image intake (resize + compress to a data: URI) that the editor's upload field uses. Like every panel in @parametron/ui, the cluster is host-pure — it never touches fetch/storage, only the MaterialsPanelHost/MaterialEditorHost callbacks passed in by the caller.
Import
import {
MaterialEditor,
MaterialsPanel,
compressImageToDataUri,
fitWithin,
} from "@parametron/ui"
import type { MaterialEditorHost, MaterialRecord, MaterialsPanelHost } from "@parametron/ui"Exported from the package root (packages/ui/src/material/index.ts → the root barrel src/index.ts); there is no /material subpath.
MaterialRecord
type MaterialRecord = {
name: string
baseColor?: string
texture?: string // https:// or data: URI
tileWidth?: number // physical mm of one seamless texture tile
tileHeight?: number
colorFactor?: number // 0 = texture only, 1 = solid baseColor
opacity?: number
roughness?: number
metallic?: number
}This mirrors the engine's MaterialJSON field-for-field (same names, same defaults) — kept as a local type so the panel stays host-pure rather than importing the schema. See Schema & validation for the canonical MaterialJSON shape and how the materials[] lane is consumed by name (applyMaterial) elsewhere in a model.
MaterialsPanel
function MaterialsPanel(host: MaterialsPanelHost): DomphyElement<"div">type MaterialsPanelHost = {
materials: () => MaterialRecord[] // live array — never copied
onAdd: () => MaterialRecord // create + append, return it
onChange: (record: MaterialRecord) => void // a field was mutated in place
onRename: (record: MaterialRecord, name: string) => boolean // false = reject, field reverts
onRemove: (record: MaterialRecord) => void
}Renders a compact list — swatch, name, ✕ remove — one row per record from host.materials(). Clicking a row toggles an inline MaterialEditor underneath it (accordion; only one record open at a time, tracked by name). "+ Add Material" calls host.onAdd() and immediately opens the returned record's editor.
Two toState values drive re-renders: openName (which record's editor is expanded) and an internal tick counter bumped after every host callback, so panel-internal edits repaint before the host's own change notification loops back. Rows are keyed by record.name (_key: record.name) — onRename returning true is what lets the panel re-key and keep the same row open across a rename.
const panel = MaterialsPanel({
materials: () => model.materials,
onAdd: () => {
const record: MaterialRecord = { name: `Material ${model.materials.length + 1}` }
model.materials.push(record)
return record
},
onChange: () => persist(model),
onRename: (record, name) => {
if (!name || model.materials.some(m => m !== record && m.name === name)) return false
record.name = name
persist(model)
return true
},
onRemove: (record) => {
model.materials = model.materials.filter(m => m !== record)
persist(model)
},
})MaterialEditor
function MaterialEditor(
record: MaterialRecord,
records: () => MaterialRecord[],
host: MaterialEditorHost,
): DomphyElement<"div">
type MaterialEditorHost = Pick<MaterialsPanelHost, "onChange" | "onRename">A single-record form: a preview swatch (baseColor under the texture image, the same layering the renderer composes) followed by one labelled row per field — Name, Color, Texture, Tile mm, and slider rows for Opacity, Roughness, Metallic, plus a Tint slider (colorFactor) shown only when a texture is set.
Every control mutates record in place, then calls host.onChange(record) — the caller owns render refresh and persistence, the editor never re-renders itself beyond its own local display ref on slider rows. Name commits on blur/Enter through host.onRename (reverts the field if rejected); Color and the numeric fields commit on input/change; the Texture row's "Upload…" label wraps a hidden <input type="file"> that runs compressImageToDataUri and writes the result straight into record.texture.
records (the second parameter) is threaded through for parity with the panel's accordion call site but isn't read by the editor's own body today — pass host.materials when calling it standalone (e.g. from a point-of-use popover on a face).
const editor = MaterialEditor(record, () => model.materials, {
onChange: (record) => persist(model),
onRename: (record, name) => {
record.name = name
return true
},
})Texture intake (texture.ts)
function fitWithin(width: number, height: number, max: number): [number, number]
function compressImageToDataUri(file: File, maxEdge?: number): Promise<string>fitWithin scales (width, height) down to fit inside a max-by-max square, preserving aspect ratio, and never upscales — pure function, no DOM, usable in tests.
compressImageToDataUri is the browser-only path MaterialEditor's upload field calls: decode the File via createImageBitmap, resize onto a <canvas> (default maxEdge = 512), encode as WebP at quality 0.8, and fall back to JPEG at 0.85 if the browser silently returned PNG (no WebP encoder). The result lands directly in record.texture as a data: URI, so the document stays self-contained (offline use, SketchUp HtmlDialog, sharing) — an https:// URL typed into the Texture text field is the alternative, non-embedded path, and both converge on the same record.texture string.
See also
- Schema & validation — the canonical
MaterialJSONshape and thematerials[]asset lane this panel edits. - Model authoring reference — how
applyMaterialconsumes a record by name inside a model's operations. - Catalog panel —
CatalogMaterial, a read-only material presentation used in the catalog browsing flow (distinct from this cluster's editing form). - UI overview & chrome — the host-pure doctrine and the full panel cluster table.