Runtime custom configurator UI
Status: Normative.
Use this guide when an application owns its UI and Three.js presentation while Vertcie remains authoritative for configuration branches, selections, geometry, shaders, materials, and transforms. A design system or Figma file controls presentation only; it must not become configuration truth.
Data flow
customer browser
→ same-origin customer server
→ Vertcie publication-scoped API
→ Expanders manifest and selected shader descriptors
→ custom controls
→ canonical Expander session
→ selected meshes, shaders, and transforms
→ Three.js scene updateKeep x-vertcie-key on the customer server. The browser must never call Vertcie with that header.
Load the Expanders manifest
Fetch the active manifest through the v1 API:
GET /api/v1/publications/{publishedFileId}/manifests/expanders
x-vertcie-key: SERVER_KEYUnwrap the v1 response envelope and retain data.revision, data.assets, data.shaders, and data.initialState.
The configuration controls come from:
const {
branches,
selections: initialSelections,
resolutionModel,
baseSelectedShaders = [],
} = manifest.initialState;Do not infer choices from labels, previews, filenames, Figma data, or GLTF materials. Preserve branch, option, shader, mesh, and revision identifiers as opaque stable values.
Build custom controls
Use the canonical createExpanderSession implementation from @vertcie/runtime-expanders/session:
const session = createExpanderSession({
branches,
initialSelections,
resolutionModel,
});Render branches in authored order, filtered by session.state.activeBranchIds. Render option labels from the corresponding manifest branch, but store and submit option IDs.
On a choice:
const state = session.select(branchId, optionId);
const shaderIds = [...new Set([
...baseSelectedShaders,
...state.selectedShaders,
])];Rerender controls from state.activeBranchIds and state.selections. Apply state.selectedMeshes, shaderIds, and state.selectedTransforms to the existing scene as one logical update. Do not reset camera or product presentation state.
Do not replace the canonical session with a simplified dependency resolver. Conditional ownership, equivalent migrated groups, material groups, Match Keys, and transform activation are part of its contract.
The session is a shipped client-side runtime API, not an HTTP session resource. There are no /clients/expanders/select, /session, or /state routes. The separate server operation is:
POST /api/v1/publications/{publishedFileId}/selection-resolutionsThat operation resolves supplied selection data into runtime assets and materials; it does not replace the in-memory Expander session that owns active branch UI state. Do not reconstruct createExpanderSession from a subset of manifest fields merely because a guessed session URL returns 404.
Send the session's canonical state.selections map as selections. The response format is vertcie/runtime-selection-resolution/v1 and is produced by executing the same published manifest/session contract shown below. Use its activeGroupIds, visibleMeshIds, selectedShaderIds, materials, transforms, and meshPaths directly; do not rerun dependency rules in the consumer. Add revision to pin the same immutable manifest used to initialize the UI. Signature integrations send client: "signature" and the complete signature instead.
The same ownership boundary applies to cross-option house rules. Authors edit them in the card's Rules Editor using workflow-backed group and choice controls. Publication writes the active, enabled rules to manifest.runtimeRules; the Expander session evaluates them in ascending priority order and exposes the result as state.availableOptionIdsByBranch. A custom UI must render those available options and call the session's canonical select operation. It must not reproduce a family matrix, compare display labels, or embed product IDs.
State ownership and Redux integration
The Expander session owns configuration logic. Redux may mirror its serializable snapshot for UI rendering, history, analytics, and application coordination, but reducers must not independently recompute branch dependencies or material activation.
Keep these concerns separate:
| Owner | Responsibility |
|---|---|
| API gateway | Authenticated server-side fetches and v1 envelope normalization |
| Expander session | Branch ownership, dependency activation, Match Key synchronization, and canonical selection resolution |
| Redux configurator slice | Serializable manifest metadata and the latest canonical session snapshot |
| Runtime resource service | In-memory geometry, full shader, texture, and signed-URL caches |
| Redux presentation slice | Camera, orbit target, product rotation, environment, and viewer preferences |
| Three.js adapter | Apply the latest selected meshes, shaders, materials, and transforms |
Do not store the API key, complete signed URLs, Three.js objects, textures, materials, GLTF scenes, promises, abort controllers, or the mutable Expander session in Redux. Redux DevTools and persistence middleware can expose state.
A suitable serializable state shape is:
type ConfiguratorState = {
publication: {
publishedFileId: string | null;
revision: number | null;
client: "expanders";
status: "idle" | "loading" | "ready" | "error";
errorCode: string | null;
};
catalog: {
branches: ExpanderBranch[];
shaderMetadataById: Record<string, {
id: string;
label?: string;
targetAssetIds?: string[];
targetMeshIds?: string[];
hasSwatch: boolean;
}>;
baseSelectedShaders: string[];
};
selection: {
selections: Record<string, string>;
activeBranchIds: string[];
selectedMeshes: string[];
selectedShaders: string[];
selectedTransforms: RuntimeTransform[];
generation: number;
};
};If swatch URLs are temporary, keep them in the runtime resource service rather than Redux. A selector may join option and shader IDs with an in-memory swatch cache at render time. Store only hasSwatch, hash, or another non-secret cache identity in serializable application state.
Create one Expander session after a manifest has been validated. Keep it in a controller/service closure:
let expanderSession = null;
function initializeConfiguration(manifest) {
expanderSession = createExpanderSession({
branches: manifest.initialState.branches,
initialSelections: manifest.initialState.selections,
resolutionModel: manifest.initialState.resolutionModel,
});
store.dispatch(configurationInitialized({
publishedFileId: manifest.publishedFileId,
revision: manifest.revision,
branches: manifest.initialState.branches,
baseSelectedShaders: manifest.initialState.baseSelectedShaders ?? [],
snapshot: expanderSession.state,
}));
}The user-selection event flow is:
Figma control click
-> dispatch optionSelectionRequested(branchId, optionId)
-> controller validates IDs against the current active manifest branch
-> canonical session.select(branchId, optionId)
-> dispatch canonicalSelectionReceived(snapshot, generation)
-> Redux selectors rerender active controls and selected labels
-> resource service resolves selected geometry and full shaders
-> Three.js adapter atomically applies the same generationThe reducer records the session result; it does not derive a new result:
function selectOption(branchId, optionId) {
return async (dispatch, getState, services) => {
if (!expanderSession) throw new Error("Expander session is unavailable");
const before = getState().configurator.selection;
if (!before.activeBranchIds.includes(branchId)) return;
const branch = services.branchById(branchId);
if (!branch?.options.some((option) => option.id === optionId)) return;
const generation = before.generation + 1;
const snapshot = expanderSession.select(branchId, optionId);
dispatch(canonicalSelectionReceived({ generation, snapshot }));
const shaderIds = [...new Set([
...getState().configurator.catalog.baseSelectedShaders,
...snapshot.selectedShaders,
])];
await services.viewer.applySelection({
generation,
selectedMeshes: snapshot.selectedMeshes,
selectedShaderIds: shaderIds,
selectedTransforms: snapshot.selectedTransforms,
});
};
}Use selectors rather than copying display state into components:
const selectActiveBranches = createSelector(
[selectBranches, selectActiveBranchIds],
(branches, ids) => {
const active = new Set(ids);
return branches.filter((branch) => active.has(branch.id));
},
);
const makeSelectBranchOptions = (branchId) => createSelector(
[selectActiveBranches, selectSelections],
(branches, selections) => {
const branch = branches.find((entry) => entry.id === branchId);
if (!branch) return null;
return {
branch,
selectedOptionId: selections[branch.id],
options: branch.options,
};
},
);Keep presentation state in a separate slice. A configuration action must not write camera position, orbit target, product rotation, or environment state. Likewise, presentation actions must not modify canonical selections.
Use a generation number for newest-wins behavior. Resource completions and the viewer adapter must compare their generation with the current Redux generation before committing. Aborted or stale work is disposed without dispatching a configuration rollback.
Mock and live gateways must normalize to the same manifest/session contract before initialization. Components and reducers must not contain if (mock) branches. Synthetic fixtures remain isolated from live mode and must never provide fallback customer labels, shader data, or swatches.
Labels and material swatches
Match the native Expanders labeling contract:
- the control/group heading is
branch.label; - the visible choice text is
option.label; branch.idandoption.idare stored as opaque values, never displayed as
substitute labels;
matchKeydrives cross-branch resolution and is not customer-facing copy.
Do not replace option.label with a shader descriptor label. A descriptor label may be used as secondary diagnostic metadata, but the authored option label remains authoritative.
Build a shader index once:
const shaderById = new Map(
(manifest.shaders ?? []).map((shader) => [shader.id, shader]),
);
const materialBranchIds = new Set(
(manifest.initialState.resolutionModel?.materialGroups ?? [])
.map((group) => group.id),
);Resolve the swatch for an option from its authored shader contribution:
function optionShaderIds(branch, option) {
const selected = Array.isArray(option.selectedShaders)
? option.selectedShaders
: [];
// Runtime material-choice IDs are shader IDs. Use that identity fallback
// only for a branch declared by the resolution model as a material group.
return selected.length
? selected
: materialBranchIds.has(branch.id)
? [option.id]
: [];
}
function optionSwatch(branch, option) {
for (const shaderId of optionShaderIds(branch, option)) {
const swatch = shaderById.get(shaderId)?.swatch;
if (swatch?.thumbnailUrl && swatch?.detailUrl) return swatch;
}
return null;
}The compact manifest shader descriptor carries:
type RuntimeSwatch = {
version?: number;
hash?: string;
thumbnailUrl: string; // standard 128 px catalog/choice image
detailUrl: string; // standard 512 px inspection image
};Use thumbnailUrl for option grids, lists, and collapsed selection summaries. Use detailUrl only for enlarged inspection, zoom, or an explicitly high-resolution selected state. Do not use material.mapUrl, a base-color texture, a shader texture path, or a rendered model screenshot as the option swatch. The swatch is the authored standard material preview and may include important framing around the preview sphere.
Render a stable square media container and preserve the authored image framing:
<img
src="SWATCH_THUMBNAIL_URL"
alt="OPTION_LABEL material swatch"
width="128"
height="128"
loading="lazy"
decoding="async"
/>.material-option__media {
aspect-ratio: 1;
overflow: hidden;
}
.material-option__media img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}Do not crop or scale the sphere differently from the standard swatch. Mark the selected option from session.state.selections[branch.id], not from image load state.
Structural choices can legitimately have no shader swatch. Render their authored label and the Figma-defined structural icon or neutral placeholder; never borrow a swatch from another option. If a material option has no valid swatch, show a deterministic neutral placeholder and keep the option usable. Image failure must not mutate selection state.
The hash and version may be used as cache identity. Treat URL values as replaceable delivery values. Never parse sourcePath, expose it to the browser, or use it to construct another URL.
Resolve geometry
Each manifest asset contains a stable id and a temporary authorized url. Load the GLTF from the URL and preserve the descriptor identity on its root:
root.userData.assetId = asset.id;
root.userData.assetKey = asset.assetKey;
root.userData.meshReference = asset.meshReference;Show only roots selected by state.selectedMeshes. If mesh URLs must be refreshed, send the selected IDs through the publication-scoped endpoint:
POST /api/v1/publications/{publishedFileId}/mesh-paths
Content-Type: application/json
x-vertcie-key: SERVER_KEY
{ "meshIds": ["mesh-id"] }Unwrap data.meshPaths. Never construct storage paths or accept arbitrary paths from the browser.
Resolve selected shaders
manifest.shaders is a bounded descriptor index. A descriptor contains its id, target identifiers, and either an inline material or a reference to a material payload. For a selected descriptor without an inline material, the customer server fetches the immutable shader for the resolved manifest revision:
GET /api/runtime/publications/{publishedFileId}/revisions/{revision}/clients/expanders/shaders/{shaderId}
x-vertcie-key: SERVER_KEYThis compatibility response is a shader assignment rather than a v1 envelope. Validate that its id equals the requested ID and that it contains material. Cache it in memory by published file, revision, client, and shader ID. Do not cache or log signed URL values independently.
An assignment applies only to its targetAssetIds or targetMeshIds. Match those identifiers against the preserved asset identity. Never apply every selected shader to every visible mesh.
Assignments that affect separate objects may resolve in parallel. Assignments that overlap one object must apply serially in authored selection order; the last applicable assignment wins.
Three.js material semantics
Use THREE.MeshPhysicalMaterial. Prefer the shipped Runtime Core applyRuntimeMaterial and resetRuntimeMaterials functions. A custom renderer must preserve the same behavior.
Supported physical fields include:
color,roughness,metalness,opacity,transparent, andwireframe;ior,transmission,clearcoat, andclearcoatRoughness;- sheen, anisotropy, iridescence, and dispersion fields when supported by the
deployed Three.js version;
normalScale,bumpScale,displacementScale, anddisplacementBias.
Missing fields do not authorize invented values. If normalScale is a scalar, apply it to both vector components.
Before an authored finish is applied, clear embedded GLTF maps from the target material. Clone and retain each mesh's baseline material when geometry first loads so a changed selection can restore it. Dispose replaced material and texture instances.
Map signed material URL fields as follows:
| JSON field | Three.js slot | Color space |
|---|---|---|
mapUrl | map | THREE.SRGBColorSpace |
normalMapUrl | normalMap | THREE.NoColorSpace |
bumpMapUrl | bumpMap | THREE.NoColorSpace |
roughnessMapUrl | roughnessMap | THREE.NoColorSpace |
metalnessMapUrl | metalnessMap | THREE.NoColorSpace |
alphaMapUrl | alphaMap | THREE.NoColorSpace |
displacementMapUrl | displacementMap | THREE.NoColorSpace |
For every loaded texture:
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.flipY = false;
texture.center.set(0.5, 0.5);
const x = finiteNonZero(material.tiling?.tileX) ?? 1;
const y = finiteNonZero(material.tiling?.tileY) ?? 1;
texture.repeat.set(material.flipU ? -x : x, material.flipV ? -y : y);
texture.rotation = THREE.MathUtils.degToRad(finite(material.rotation) ?? 0);
texture.updateMatrix();
texture.needsUpdate = true;When a roughness or metalness map exists, use its supplied scalar. If the payload omits the scalar, use 1 so the map contributes fully.
Transforms and asynchronous safety
Transform nodes do not replace meshes. The canonical Expander session resolves transform activation into two separate outputs:
state.selectedMeshesis the complete visibility set, including geometry
contributed through a transform's activationAssetIds;
state.selectedTransformscontains the active transform records to apply to
their authored targets.
Never derive visibility from selectedTransforms, transform IDs, target IDs, or an individual option's local contribution. Never remove a mesh merely because it has an active transform. Use only the complete canonical state.selectedMeshes set to load and show/hide asset roots.
A resolved transform can contain:
type RuntimeTransform = {
id: string;
label?: string;
activationAssetIds?: string[];
targetAssetIds?: string[];
targetMeshIds?: string[];
position?: [number, number, number] | { x?: number; y?: number; z?: number };
rotation?: [number, number, number] | { x?: number; y?: number; z?: number };
scale?: [number, number, number] | { x?: number; y?: number; z?: number };
};activationAssetIds participate in canonical mesh selection; they are not transform targets and must not be sent to a transform lookup as mesh IDs. targetAssetIds and targetMeshIds identify the loaded asset roots that receive the transform. Match them against preserved assetId, assetKey, and meshReference identities using the same bounded normalization used for shader targets.
Store each loaded asset root's baseline after the manifest asset transform and GLTF scene transform have been applied:
root.userData.workflowBaselineTransform = {
position: root.position.toArray(),
rotation: [root.rotation.x, root.rotation.y, root.rotation.z],
scale: root.scale.toArray(),
};For every canonical selection snapshot, update in this order:
- load every asset in
state.selectedMeshesthat is not already loaded; - set root visibility strictly from membership in
state.selectedMeshes; - reset all changed transform targets to their stored baseline;
- apply active transforms in the authored order returned by
state.selectedTransforms;
- apply shaders/materials;
- commit only if the selection generation is still current.
The Runtime transform values are already runtime values. Position is an additive translation in model units, rotation is additive in radians, and scale is multiplicative. Do not convert rotation from degrees and do not replace the baseline transform:
function applyRuntimeTransform(root, transform) {
const vector = (value, fallback) => {
if (Array.isArray(value)) {
return fallback.map((item, index) =>
Number.isFinite(Number(value[index])) ? Number(value[index]) : item);
}
const source = value && typeof value === "object" ? value : {};
return ["x", "y", "z"].map((key, index) =>
Number.isFinite(Number(source[key])) ? Number(source[key]) : fallback[index]);
};
if (transform.position) {
root.position.add(new THREE.Vector3(...vector(transform.position, [0, 0, 0])));
}
if (transform.rotation) {
const [x, y, z] = vector(transform.rotation, [0, 0, 0]);
root.rotation.x += x;
root.rotation.y += y;
root.rotation.z += z;
}
if (transform.scale) {
root.scale.multiply(new THREE.Vector3(...vector(transform.scale, [1, 1, 1])));
}
}Apply transforms to the loaded asset root, not independently to every child mesh, unless an authored target explicitly identifies a separately loaded root. Applying the same transform to both root and children doubles movement and can make geometry appear to disappear.
Missing vector components use neutral defaults: 0 for position and rotation, 1 for scale. Never default scale to zero. Do not write visible = false during reset, do not remove transformed roots from the scene, and do not fit or reset the camera as a side effect of a selection transform.
When several transforms target one root, reset once and apply each active transform sequentially in canonical authored order. On the next selection, reset to baseline again before applying the new active list; transforms must never accumulate across selections.
Increment a selection generation for every user change. Before committing asynchronously loaded geometry, shader data, or textures, confirm it still belongs to the newest generation. Dispose stale results so an older request cannot overwrite the current configuration.
Viewer toolbar
The toolbar is presentation state and must remain independent of canonical configuration state. Create it once for the viewer/scene controller. Do not recreate it after an option, shader, mesh, or transform change.
Use the ordered tool allowlist from manifest.clientUx.presentation.controls.tools when it is present. Ignore unknown IDs. An explicit empty array means no toolbar tools. When no allowlist is supplied, the complete supported set is:
| Tool ID | Label | Required behavior |
|---|---|---|
free | Free Mode | Enable rotate, pan, and zoom together |
rotate | Orbit / Rotate | Enable rotation only |
pan | Pan | Enable panning only |
zoom | Zoom | Enable dolly/zoom only |
views | Camera Views | Menu for left, front, right, top, and bottom views |
grid | Toggle Grid | Toggle a grid sized from current visible model bounds |
autoRotate | Auto-Rotate | Toggle orbit-control auto rotation without changing selection |
dimensions | Show Dimensions | Toggle width, height, and depth for visible configured geometry |
pathtrace | Advanced Rendering | Asynchronously toggle path tracing when supported |
fit | Reset Scene | Fit the camera to the current visible configured model |
fullscreen | Fullscreen | Enter or exit fullscreen for the viewer container |
savePreview | Export Image | Download a PNG of the current view |
saveModel | Download Model | Menu for GLB, FBX, OBJ, and DXF export |
ar | View in Space | Emit an application AR request for the supported AR asset flow |
free, rotate, pan, and zoom form one exclusive interaction-mode group. grid, autoRotate, dimensions, pathtrace, and fullscreen are toggle state. fit, savePreview, and ar are actions. views and saveModel own menus.
The scene-controller interface is:
type ViewerToolbarScene = {
setInteractionMode(mode: "free" | "rotate" | "pan" | "zoom"): void;
setCameraView(view: "left" | "front" | "right" | "top" | "bottom"): void;
toggleGrid(force?: boolean): boolean;
toggleAutoRotate(force?: boolean): boolean;
toggleDimensions(force?: boolean): boolean;
togglePathtrace(force?: boolean): Promise<boolean>;
fit(): void;
exportImage(): void;
exportModel(format: "glb" | "fbx" | "obj" | "dxf", name: string): Promise<void>;
};Interaction modes map directly to OrbitControls:
function setInteractionMode(mode) {
const free = mode === "free";
controls.enableRotate = free || mode === "rotate";
controls.enablePan = free || mode === "pan";
controls.enableZoom = free || mode === "zoom";
}Camera views target the current visible model bounding sphere. Left uses [-1, 0, 0], front [0, 0, 1], right [1, 0, 0], top [0, 1, 0.0001], and bottom [0, -1, 0.0001]. Update the camera up vector for top/bottom views and preserve projection settings.
Grid and dimension bounds use visible configured asset roots only. Dimensions display width, height, and depth in meters and rebuild after a canonical mesh or transform change while the dimensions toggle remains active. Dimension labels must follow camera movement and be disposed with their geometries, materials, DOM overlay, and listeners.
fit uses the current visible configured model bounds. It is an explicit user action; configuration and transform updates must not invoke it automatically.
Path tracing is optional and capability-gated. Disable its button while a toggle is pending, reflect the resolved state with aria-pressed, and report a sanitized pathtrace-error without changing configuration. Unsupported active materials/features use the documented raster fallback rather than falsely claiming an advanced render.
Fullscreen state synchronizes from the document fullscreenchange event, including user Escape and browser rejection. Do not assume a request succeeded when updating aria-pressed.
Image export explicitly renders the current scene/camera, obtains a PNG blob, downloads it through a temporary object URL, and revokes that URL. Model export uses the current configured visible product and sanitizes the authored publication label for its download name. Export errors do not mutate selection.
The AR button does not fabricate a file or expose a storage path. It emits an application action containing stable publication, revision, and selection identity. The server performs the documented AR request and returns an authorized temporary result. Disable or omit AR when its capability is absent.
Group tools visually as camera, visual, and action groups. Desktop may collapse to a single aperture control and expand on hover or focus. Every configured tool must remain accessible without hover. On narrow/touch screens, use a horizontal scroller, overflow menu, or bottom sheet; do not silently hide all non-AR tools.
Accessibility requirements:
- the container uses
role="toolbar"and an accessible label; - every button has a visible or accessible name and square touch target;
- exclusive modes and toggles use accurate
aria-pressed; - menus expose expanded state, keyboard navigation, Escape close, and focus
return;
- hover, focus, active, disabled, pending, and error states are distinguishable;
- reduced-motion preferences disable nonessential toolbar animation;
- toolbar operation does not trap focus or steal canvas keyboard controls.
Keep toolbar state in the Redux presentation slice or viewer controller, never the configurator selection slice. Store only serializable values such as active interaction mode and toggle state; keep controls, scene, exporters, renderers, and DOM elements outside Redux.
Feature-detect Fullscreen, path tracing, model exporters, image blob support, and AR before enabling their controls. Do not render a control that looks functional while silently no-oping. Preserve active modes/toggles through selection changes, except dimensions may rebuild its measurement objects from the new visible bounds.
On viewer disposal, remove toolbar DOM, menus, document/fullscreen listeners, focus/keyboard handlers, dimension overlays, object URLs, and pending toolbar operation callbacks.
Selection transition and option focus parity
Custom viewers implementing the manifest presentation flags must preserve the same boundaries as Core. selectionTransitions is cosmetic and cannot delay or change the newest canonical selection commit. For zoomToOption, union the post-transform world-space bounding boxes of all IDs in the clicked option's selectedMeshes array, project all eight corners into the requested or current camera direction, and fit that complete box. Do not substitute the aggregate state.selectedMeshes, labels, or mesh-name matching. If the option contributes no meshes, preserve the camera. Respect reduced motion and cancel stale camera or opacity animation generations.
Conditional product features
Do not implement individual product features as local viewer booleans or by matching scene-object names. Arms, headrests, bases, mechanisms, finishes, upholstery, accessories, dependencies, geometry, transforms, and materials are all authored Expander data using one canonical selection pipeline.
Render active branches in manifest order with authored labels. Send the selected opaque option ID to session.select(branchId, optionId), then replace the Redux snapshot with the complete returned state. The session owns branch ownership, dependencies, defaults, exclusions, and Match Key behavior. Never locally infer which dependent control should appear or which option it should select.
Use returned selectedMeshes as the entire visibility truth. Options contribute or omit authored assets through the snapshot; do not search names or labels to decide visibility. Apply returned transforms once to matched roots from baseline and resolved shaders only to authored targets. A relationship between component materials exists only when the resolved payload expresses it; the UI must not copy materials because labels seem related.
Every feature change preserves camera, product presentation, toolbar, and other viewer state. Test structural add/remove transitions, alternative choices, nested branch activation, defaults, exclusions, Match Keys, multi-component contributions, transforms, targeted materials, and rapid newest-wins changes using real manifest IDs.
Fabric, leather, and other exclusive material families
The manifest can contain definitions for several alternative material-family branches even though only one is active. Build the visible list by filtering manifest branches through session.state.activeBranchIds; the existence of a branch in manifest.branches does not make it visible.
For newly compiled manifests, authored ownership wins. If a migrated material group has no authored owner edge, the compiler populates ownerChoiceIds by matching singular, case-insensitive material-family identity, such as Leathers to Leather. Older manifests can still contain an empty ownerChoiceIds; the canonical session remains backward compatible by using material-group identity and Match Keys. Consumers must use the session result and must not infer or patch owner links themselves.
After the controlling family changes, call session.select and atomically use its returned snapshot. Render only the returned active family branch. Do not keep an inactive family's grid mounted, merge its options with the active family, infer the family from labels, or let inactive options contribute shaders. Equivalent migrated routes and stale route selections are internal resolver concerns synchronized through Match Keys.
Redux stores the canonical activeBranchIds and selections. Derive visible branches from them on every snapshot, key controls and options by opaque IDs, and avoid a second cached list in component state. This prevents fabric and leather grids from occupying the same region during a transition.
Resolve swatches only for options in the active branch. Missing preview data uses Preview unavailable; never borrow an inactive family's image. Generation guards cover family transitions so stale shader or image responses cannot reintroduce the previous family or briefly apply its material.
The active configuration picker has no cross-family All mode. It shows only the family branch returned in activeBranchIds. A separate catalog browser may browse multiple families, but it is not the current-configuration option control and contributes no selections or shaders until an authored family choice activates them.
Signed URL handling
Signed URLs are short-lived delivery capabilities:
- keep them in memory only;
- never commit, persist, print, or include them in diagnostics;
- refetch the manifest, shader, or mesh-path response after expiration;
- retry an expired asset only once after refresh;
- never parse a signed URL to derive a storage path.
Access to an authorized publication includes its referenced runtime assets. Direct access to source texture boards is neither required nor implied.
Acceptance checks
Before release, verify:
- controls use real manifest IDs and canonical dependency behavior;
- mesh visibility changes with structural options;
- shaders affect only their authored targets;
- base color, normal, and scalar maps use the correct color spaces;
- tiling, rotation, and flips match the material payload;
- selection changes preserve camera and product presentation;
- transform activation retains its contributed meshes and moves only authored
target roots without double application or accumulation;
- delayed stale assets cannot overwrite the newest selection;
- browser traffic contains no
x-vertcie-key; - logs contain no API key or signed URL.