From 5dd8f1aeaf43f32c56979d9189f6f95279497bd7 Mon Sep 17 00:00:00 2001 From: Vincent Fretin Date: Sat, 4 Jul 2026 10:21:10 +0200 Subject: [PATCH 1/9] Treat the material property type like selector types A-Frame's upcoming `material` property type (aframevr/aframe#5846) references an asset by selector (`#myMaterial`) or an inline `material(...)` definition. Edit it as a raw string committed on blur, like selector/selectorAll: committing on each keystroke would parse partial selectors and detach the entity from its shared material while typing. Co-Authored-By: Claude Fable 5 --- src/components/components/PropertyRow.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/components/PropertyRow.js b/src/components/components/PropertyRow.js index a5fd51074..04fb10e1d 100644 --- a/src/components/components/PropertyRow.js +++ b/src/components/components/PropertyRow.js @@ -68,7 +68,12 @@ export default class PropertyRow extends React.Component { getWidget() { const props = this.props; const type = this.getType(); - const isSelectorType = type === 'selector' || type === 'selectorAll'; + // The material type behaves like a selector (`#myMaterial` or an inline + // `material(...)` definition): edit the raw string and commit on blur so + // partial selectors typed by the user are not committed keystroke by + // keystroke (which would detach the entity from its shared material). + const isSelectorType = + type === 'selector' || type === 'selectorAll' || type === 'material'; const value = isSelectorType ? props.entity.getDOMAttribute(props.componentname)?.[props.name] From 92b345ddf022efae4780eea246206a6e1be1a48b Mon Sep 17 00:00:00 2001 From: Vincent Fretin Date: Sat, 4 Jul 2026 10:34:08 +0200 Subject: [PATCH 2/9] Add material asset entities to the example scene Two assets (PBR and textured) shared across entities, plus an inline material(...) definition, to test the material property type. Requires an A-Frame build with aframevr/aframe#5846; with older builds these entities render with a default material and a warning. Co-Authored-By: Claude Fable 5 --- examples/index.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/examples/index.html b/examples/index.html index c14923230..410349c94 100644 --- a/examples/index.html +++ b/examples/index.html @@ -77,6 +77,8 @@ + + @@ -93,6 +95,12 @@ + + + + + + From 79cdbb31bd1fedc5376ff5a6aebf5675bd2651ab Mon Sep 17 00:00:00 2001 From: Vincent Fretin Date: Sat, 4 Jul 2026 10:45:17 +0200 Subject: [PATCH 3/9] Add material assets modal: picker and live editor for The material property type now renders a MaterialWidget: the reference string (committed on blur) plus a swatch previewing the referenced material's color/texture. Clicking the swatch opens a Material Assets modal that: - lists every in the scene with a color/texture swatch, - applies the selected asset to the property (USE SELECTED or double-click), - edits the selected material's properties (schema-driven widgets, shader read-only) live for every entity sharing it, - creates new assets under . Co-Authored-By: Claude Fable 5 --- src/components/Main.js | 26 +++ src/components/components/PropertyRow.js | 5 + src/components/modals/ModalMaterials.js | 280 +++++++++++++++++++++++ src/components/widgets/MaterialWidget.js | 151 ++++++++++++ src/style/index.styl | 1 + src/style/materialModal.styl | 70 ++++++ 6 files changed, 533 insertions(+) create mode 100644 src/components/modals/ModalMaterials.js create mode 100644 src/components/widgets/MaterialWidget.js create mode 100644 src/style/materialModal.styl diff --git a/src/components/Main.js b/src/components/Main.js index ae09430db..1d3f16828 100644 --- a/src/components/Main.js +++ b/src/components/Main.js @@ -4,6 +4,7 @@ import { AwesomeIcon } from './AwesomeIcon'; import Events from '../lib/Events'; import ComponentsSidebar from './components/Sidebar'; import ModalTextures from './modals/ModalTextures'; +import ModalMaterials from './modals/ModalMaterials'; import ModalHelp from './modals/ModalHelp'; import ModalSponsor from './modals/ModalSponsor'; import SceneGraph from './scenegraph/SceneGraph'; @@ -22,6 +23,7 @@ export default class Main extends React.Component { isHelpOpen: false, isModalSponsorOpen: false, isModalTexturesOpen: false, + isModalMaterialsOpen: false, sceneEl: AFRAME.scenes[0], visible: { scenegraph: true, @@ -76,6 +78,17 @@ export default class Main extends React.Component { }.bind(this) ); + Events.on( + 'openmaterialsmodal', + function (selectedMaterial, materialOnClose) { + this.setState({ + selectedMaterial: selectedMaterial, + isModalMaterialsOpen: true, + materialOnClose: materialOnClose + }); + }.bind(this) + ); + Events.on('entityselect', (entity) => { this.setState({ entity: entity }); }); @@ -100,6 +113,13 @@ export default class Main extends React.Component { } }; + onModalMaterialsClose = (value) => { + this.setState({ isModalMaterialsOpen: false }); + if (this.state.materialOnClose) { + this.state.materialOnClose(value); + } + }; + openModalSponsor = () => { this.setState({ isModalSponsorOpen: true }); }; @@ -225,6 +245,12 @@ export default class Main extends React.Component { selectedTexture={this.state.selectedTexture} onClose={this.onModalTextureOnClose} /> + ); } diff --git a/src/components/components/PropertyRow.js b/src/components/components/PropertyRow.js index 04fb10e1d..e45178fca 100644 --- a/src/components/components/PropertyRow.js +++ b/src/components/components/PropertyRow.js @@ -9,6 +9,7 @@ import CopyToClipboardButton from '../CopyToClipboardButton'; import BooleanWidget from '../widgets/BooleanWidget'; import ColorWidget from '../widgets/ColorWidget'; import InputWidget from '../widgets/InputWidget'; +import MaterialWidget from '../widgets/MaterialWidget'; import NumberWidget from '../widgets/NumberWidget'; import SelectWidget from '../widgets/SelectWidget'; import TextureWidget from '../widgets/TextureWidget'; @@ -116,6 +117,10 @@ export default class PropertyRow extends React.Component { if (type === 'map') { return ; } + if (type === 'material') { + // widgetProps contains onBlur (material is a selector-like type). + return ; + } switch (type) { case 'number': { diff --git a/src/components/modals/ModalMaterials.js b/src/components/modals/ModalMaterials.js new file mode 100644 index 000000000..76b23ed88 --- /dev/null +++ b/src/components/modals/ModalMaterials.js @@ -0,0 +1,280 @@ +/* eslint-disable no-prototype-builtins */ +import React from 'react'; +import PropTypes from 'prop-types'; +import Modal from './Modal'; +import BooleanWidget from '../widgets/BooleanWidget'; +import ColorWidget from '../widgets/ColorWidget'; +import InputWidget from '../widgets/InputWidget'; +import NumberWidget from '../widgets/NumberWidget'; +import SelectWidget from '../widgets/SelectWidget'; +import Vec2Widget from '../widgets/Vec2Widget'; + +/** + * Modal listing the assets of the scene. + * + * Acts both as a picker (when opened from the material property widget, the + * selected material can be applied to the property) and as a live editor: + * property changes are set on the element, updating every entity + * sharing the material. + */ +export default class ModalMaterials extends React.Component { + static propTypes = { + isOpen: PropTypes.bool, + onClose: PropTypes.func, + pickEnabled: PropTypes.bool, + selectedMaterial: PropTypes.string + }; + + constructor(props) { + super(props); + this.state = { + isOpen: this.props.isOpen, + materials: [], + selectedEl: null + }; + } + + static getDerivedStateFromProps(props, state) { + if (state.isOpen !== props.isOpen) { + return { isOpen: props.isOpen }; + } + return null; + } + + componentDidUpdate(prevProps) { + if (this.props.isOpen && !prevProps.isOpen) { + this.refresh(); + } + } + + refresh() { + const materials = Array.from(document.querySelectorAll('a-material')); + const value = this.props.selectedMaterial; + let selectedEl = null; + if (value) { + if (value[0] === '#') { + selectedEl = materials.find((el) => el.id === value.substring(1)); + } else { + selectedEl = materials.find((el) => el.inlineString === value); + } + } + this.setState({ + materials: materials, + selectedEl: selectedEl || materials[0] || null + }); + } + + onClose = () => { + if (this.props.onClose) { + this.props.onClose(); + } + }; + + pick = () => { + const el = this.state.selectedEl; + if (!el || !this.props.onClose) return; + this.props.onClose(el.id ? '#' + el.id : el.inlineString); + }; + + createMaterial = () => { + let n = 1; + while (document.getElementById('material-' + n)) { + n++; + } + const el = document.createElement('a-material'); + el.id = 'material-' + n; + const sceneEl = AFRAME.scenes[0]; + let assetsEl = sceneEl.querySelector('a-assets'); + if (!assetsEl) { + assetsEl = document.createElement('a-assets'); + sceneEl.appendChild(assetsEl); + } + assetsEl.appendChild(el); + this.setState({ + materials: Array.from(document.querySelectorAll('a-material')), + selectedEl: el + }); + }; + + updateProperty = (name, value) => { + const el = this.state.selectedEl; + const propDef = el.schema[name]; + let stringValue; + if (value === null || value === undefined) { + stringValue = ''; + } else if (typeof value === 'object') { + stringValue = propDef.stringify(value); + } else { + stringValue = String(value); + } + el.setAttribute(name, stringValue); + // Attribute changes are picked up by a MutationObserver (asynchronous); + // apply synchronously so swatches and dependent widgets refresh right away. + el.attributeChangedCallback(name.toLowerCase(), null, stringValue); + this.forceUpdate(); + }; + + getMaterialTitle(el) { + return el.id ? '#' + el.id : el.inlineString || '(inline)'; + } + + renderSwatch(el) { + let color = '#888'; + let imageSrc = null; + const material = el.getMaterial(); + if (material && material.color) { + color = '#' + material.color.getHexString(); + } + const image = material && material.map && material.map.image; + if (image && image.src) { + imageSrc = image.src; + } + return ( + + {imageSrc ? : null} + + ); + } + + renderMaterialsList() { + const materials = this.state.materials; + if (materials.length === 0) { + return

