Every shareable setting is self-contained and registered once. The registry is the single source of truth for UI generation, defaults, validation, and URL persistence.
Every setting is described by a single object.
type SettingDefinition<T> = {
id: string;
label: string;
type:
| 'boolean'
| 'number'
| 'select'
| 'color';
defaultValue: T;
serialize(value: T): string;
deserialize(value: string | null): T;
createControl(
value: T,
onChange: (value: T) => void
): HTMLElement;
};Example:
const buttonPositionSetting: SettingDefinition<string> = {
id: 'btn',
label: 'Button Position',
type: 'select',
defaultValue: 'tl',
serialize: value => value,
deserialize: value => {
if (
value === 'tl' ||
value === 'tr' ||
value === 'bl' ||
value === 'br'
) {
return value;
}
return 'tl';
},
createControl(value, onChange) {
const select = document.createElement('select');
[
['tl', 'Top Left'],
['tr', 'Top Right'],
['bl', 'Bottom Left'],
['br', 'Bottom Right'],
].forEach(([id, label]) => {
const option = document.createElement('option');
option.value = id;
option.textContent = label;
select.append(option);
});
select.value = value;
select.onchange = () => onChange(select.value);
return select;
},
};All settings live in one registry.
export const SETTINGS = [
buttonPositionSetting,
// future settings...
] as const;The panel is generated entirely from this registry.
for (const setting of SETTINGS) {
createSettingRow(setting);
}No hand-written UI per setting.
Current values are stored separately.
type SettingsState = Record<string, unknown>;
const settings: SettingsState = {};Initialization:
for (const def of SETTINGS) {
settings[def.id] = def.defaultValue;
}Each setting owns exactly one query parameter.
Rule:
setting.id === query parameter name
Example:
?btn=tr
for
buttonPosition = "top-right"function settingsToSearchParams(): URLSearchParams {
const params = new URLSearchParams();
for (const def of SETTINGS) {
const value = settings[def.id];
if (value === def.defaultValue) {
continue;
}
params.set(
def.id,
def.serialize(value)
);
}
return params;
}Default values are omitted.
So:
?btn=tl
becomes:
because tl is the default.
Cleaner URLs.
function loadSettings(
params: URLSearchParams
) {
for (const def of SETTINGS) {
settings[def.id] =
def.deserialize(
params.get(def.id)
);
}
}Each setting validates itself.
Invalid values fall back to defaults.
Example:
?btn=potato
becomes
buttonPosition = 'tl';Camera is special because it is not a UI setting.
We store it separately.
?cam=45,30,3.2
Meaning:
theta
phi
radius
Example:
?cam=45,30,2.7
This keeps camera state compact.
The model setting is registered in the settings registry like any other configurable setting.
?model=skull.obj
It uses the standard id: 'model' and its createControl renders a grid
of buttons, one per registered model, each showing a stylised SVG thumbnail.
The model setting is treated specially only during serialization — it is
handled by the explicit model parameter in serializeState() rather than
the generic settings loop, to keep the URL format clean and avoid duplicate
query parameters.
?v=1
&model=hand.obj
&cam=45,30,2.5
&btn=br
Compact version:
?v=1&model=hand.obj&cam=45,30,2.5&btn=br
Suppose later you add:
Background Color
You create:
const backgroundColorSetting = {
id: 'bg',
label: 'Background',
type: 'color',
defaultValue: '#ffffff',
serialize: v => v,
deserialize: v => v ?? '#ffffff',
};Add it:
SETTINGS.push(backgroundColorSetting);Immediately you get:
- UI control
- URL loading
- URL saving
- default handling
- sharing support
No URL code changes.
To add a new model to the model selection setting:
- Place the model file in
/public/models/. - Add a
ModelEntrytosrc/models/registry.tswith:id— the filename (e.g."hand.obj")label— display namethumbnail— inline SVG string for the button
- Optionally add credit metadata in
src/models/credits.ts.
No registry or URL code changes needed — the model setting automatically picks up new entries from the models registry.