Parametron

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 MaterialJSON shape and the materials[] asset lane this panel edits.
  • Model authoring reference — how applyMaterial consumes a record by name inside a model's operations.
  • Catalog panelCatalogMaterial, 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.
📖 3 min read