No <a-material> assets in the scene.

; + } + return ( +
    + {materials.map((el, idx) => ( +
  • this.setState({ selectedEl: el })} + onDoubleClick={this.props.pickEnabled ? this.pick : undefined} + title={this.getMaterialTitle(el)} + > + {this.renderSwatch(el)} + {this.getMaterialTitle(el)} +
  • + ))} +
+ ); + } + + renderPropertyRow(el, key) { + const propDef = el.schema[key]; + const value = el.data[key]; + const id = 'materialasset:' + key; + const onChange = (widgetName, widgetValue) => + this.updateProperty(key, widgetValue); + const widgetProps = { id: id, name: key, onChange: onChange, value: value }; + let widget; + + if (key === 'shader') { + // Shader cannot be changed after the material is created. + widget = {el.data.shader}; + } else if (propDef.oneOf && propDef.oneOf.length > 0) { + widget = ; + } else if (propDef.type === 'map') { + // Commit on blur; a partial URL or selector is not worth loading. + widget = ( + + ); + } else { + switch (propDef.type) { + case 'number': { + widget = ( + + ); + break; + } + case 'int': + case 'time': { + widget = ; + break; + } + case 'vec2': { + widget = ; + break; + } + case 'color': { + widget = ; + break; + } + case 'boolean': { + widget = ; + break; + } + default: { + widget = ( + + ); + } + } + } + + return ( +
+ + {widget} +
+ ); + } + + renderEditor() { + const el = this.state.selectedEl; + if (!el || !el.schema) return null; + const keys = Object.keys(el.schema).sort(); + return keys.map((key) => this.renderPropertyRow(el, key)); + } + + render() { + return ( + +
+
+ {this.renderMaterialsList()} +
+ + {this.props.pickEnabled && ( + + )} +
+
+
{this.renderEditor()}
+
+
+ ); + } +} diff --git a/src/components/widgets/MaterialWidget.js b/src/components/widgets/MaterialWidget.js new file mode 100644 index 000000000..61aa43e48 --- /dev/null +++ b/src/components/widgets/MaterialWidget.js @@ -0,0 +1,151 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Events from '../../lib/Events'; + +/** + * Widget for the `material` property type: a reference to an asset + * (e.g., `#myMaterial`) or an inline `material(...)` definition. + * + * The value is edited as a raw string and committed on blur, like selector types. + * The swatch opens the materials modal to pick another material asset or edit the + * existing ones. + */ +export default class MaterialWidget extends React.Component { + static propTypes = { + id: PropTypes.string, + name: PropTypes.string.isRequired, + onBlur: PropTypes.func, + value: PropTypes.string + }; + + static defaultProps = { + value: '' + }; + + constructor(props) { + super(props); + this.state = { value: this.props.value || '' }; + this.input = React.createRef(); + this.canvas = React.createRef(); + } + + componentDidMount() { + this.paintSwatch(); + } + + componentDidUpdate(prevProps) { + if (this.props.value !== prevProps.value) { + this.setState({ value: this.props.value || '' }); + } + this.paintSwatch(); + } + + resolveMaterialEl = (value) => { + if (!value) return null; + if (value[0] === '#') { + const el = document.getElementById(value.substring(1)); + return el && el.isMaterialAsset ? el : null; + } + if (value.indexOf('material(') === 0) { + return ( + Array.from(document.querySelectorAll('a-material')).find( + (el) => el.inlineString === value + ) || null + ); + } + return null; + }; + + paintSwatch = () => { + const canvas = this.canvas.current; + if (!canvas) return; + const context = canvas.getContext('2d'); + context.clearRect(0, 0, canvas.width, canvas.height); + + const materialEl = this.resolveMaterialEl(this.state.value); + if (!materialEl) return; + const material = materialEl.getMaterial(); + if (!material) return; + + if (material.color) { + context.fillStyle = '#' + material.color.getHexString(); + context.fillRect(0, 0, canvas.width, canvas.height); + } + const image = material.map && material.map.image; + if (image && image.width > 0) { + try { + context.drawImage(image, 0, 0, canvas.width, canvas.height); + } catch (e) { + // Some sources (e.g., detached video) cannot be drawn; keep the color. + } + } + }; + + commit = (value) => { + if (this.props.onBlur) { + this.props.onBlur(this.props.name, value); + } + }; + + onChange = (event) => { + this.setState({ value: event.target.value }); + }; + + onBlur = (event) => { + this.commit(event.target.value); + this.paintSwatch(); + }; + + onKeyDown = (event) => { + event.stopPropagation(); + // enter + if (event.keyCode === 13) { + this.input.current.blur(); + } + }; + + openDialog = () => { + Events.emit('openmaterialsmodal', this.state.value, (value) => { + if (value !== undefined && value !== null && value !== this.state.value) { + this.setState({ value: value }); + this.commit(value); + } + // The material may have been edited in the modal without changing the + // reference; repaint once the modal closes. + setTimeout(this.paintSwatch, 0); + }); + }; + + render() { + const materialEl = this.resolveMaterialEl(this.state.value); + const hint = materialEl + ? 'Material asset: ' + + (materialEl.id ? '#' + materialEl.id : materialEl.inlineString) + + '\nClick the swatch to pick or edit materials' + : 'Click the swatch to pick or edit materials'; + + return ( + + + + + ); + } +} diff --git a/src/style/index.styl b/src/style/index.styl index 0981cfc55..1a0422e10 100644 --- a/src/style/index.styl +++ b/src/style/index.styl @@ -57,6 +57,7 @@ body.aframe-inspector-opened @import './help'; @import './select'; @import './textureModal'; + @import './materialModal'; @import './viewport'; @import './widgets'; diff --git a/src/style/materialModal.styl b/src/style/materialModal.styl new file mode 100644 index 000000000..045095c0c --- /dev/null +++ b/src/style/materialModal.styl @@ -0,0 +1,70 @@ +#materialsModal .modal-content + max-width 760px + width calc(100% - 100px) + +.materialsModal + display flex + gap 16px + +.materialsModal .materialsList + display flex + flex-direction column + flex-shrink 0 + width 240px + +.materialsModal .materialsList ul + list-style none + margin 0 + max-height calc(100vh - 320px) + overflow-y auto + padding 0 + +.materialsModal .materialsList li + align-items center + border-radius 2px + cursor pointer + display flex + gap 8px + margin 2px + overflow hidden + padding 6px 8px + +.materialsModal .materialsList li.selected, +.materialsModal .materialsList li:hover + box-shadow 0 0 0 2px var(--color-primary) + +.materialsModal .materialsList li .title + color var(--color-property-defined) + font-weight 600 + overflow hidden + text-overflow ellipsis + white-space nowrap + +.materialsModal .materialsActions + display flex + flex-direction column + gap 4px + margin-top 8px + +.materialSwatch + border 1px solid var(--color-base-content) + border-radius 2px + display inline-block + flex-shrink 0 + height 18px + overflow hidden + width 28px + +.materialSwatch img + display block + height 100% + object-fit cover + width 100% + +.materialsModal .materialsEditor + flex-grow 1 + max-height calc(100vh - 280px) + overflow-y auto + +.materialsModal .materialsEditor .propertyRow + padding 2px 10px 2px 0 From 19f6b4b174f02a91be0b946ba0bacc7db57c6741 Mon Sep 17 00:00:00 2001 From: Vincent Fretin Date: Sat, 4 Jul 2026 10:58:20 +0200 Subject: [PATCH 4/9] Hide ignored material properties when a material asset is used When the material component's `material` property references an asset, all other properties are ignored (the asset fully defines the material), so only show the material property row. Also prevent text selection when double-clicking a material in the modal gallery to apply it. Co-Authored-By: Claude Fable 5 --- src/components/components/Component.js | 9 +++++++++ src/style/materialModal.styl | 1 + 2 files changed, 10 insertions(+) diff --git a/src/components/components/Component.js b/src/components/components/Component.js index 935920787..aca98e475 100644 --- a/src/components/components/Component.js +++ b/src/components/components/Component.js @@ -92,9 +92,18 @@ export default class Component extends React.Component { ); } + // When the material component uses a material asset (material property set), + // every other property is ignored, entirely defined by the ; only + // show the material property. + const usesMaterialAsset = + this.props.name === 'material' && !!componentData.data.material; + return Object.keys(componentData.schema) .sort() .filter((propertyName) => shouldShowProperty(propertyName, componentData)) + .filter( + (propertyName) => !usesMaterialAsset || propertyName === 'material' + ) .map((propertyName) => (