aframe-lsystem-component
Version:
L-System/LSystem component for A-Frame to draw 3D turtle graphics. Using Lindenmayer as backend.
534 lines (413 loc) • 19.2 kB
JavaScript
if (typeof AFRAME === 'undefined') {
throw new Error('Component attempted to register before AFRAME was available.');
}
import LSystem from 'lindenmayer';
// As we use webpack for compiling the source, it's used to bundle the
// web worker into a blob via: https://github.com/webpack/worker-loader
import LSystemWorker from 'worker-loader?inline&fallback=false!./worker.js';
import './primitives/a-lsystem.js';
/**
* Lindenmayer-System component for A-Frame.
*/
function parseFromTo(value) {
let flatResult = value.split(/(\w)\s*:\s*/).filter(part => part.length !== 0);
let result = [];
for (var i = 0; i < flatResult.length; i+=2) {
result.push([flatResult[i], flatResult[i+1]]);
}
return result;
}
AFRAME.registerComponent('lsystem', {
schema: {
axiom: {
type: 'string',
default: 'F'
},
productions: {
default: 'F:FF',
// return an array of production tuples ([[from, to], ['F', 'F+F']])
parse: (value) => parseFromTo(value).map(([from, to]) => [from, to.replace(/\s/g, '')])
},
// A: blue line, red line, yellow line B: red line
segmentMixins: {
type: 'string',
parse: function (value) {
let mixinsForSymbol = new Map();
let result = parseFromTo(value);
for (let [from, to] of result) {
to = to.replace(/[\[\]]/g, '').split(',');
mixinsForSymbol.set(from, to);
}
return mixinsForSymbol;
}
},
iterations: {
type: 'int',
default: 1
},
angle: {
default: 90.0
},
translateAxis: {
type: 'string',
default: 'y',
parse: function(value) {
value = value.toLowerCase();
if (value === 'x') {
return new THREE.Vector3(1, 0, 0);
} else if (value === 'y') {
return new THREE.Vector3(0, 1, 0);
} else if (value === 'z') {
return new THREE.Vector3(0, 0, 1);
} else {
throw new Error('translateAxis has to be a string: "x", "y" or "z"');
}
}
},
scaleFactor: {
default: 1.0
},
dynamicSegmentLength: {
default: true
},
mergeGeometries: {
type: 'boolean',
default: true
},
functionsInProductions: {
type: 'boolean',
default: true
}
},
/**
* Called once when component is attached. Generally for initial setup.
*/
init: function () {
this.sceneEl = document.querySelector('a-scene');
let self = this;
this.initWorker();
this.X = new THREE.Vector3(1, 0, 0);
this.Y = new THREE.Vector3(0, 1, 0);
this.Z = new THREE.Vector3(0, 0, 1);
this.xPosRotation = new THREE.Quaternion();
this.xNegRotation = new THREE.Quaternion();
this.yPosRotation = new THREE.Quaternion();
this.yNegRotation = new THREE.Quaternion();
this.zPosRotation = new THREE.Quaternion();
this.zNegRotation = new THREE.Quaternion();
this.yReverseRotation = new THREE.Quaternion();
this.xPosRotation = new THREE.Quaternion();
this.xNegRotation = new THREE.Quaternion();
this.yPosRotation = new THREE.Quaternion();
this.yNegRotation = new THREE.Quaternion();
this.zPosRotation = new THREE.Quaternion();
this.zNegRotation = new THREE.Quaternion();
this.yReverseRotation = new THREE.Quaternion();
this.segmentLengthFactor = 1.0;
this.transformationSegment = new THREE.Object3D();
this.transformationSegmentTemplate = this.transformationSegment.clone();
let scaleFactor = self.data.scaleFactor;
this.colorIndex = 0;
this.lineWidth = 0.0005;
this.lineLength = 0.125;
this.LSystem = new LSystem({
axiom: 'F',
productions: {'F': 'F'},
finals: {
/* As a default F is already defined as final, new ones get added automatically
by parsing the segment mixins. If no segment mixin for any symbol is defined
it wont get a final function and therefore not render.
*/
'+': () => { self.transformationSegment.quaternion.multiply(self.yPosRotation);},
'-': () => { self.transformationSegment.quaternion.multiply(self.yNegRotation);},
'&': () => { self.transformationSegment.quaternion.multiply(self.zNegRotation);},
'^': () => { self.transformationSegment.quaternion.multiply(self.zPosRotation);},
'\\': () =>{ self.transformationSegment.quaternion.multiply(self.xNegRotation);},
'<': () => { self.transformationSegment.quaternion.multiply(self.xNegRotation);},
'/': () => { self.transformationSegment.quaternion.multiply(self.xPosRotation);},
'>': () => { self.transformationSegment.quaternion.multiply(self.xPosRotation);},
'|': () => { self.transformationSegment.quaternion.multiply(self.yReverseRotation);},
'!': () => {
self.segmentLengthFactor *= scaleFactor;
self.transformationSegment.scale.set(
self.transformationSegment.scale.x *= scaleFactor, self.transformationSegment.scale.y *= scaleFactor, self.transformationSegment.scale.z *= scaleFactor
);
self.colorIndex++;
},
'\'': () => {
self.segmentLengthFactor *= (1.0 / scaleFactor);
self.transformationSegment.scale.set(
self.transformationSegment.scale.x *= (1.0 / scaleFactor), self.transformationSegment.scale.y *= (1.0 / scaleFactor), self.transformationSegment.scale.z *= (1.0 / scaleFactor)
);
self.colorIndex = Math.max(0, self.colorIndex - 1);
},
'[': () => { self.stack.push(self.transformationSegment.clone()); },
']': () => { self.transformationSegment = self.stack.pop(); }
}
});
},
/**
* Called when component is attached and when component data changes.
* Generally modifies the entity based on the data.
*/
update: function (oldData) {
// var diffData = diff(data, oldData || {});
// console.log(diffData);
// TODO: Check if only angle changed or axiom or productions
//
let self = this;
if (this.data.mergeGeometries === false && this.segmentElementGroupsMap !== undefined) {
for (let segmentElGroup of this.segmentElementGroupsMap.values()) {
segmentElGroup.removeObject3D('mesh');
segmentElGroup.innerHTML = '';
}
}
if (Object.keys(oldData).length === 0) {
this.updateLSystem();
this.updateSegmentMixins();
this.updateTurtleGraphics();
} else {
let visualChange = false;
if ((oldData.axiom && oldData.axiom !== this.data.axiom) || (oldData.iterations && oldData.iterations !== this.data.iterations) || (oldData.productions && JSON.stringify(oldData.productions) !== JSON.stringify(this.data.productions))) {
this.updateLSystem();
visualChange = true;
}
if (oldData.segmentMixins !== undefined && JSON.stringify(Array.from(oldData.segmentMixins.entries())) !== JSON.stringify(Array.from(this.data.segmentMixins.entries())) ) {
this.updateSegmentMixins();
visualChange = true;
}
if (visualChange || oldData.angle && oldData.angle !== this.data.angle) {
this.updateTurtleGraphics();
} else {
// console.log('nothing changed in update?');
// this.updateLSystem();
// this.updateSegmentMixins();
}
}
},
// if this.dynamicSegmentLength===true use this function to set the length
// depending on segments geometries bbox
calculateSegmentLength: function (mixin, geometry) {
if (this.segmentLengthMap.has(mixin)) return this.segmentLengthMap.get(mixin);
geometry.computeBoundingBox();
let segmentLength;
if (this.data.translateAxis.equals(this.X) ) {
segmentLength = Math.abs(geometry.boundingBox.min.x - geometry.boundingBox.max.x);
} else if (this.data.translateAxis.equals(this.Y)) {
segmentLength = Math.abs(geometry.boundingBox.min.y - geometry.boundingBox.max.y);
} else if (this.data.translateAxis.equals(this.Z)) {
segmentLength = Math.abs(geometry.boundingBox.min.z - geometry.boundingBox.max.z);
}
this.segmentLengthMap.set(mixin, segmentLength);
return segmentLength;
},
initWorker: function() {
this.worker = new LSystemWorker();
},
pushSegment: function(symbol) {
let self = this;
let currentQuaternion = self.transformationSegment.quaternion;
let currentPosition = self.transformationSegment.position;
let currentScale = self.transformationSegment.scale;
// Cap colorIndex to maximum mixins defined for the symbol.
let cappedColorIndex = Math.min(this.colorIndex, this.data.segmentMixins.get(symbol).length - 1);
let mixin = this.mixinMap.get(symbol + cappedColorIndex);
if (this.data.mergeGeometries === false) {
let newSegment = document.createElement('a-entity');
newSegment.setAttribute('mixin', mixin);
newSegment.addEventListener('loaded', (e) => {
// Offset child element of object3D, to rotate around end point
// IMPORTANT: It may change that A-Frame puts objects into a group
let segmentLength = self.segmentLengthMap.get(mixin);
newSegment.object3D.children[0].translateOnAxis(self.data.translateAxis, (segmentLength * self.segmentLengthFactor) / 2);
newSegment.object3D.quaternion.copy(currentQuaternion);
newSegment.object3D.position.copy(currentPosition);
newSegment.object3D.scale.copy(currentScale);
},
{once: true});
this.segmentElementGroupsMap.get(symbol + cappedColorIndex).appendChild(newSegment);
} else {
let segmentObject3D = this.segmentObjects3DMap.get(symbol + cappedColorIndex);
let newSegmentObject3D = segmentObject3D.clone();
newSegmentObject3D.matrixAutoUpdate = false;
newSegmentObject3D.quaternion.copy(currentQuaternion);
newSegmentObject3D.position.copy(currentPosition);
newSegmentObject3D.scale.copy(currentScale);
newSegmentObject3D.updateMatrix();
this.mergeGroups.get(symbol + cappedColorIndex).geometry.merge(newSegmentObject3D.geometry, newSegmentObject3D.matrix);
}
let segmentLength = this.segmentLengthMap.get(mixin);
this.transformationSegment.translateOnAxis(this.data.translateAxis, segmentLength * this.segmentLengthFactor);
},
updateLSystem: function () {
let self = this;
// post params to worker
let params = {
axiom: this.data.axiom,
productions: this.data.productions,
iterations: this.data.iterations
};
if (Date.now() - this.worker.startTime > 1000 ) {
// if we got user input, but worker is running for over a second
// terminate old worker and start new one.
this.worker.terminate();
this.initWorker();
}
this.worker.startTime = Date.now();
this.workerPromise = new Promise((resolve, reject) => {
this.worker.onmessage = (e) => {
self.LSystem.setAxiom(e.data.result);
resolve();
};
});
this.worker.postMessage(params);
return this.workerPromise;
},
updateSegmentMixins: function () {
let self = this;
this.el.innerHTML = '';
// Map for remembering the elements holding differnt segment types
this.segmentElementGroupsMap = new Map();
this.mixinMap = new Map();
// Construct a map with keys = `symbol + colorIndex` from data.segmentMixins
for (let [symbol, mixinList] of this.data.segmentMixins) {
for (let i = 0; i < mixinList.length; i++) {
this.mixinMap.set(symbol + i, mixinList[i]);
}
}
// Map for buffering geometries for use in pushSegments()
// when merging geometries ourselves and not by appending a `mixin` attributes,
// as done with `mergeGeometry = false`.
this.segmentObjects3DMap = new Map();
this.segmentLengthMap = new Map();
this.mergeGroups = new Map();
this.mixinPromises = [];
// Collect mixin info by pre-appending segment elements with their mixin
// Then use the generated geometry etc.
if (this.data.segmentMixins && this.data.segmentMixins.length !== 0) {
// Go through every symbols segmentMixins as defined by user
for (let el of this.data.segmentMixins) {
let [symbol, mixinList] = el;
// Set final functions for each symbol that has a mixin defined
this.LSystem.setFinal(symbol, () => {self.pushSegment.bind(self, symbol)();});
// And iterate the MixinList to buffer the segments or calculate segment lengths…
for (let i = 0; i < mixinList.length; i++) {
let mixinColorIndex = i;
let mixin = mixinList[mixinColorIndex];
self.mixinPromises.push(new Promise((resolve, reject) => {
// Save mixinColorIndex for async promise below.
let segmentElGroup = document.createElement('a-entity');
segmentElGroup.setAttribute('id', mixin + '-group-' + mixinColorIndex + Math.floor(Math.random() * 10000));
// TODO: Put it all under this.mergeData
segmentElGroup.setAttribute('geometry', 'buffer', false);
segmentElGroup.setAttribute('mixin', mixin);
segmentElGroup.addEventListener('loaded', function (e) {
let segmentObject = segmentElGroup.getObject3D('mesh').clone();
// Make sure the geometry is actually unique
// AFrame sets the same geometry for multiple entities. As we modify
// the geometry per entity we need to have unique geometry instances.
// TODO: hm, maybe try to use instanced geometry and offset on object?
segmentElGroup.getObject3D('mesh').geometry.dispose();
segmentObject.geometry = (segmentObject.geometry.clone());
let segmentLength = self.calculateSegmentLength(mixin, segmentObject.geometry);
// Do some additional stuff like buffering 3D objects / geometry
// if we want to merge geometries.
if (self.data.mergeGeometries === true) {
// Offset geometry by half segmentLength to get the rotation point right.
let translation = self.data.translateAxis.clone().multiplyScalar((segmentLength * self.segmentLengthFactor)/2);
// IMPORTANT!!!
// TODO: Try to use pivot object instead of translating geometry
// this may help in reusing geometry and not needing to clone it (see above)?
// see: https://github.com/mrdoob/three.js/issues/1364
//and
// see: http://stackoverflow.com/questions/28848863/threejs-how-to-rotate-around-objects-own-center-instead-of-world-center
segmentObject.geometry.translate(translation.x, translation.y, translation.z);
self.segmentObjects3DMap.set(symbol + mixinColorIndex, segmentObject );
}
segmentElGroup.removeObject3D('mesh');
resolve();
}, {once: true});
if (this.segmentElementGroupsMap.has(symbol + mixinColorIndex)) {
let previousElGroup = this.segmentElementGroupsMap.get(symbol + mixinColorIndex);
this.segmentElementGroupsMap.delete(symbol + mixinColorIndex);
this.el.removeChild(previousElGroup);
}
this.segmentElementGroupsMap.set(symbol + mixinColorIndex, segmentElGroup);
this.el.appendChild(segmentElGroup);
}));
}
}
}
},
updateTurtleGraphics: async function() {
await Promise.all([...this.mixinPromises, this.workerPromise]);
// The main segment used for saving transformations (rotation, translation, scale(?))
this.transformationSegment.copy(this.transformationSegmentTemplate);
// set merge groups
if (this.data.mergeGeometries === true)
for (let [id, segmentObject] of this.segmentObjects3DMap) {
this.mergeGroups.set(id, new THREE.Mesh(
new THREE.Geometry(), segmentObject.material
));
}
// We push copies of this.transformationSegment on branch symbols inside this array.
this.stack = [];
let angle = this.data.angle;
// Set quaternions based on angle slider
this.xPosRotation.setFromAxisAngle( this.X, (Math.PI / 180) * angle );
this.xNegRotation.setFromAxisAngle( this.X, (Math.PI / 180) * -angle );
this.yPosRotation.setFromAxisAngle( this.Y, (Math.PI / 180) * angle );
this.yNegRotation.setFromAxisAngle( this.Y, (Math.PI / 180) * -angle );
this.yReverseRotation.setFromAxisAngle( this.Y, (Math.PI / 180) * 180 );
this.zPosRotation.setFromAxisAngle( this.Z, (Math.PI / 180) * angle );
this.zNegRotation.setFromAxisAngle( this.Z, (Math.PI / 180) * -angle );
//
// this.geometry = new THREE.CylinderGeometry(this.lineWidth, this.lineWidth, self.data.lineLength, 3);
// this.geometry.rotateZ((Math.PI / 180) * 90);
// this.geometry.translate( -(this.data.segmentLength/2), 0, 0 );
// for (let face of this.geometry.faces) {
// face.color.setHex(this.colors[colorIndex]);
// }
// this.geometry.colorsNeedUpdate = true;
this.LSystem.final();
// finally set the merged meshes to be visible.
if (this.data.mergeGeometries === true) {
for (let tuple of this.segmentElementGroupsMap) {
let [symbolWithColorIndex, elGroup] = tuple;
let mergeGroup = this.mergeGroups.get(symbolWithColorIndex);
// Remove unused element groups inside our element
if (mergeGroup.geometry.vertices.length === 0) {
this.el.removeChild(elGroup);
} else {
elGroup.setObject3D('mesh', this.mergeGroups.get(symbolWithColorIndex));
elGroup.setAttribute('mixin', this.mixinMap.get(symbolWithColorIndex));
}
}
}
},
/**
* Called when a component is removed (e.g., via removeAttribute).
* Generally undoes all modifications to the entity.
*/
remove: function () {
},
/**
* Called on each scene tick.
*/
tick: function (t) {
// console.log(this.parentEl === undefined);
// console.log('\nTICK\n', t);
},
/**
* Called when entity pauses.
* Use to stop or remove any dynamic or background behavior such as events.
*/
pause: function () {
},
/**
* Called when entity resumes.
* Use to continue or add any dynamic or background behavior such as events.
*/
play: function () {
},
});