diff --git a/src/aframe-streetmix-parsers.js b/src/aframe-streetmix-parsers.js index 2ac9f62a..cdcf9b96 100644 --- a/src/aframe-streetmix-parsers.js +++ b/src/aframe-streetmix-parsers.js @@ -352,7 +352,7 @@ function getBikeLaneMixin(variant) { } function getBusLaneMixin(variant) { - if ((variant === 'colored') | (variant === 'red')) { + if (variant === 'colored' || variant === 'red') { return 'surface-red bus-lane'; } if (variant === 'blue') { diff --git a/src/assets.js b/src/assets.js index e7f666f2..5862e059 100644 --- a/src/assets.js +++ b/src/assets.js @@ -36,6 +36,7 @@ function buildAssetHTML(assetUrl, categories) { + `, people: ` @@ -128,7 +129,7 @@ function buildAssetHTML(assetUrl, categories) { - + @@ -141,50 +142,58 @@ function buildAssetHTML(assetUrl, categories) { `, 'lane-separator': ` - - - - - - - - - + + + + + + + + + + + + + + + + + `, stencils: ` - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + `, 'vehicles-transit': ` - - + + `, @@ -229,7 +238,7 @@ function buildAssetHTML(assetUrl, categories) { - + @@ -383,7 +392,7 @@ Unused assets kept commented here for future reference - + diff --git a/src/components/managed-street.js b/src/components/managed-street.js new file mode 100644 index 00000000..d70cdabc --- /dev/null +++ b/src/components/managed-street.js @@ -0,0 +1,944 @@ +/* global AFRAME */ + +// Orientation - default model orientation is "outbound" (away from camera) +const { segmentVariants } = require('../segments-variants.js'); +const streetmixUtils = require('../tested/streetmix-utils'); +const streetmixParsersTested = require('../tested/aframe-streetmix-parsers-tested'); + +// invoking from js console +/* +userLayersEl = document.getElementById('street-container'); +newStreetEl = document.createElement('a-entity'); +newStreetEl.setAttribute('managed-street', 'sourceType: streetmix-url; sourceValue: https://streetmix.net/kfarr/3/; synchronize: true'); +userLayersEl.append(newStreetEl); +*/ + +AFRAME.registerComponent('managed-street', { + schema: { + width: { + type: 'number' + }, + length: { + type: 'number', + default: 60 + }, + sourceType: { + type: 'string', + oneOf: ['streetmix-url', 'streetplan-url', 'json-blob'] + }, + sourceValue: { + type: 'string' + }, + sourceId: { + type: 'string' + }, + synchronize: { + type: 'boolean', + default: false + }, + showVehicles: { + type: 'boolean', + default: true + }, + showStriping: { + type: 'boolean', + default: true + }, + justifyWidth: { + default: 'center', + type: 'string', + oneOf: ['center', 'left', 'right'] + }, + justifyLength: { + default: 'middle', + type: 'string', + oneOf: ['middle', 'start', 'end'] + } + }, + init: function () { + this.managedEntities = []; + this.pendingEntities = []; + // Bind the method to preserve context + this.refreshFromSource = this.refreshFromSource.bind(this); + }, + update: function (oldData) { + const data = this.data; + const dataDiff = AFRAME.utils.diff(oldData, data); + + if (data.synchronize) { + this.el.setAttribute('managed-street', 'synchronize', false); + this.refreshFromSource(); + } + + const dataDiffKeys = Object.keys(dataDiff); + if ( + dataDiffKeys.length === 1 && + (dataDiffKeys.includes('justifyWidth') || + dataDiffKeys.includes('justifyLength')) + ) { + this.refreshManagedEntities(); + this.applyJustification(); + this.createOrUpdateJustifiedDirtBox(); + } + + if (dataDiffKeys.includes('width')) { + this.createOrUpdateJustifiedDirtBox(); + } + + if (dataDiffKeys.includes('length')) { + this.refreshManagedEntities(); + this.applyLength(); + this.createOrUpdateJustifiedDirtBox(); + } + // if the value of length changes, then we need to update the length of all the child objects + // we need to get a list of all the child objects whose length we need to change + }, + refreshFromSource: function () { + const data = this.data; + if (data.sourceType === 'streetmix-url') { + this.loadAndParseStreetmixURL(data.sourceValue); + } else if (data.sourceType === 'streetplan-url') { + this.refreshFromStreetplanURL(data.sourceValue); + } else if (data.sourceType === 'json-blob') { + this.refreshFromJSONBlob(data.sourceValue); + } + }, + applyLength: function () { + const data = this.data; + const segmentEls = this.managedEntities; + const streetLength = data.length; + + segmentEls.forEach((segmentEl) => { + segmentEl.setAttribute('street-segment', 'length', streetLength); + }); + }, + applyJustification: function () { + const data = this.data; + const segmentEls = this.managedEntities; + const streetWidth = data.width; + const streetLength = data.length; + + // set starting xPosition for width justification + let xPosition = 0; // default for left justified + if (data.justifyWidth === 'center') { + xPosition = -streetWidth / 2; + } + if (data.justifyWidth === 'right') { + xPosition = -streetWidth; + } + // set z value for length justification + let zPosition = 0; // default for middle justified + if (data.justifyLength === 'start') { + zPosition = -streetLength / 2; + } + if (data.justifyLength === 'end') { + zPosition = streetLength / 2; + } + + segmentEls.forEach((segmentEl) => { + if (!segmentEl.getAttribute('street-segment')) { + return; + } + const segmentWidth = segmentEl.getAttribute('street-segment').width; + const yPosition = segmentEl.getAttribute('position').y; + xPosition += segmentWidth / 2; + segmentEl.setAttribute( + 'position', + `${xPosition} ${yPosition} ${zPosition}` + ); + xPosition += segmentWidth / 2; + }); + }, + refreshManagedEntities: function () { + // create a list again of the managed entities + this.managedEntities = Array.from( + this.el.querySelectorAll('[street-segment]') + ); + }, + createOrUpdateJustifiedDirtBox: function () { + const data = this.data; + const streetWidth = data.width; + if (!streetWidth) { + return; + } + const streetLength = data.length; + if (!this.justifiedDirtBox) { + // try to find an existing dirt box + this.justifiedDirtBox = this.el.querySelector('.dirtbox'); + } + if (!this.justifiedDirtBox) { + // create new brown box to represent ground underneath street + const dirtBox = document.createElement('a-box'); + dirtBox.classList.add('dirtbox'); + this.el.append(dirtBox); + this.justifiedDirtBox = dirtBox; + dirtBox.setAttribute('material', `color: ${window.STREET.colors.brown};`); + dirtBox.setAttribute('data-layer-name', 'Underground'); + } + this.justifiedDirtBox.setAttribute('height', 2); // height is 2 meters from y of -0.1 to -y of 2.1 + this.justifiedDirtBox.setAttribute('width', streetWidth); + this.justifiedDirtBox.setAttribute('depth', streetLength - 0.2); // depth is length - 0.1 on each side + + // set starting xPosition for width justification + let xPosition = 0; // default for center justified + if (data.justifyWidth === 'left') { + xPosition = streetWidth / 2; + } + if (data.justifyWidth === 'right') { + xPosition = -streetWidth / 2; + } + + // set z value for length justification + let zPosition = 0; // default for middle justified + if (data.justifyLength === 'start') { + zPosition = -streetLength / 2; + } + if (data.justifyLength === 'end') { + zPosition = streetLength / 2; + } + + this.justifiedDirtBox.setAttribute( + 'position', + `${xPosition} -1 ${zPosition}` + ); + }, + loadAndParseStreetmixURL: async function (streetmixURL) { + const data = this.data; + const streetmixAPIURL = streetmixUtils.streetmixUserToAPI(streetmixURL); + console.log( + '[managed-street] loader', + 'sourceType: `streetmix-url`, setting `streetmixAPIURL` to', + streetmixAPIURL + ); + + try { + console.log('[managed-street] loader', 'GET ' + streetmixAPIURL); + const response = await fetch(streetmixAPIURL); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const streetmixResponseObject = await response.json(); + this.refreshManagedEntities(); + this.remove(); + + // convert units of measurement if necessary + const streetData = streetmixUtils.convertStreetValues( + streetmixResponseObject.data.street + ); + const streetmixSegments = streetData.segments; + + const streetmixName = streetmixResponseObject.name; + + this.el.setAttribute('data-layer-name', 'Street • ' + streetmixName); + const streetWidth = streetmixSegments.reduce( + (streetWidth, segmentData) => streetWidth + segmentData.width, + 0 + ); + this.el.setAttribute('managed-street', 'width', streetWidth); + + const segmentEls = parseStreetmixSegments( + streetmixSegments, + data.showStriping, + data.length, + data.showVehicles + ); + this.el.append(...segmentEls); + + this.pendingEntities = segmentEls; + // for each pending entity Listen for loaded event + for (const entity of this.pendingEntities) { + entity.addEventListener( + 'loaded', + () => { + this.onEntityLoaded(entity); + }, + { once: true } + ); + } + + // Set up a promise that resolves when all entities are loaded + this.allLoadedPromise = new Promise((resolve) => { + this.resolveAllLoaded = resolve; + }); + + // When all entities are loaded, do something with them + this.allLoadedPromise.then(() => { + this.applyJustification(); + this.createOrUpdateJustifiedDirtBox(); + AFRAME.INSPECTOR.selectEntity(this.el); + }); + } catch (error) { + console.error('[managed-street] loader', 'Loading Error:', error); + } + }, + onEntityLoaded: function (entity) { + // Remove from pending set + const index = this.pendingEntities.indexOf(entity); + if (index > -1) { + this.pendingEntities.splice(index, 1); + } + this.managedEntities.push(entity); + // If no more pending entities, resolve the promise + if (this.pendingEntities.length === 0) { + this.resolveAllLoaded(); + } + }, + remove: function () { + this.managedEntities.forEach( + (entity) => entity.parentNode && entity.remove() + ); + this.managedEntities.length = 0; // Clear the array + } +}); + +function getSeparatorMixinId(previousSegment, currentSegment) { + if (previousSegment === undefined || currentSegment === undefined) { + return null; + } + // Helper function to check if a segment type is "lane-ish" + function isLaneIsh(typeString) { + return ( + typeString.slice(typeString.length - 4) === 'lane' || + typeString === 'light-rail' || + typeString === 'streetcar' || + typeString === 'flex-zone' + ); + } + + // If either segment is not lane-ish and not a divider, return null + if ( + (!isLaneIsh(previousSegment.type) && previousSegment.type !== 'divider') || + (!isLaneIsh(currentSegment.type) && currentSegment.type !== 'divider') + ) { + return null; + } + + // Default to solid line + let variantString = 'solid-stripe'; + + // Handle divider cases + if (previousSegment.type === 'divider' || currentSegment.type === 'divider') { + return variantString; + } + + // Get directions from variant strings + const prevDirection = previousSegment.variantString.split('|')[0]; + const currDirection = currentSegment.variantString.split('|')[0]; + + // Check for opposite directions + if (prevDirection !== currDirection) { + variantString = 'solid-doubleyellow'; + + // Special case for bike lanes + if ( + currentSegment.type === 'bike-lane' && + previousSegment.type === 'bike-lane' + ) { + variantString = 'short-dashed-stripe-yellow'; + } + + // Special case for flex zones + if ( + currentSegment.type === 'flex-zone' || + previousSegment.type === 'flex-zone' + ) { + variantString = 'solid'; + } + } else { + // Same direction cases + if (currentSegment.type === previousSegment.type) { + variantString = 'dashed-stripe'; + } + + // Drive lane and turn lane combination + if ( + (currentSegment.type === 'drive-lane' && + previousSegment.type === 'turn-lane') || + (previousSegment.type === 'drive-lane' && + currentSegment.type === 'turn-lane') + ) { + variantString = 'dashed-stripe'; + } + } + + // Special cases for shared turn lanes + const prevVariant = previousSegment.variantString.split('|')[1]; + const currVariant = currentSegment.variantString.split('|')[1]; + + if (currentSegment.type === 'turn-lane' && currVariant === 'shared') { + variantString = 'solid-dashed-yellow'; + } else if (previousSegment.type === 'turn-lane' && prevVariant === 'shared') { + variantString = 'solid-dashed-yellow'; + } + + // Special case for parking lanes + if ( + currentSegment.type === 'parking-lane' || + previousSegment.type === 'parking-lane' + ) { + variantString = 'invisible'; + } + + return variantString; +} + +function getRandomIntInclusive(min, max) { + min = Math.ceil(min); + max = Math.floor(max); + return Math.floor(Math.random() * (max - min + 1) + min); +} + +function getSegmentColor(variant) { + if (variant === 'red' || variant === 'colored') { + return window.STREET.colors.red; + } + if (variant === 'blue') { + return window.STREET.colors.blue; + } + if (variant === 'green' || variant === 'grass') { + return window.STREET.colors.green; + } + return window.STREET.colors.white; +} + +// show warning message if segment or variantString are not supported +function supportCheck(segmentType, segmentVariantString) { + if (segmentType === 'separator') return; + // variants supported in 3DStreet + const supportedVariants = segmentVariants[segmentType]; + if (!supportedVariants) { + STREET.notify.warningMessage( + `The '${segmentType}' segment type is not yet supported in 3DStreet` + ); + console.log( + `The '${segmentType}' segment type is not yet supported in 3DStreet` + ); + } else if (!supportedVariants.includes(segmentVariantString)) { + STREET.notify.warningMessage( + `The '${segmentVariantString}' variant of segment '${segmentType}' is not yet supported in 3DStreet` + ); + console.log( + `The '${segmentVariantString}' variant of segment '${segmentType}' is not yet supported in 3DStreet` + ); + } +} + +// OLD: takes a street's `segments` (array) from streetmix and a `streetElementId` (string) and places objects to make up a street with all segments +// NEW: takes a `segments` (array) from streetmix and return an element and its children which represent the 3D street scene +function parseStreetmixSegments(segments, showStriping, length, showVehicles) { + // create and center offset to center the street around global x position of 0 + const segmentEls = []; + + let cumulativeWidthInMeters = 0; + for (let i = 0; i < segments.length; i++) { + let segmentColor = null; + const segmentParentEl = document.createElement('a-entity'); + segmentParentEl.classList.add('segment-parent-' + i); + + const segmentWidthInMeters = segments[i].width; + // console.log('Type: ' + segments[i].type + '; Width: ' + segmentWidthInFeet + 'ft / ' + segmentWidthInMeters + 'm'); + + cumulativeWidthInMeters = cumulativeWidthInMeters + segmentWidthInMeters; + const segmentPositionX = + cumulativeWidthInMeters - 0.5 * segmentWidthInMeters; + + // get variantString + const variantList = segments[i].variantString + ? segments[i].variantString.split('|') + : ''; + + // show warning message if segment or variantString are not supported + supportCheck(segments[i].type, segments[i].variantString); + + // elevation property from streetmix segment + const elevation = segments[i].elevation; + + const direction = + variantList[0] === 'inbound' || variantList[1] === 'inbound' + ? 'inbound' + : 'outbound'; + + // the A-Frame mixin ID is often identical to the corresponding streetmix segment "type" by design, let's start with that + let segmentPreset = segments[i].type; + + // look at segment type and variant(s) to determine specific cases + if (segments[i].type === 'drive-lane' && variantList[1] === 'sharrow') { + segmentParentEl.setAttribute( + 'street-generated-stencil', + `model: sharrow; length: ${length}; cycleOffset: 0.2; spacing: 15; direction: ${direction}` + ); + } else if ( + segments[i].type === 'bike-lane' || + segments[i].type === 'scooter' + ) { + segmentPreset = 'bike-lane'; // use bike lane road material + // get the mixin id for a bike lane + segmentColor = getSegmentColor(variantList[1]); + segmentParentEl.setAttribute( + 'street-generated-stencil', + `model: bike-arrow; length: ${length}; cycleOffset: 0.3; spacing: 20; direction: ${direction};` + ); + segmentParentEl.setAttribute( + 'street-generated-clones', + `mode: random; + modelsArray: cyclist-cargo, cyclist1, cyclist2, cyclist3, cyclist-dutch, cyclist-kid${segments[i].type === 'scooter' ? 'ElectricScooter_1' : ''}; + length: ${length}; + spacing: 2.03; + direction: ${direction}; + count: ${getRandomIntInclusive(2, 5)};` + ); + } else if ( + segments[i].type === 'light-rail' || + segments[i].type === 'streetcar' + ) { + segmentPreset = 'rail'; + // get the color for a bus lane + segmentColor = getSegmentColor(variantList[1]); + // get the mixin id for the vehicle (is it a trolley or a tram?) + const objectMixinId = + segments[i].type === 'streetcar' ? 'trolley' : 'tram'; + if (showVehicles) { + segmentParentEl.setAttribute( + 'street-generated-clones', + `mode: random; model: ${objectMixinId}; length: ${length}; spacing: 20; direction: ${direction}; count: 1;` + ); + } + segmentParentEl.setAttribute( + 'street-generated-rail', + `length: ${length}; gauge: ${segments[i].type === 'streetcar' ? 1067 : 1435};` + ); + } else if (segments[i].type === 'turn-lane') { + segmentPreset = 'drive-lane'; // use normal drive lane road material + if (showVehicles && variantList[1] !== 'shared') { + segmentParentEl.setAttribute( + 'street-generated-clones', + `mode: random; + modelsArray: sedan-rig, box-truck-rig, self-driving-waymo-car, suv-rig, motorbike; + length: ${length}; + spacing: 7.3; + direction: ${direction}; + count: ${getRandomIntInclusive(2, 4)};` + ); + } + let markerMixinId = variantList[1]; // set the mixin of the road markings to match the current variant name + // Fix streetmix inbound turn lane orientation (change left to right) per: https://github.com/streetmix/streetmix/issues/683 + if (variantList[0] === 'inbound') { + markerMixinId = markerMixinId.replace(/left|right/g, function (m) { + return m === 'left' ? 'right' : 'left'; + }); + } + if (variantList[1] === 'shared') { + markerMixinId = 'left'; + } + if (variantList[1] === 'left-right-straight') { + markerMixinId = 'all'; + } + segmentParentEl.setAttribute( + 'street-generated-stencil', + `model: ${markerMixinId}; length: ${length}; cycleOffset: 0.4; spacing: 20; direction: ${direction};` + ); + if (variantList[1] === 'shared') { + segmentParentEl.setAttribute( + 'street-generated-stencil__2', + `model: ${markerMixinId}; length: ${length}; cycleOffset: 0.6; spacing: 20; direction: ${direction}; facing: 180;` + ); + } + } else if (segments[i].type === 'divider' && variantList[0] === 'bollard') { + segmentPreset = 'divider'; + // make some bollards + segmentParentEl.setAttribute( + 'street-generated-clones', + `model: bollard; spacing: 4; length: ${length}` + ); + } else if (segments[i].type === 'divider' && variantList[0] === 'flowers') { + segmentPreset = 'grass'; + segmentParentEl.setAttribute( + 'street-generated-clones', + `model: dividers-flowers; spacing: 2.25; length: ${length}` + ); + } else if ( + segments[i].type === 'divider' && + variantList[0] === 'planting-strip' + ) { + segmentPreset = 'grass'; + segmentParentEl.setAttribute( + 'street-generated-clones', + `model: dividers-planting-strip; spacing: 2.25; length: ${length}` + ); + } else if ( + segments[i].type === 'divider' && + variantList[0] === 'planter-box' + ) { + segmentPreset = 'grass'; + segmentParentEl.setAttribute( + 'street-generated-clones', + `model: dividers-planter-box; spacing: 2.45; length: ${length}` + ); + } else if ( + segments[i].type === 'divider' && + variantList[0] === 'palm-tree' + ) { + segmentPreset = 'grass'; + segmentParentEl.setAttribute( + 'street-generated-clones', + `model: palm-tree; length: ${length}` + ); + } else if ( + segments[i].type === 'divider' && + variantList[0] === 'big-tree' + ) { + segmentPreset = 'grass'; + segmentParentEl.setAttribute( + 'street-generated-clones', + `model: tree3; length: ${length}` + ); + } else if (segments[i].type === 'divider' && variantList[0] === 'bush') { + segmentPreset = 'grass'; + segmentParentEl.setAttribute( + 'street-generated-clones', + `model: dividers-bush; spacing: 2.25; length: ${length}` + ); + } else if (segments[i].type === 'divider' && variantList[0] === 'dome') { + segmentPreset = 'divider'; + segmentParentEl.setAttribute( + 'street-generated-clones', + `model: dividers-dome; spacing: 2.25; length: ${length}` + ); + } else if (segments[i].type === 'divider') { + segmentPreset = 'divider'; + } else if ( + segments[i].type === 'temporary' && + variantList[0] === 'barricade' + ) { + segmentPreset = 'drive-lane'; + segmentParentEl.setAttribute( + 'street-generated-clones', + `model: temporary-barricade; spacing: 2.25; length: ${length}` + ); + } else if ( + segments[i].type === 'temporary' && + variantList[0] === 'traffic-cone' + ) { + segmentPreset = 'drive-lane'; + segmentParentEl.setAttribute( + 'street-generated-clones', + `model: temporary-traffic-cone; spacing: 2.25; length: ${length}` + ); + } else if ( + segments[i].type === 'temporary' && + variantList[0] === 'jersey-barrier-plastic' + ) { + segmentPreset = 'drive-lane'; + segmentParentEl.setAttribute( + 'street-generated-clones', + `model: jersey-barrier-plastic; spacing: 2.25; length: ${length}` + ); + } else if ( + segments[i].type === 'temporary' && + variantList[0] === 'jersey-barrier-concrete' + ) { + segmentPreset = 'drive-lane'; + segmentParentEl.setAttribute( + 'street-generated-clones', + `model: temporary-jersey-barrier-concrete; spacing: 2.93; length: ${length}` + ); + } else if ( + segments[i].type === 'bus-lane' || + segments[i].type === 'brt-lane' + ) { + segmentPreset = 'bus-lane'; + // get the color for a bus lane + segmentColor = getSegmentColor(variantList[1]); + + if (showVehicles) { + segmentParentEl.setAttribute( + 'street-generated-clones', + `mode: random; model: bus; length: ${length}; spacing: 15; direction: ${direction}; count: 1;` + ); + } + segmentParentEl.setAttribute( + 'street-generated-stencil', + `stencils: word-only, word-taxi, word-bus; length: ${length}; spacing: 40; padding: 10; direction: ${direction}` + ); + } else if (segments[i].type === 'drive-lane') { + if (showVehicles) { + segmentParentEl.setAttribute( + 'street-generated-clones', + `mode: random; + modelsArray: sedan-rig, box-truck-rig, self-driving-waymo-car, suv-rig, motorbike; + length: ${length}; + spacing: 7.3; + direction: ${direction}; + count: ${getRandomIntInclusive(2, 4)};` + ); + } + } else if (segments[i].type === 'food-truck') { + segmentPreset = 'drive-lane'; + segmentParentEl.setAttribute( + 'street-generated-clones', + `mode: random; model: food-trailer-rig; length: ${length}; spacing: 7; direction: ${direction}; count: 2;` + ); + } else if (segments[i].type === 'flex-zone') { + segmentPreset = 'parking-lane'; + if (showVehicles) { + const objectMixinId = + variantList[0] === 'taxi' ? 'sedan-taxi-rig' : 'sedan-rig'; + segmentParentEl.setAttribute( + 'street-generated-clones', + `mode: random; model: ${objectMixinId}; length: ${length}; spacing: 6; direction: ${direction}; count: 4;` + ); + } + segmentParentEl.setAttribute( + 'street-generated-stencil', + `stencils: word-loading-small, word-only-small; length: ${length}; spacing: 40; padding: 10; direction: ${direction}` + ); + } else if (segments[i].type === 'sidewalk' && variantList[0] !== 'empty') { + segmentParentEl.setAttribute( + 'street-generated-pedestrians', + `segmentWidth: ${segmentWidthInMeters}; density: ${variantList[0]}; length: ${length};` + ); + } else if (segments[i].type === 'sidewalk-wayfinding') { + segmentParentEl.setAttribute( + 'street-generated-clones', + `mode: single; model: wayfinding; length: ${length};` + ); + } else if (segments[i].type === 'sidewalk-bench') { + const rotationCloneY = variantList[0] === 'right' ? -90 : 90; + if (variantList[0] === 'center') { + segmentParentEl.setAttribute( + 'street-generated-clones', + `model: bench_orientation_center; length: ${length}; facing: ${rotationCloneY}; cycleOffset: 0.1` + ); + } else { + // `right` or `left` bench + segmentParentEl.setAttribute( + 'street-generated-clones', + `model: bench; length: ${length}; facing: ${rotationCloneY}; cycleOffset: 0.1` + ); + } + } else if (segments[i].type === 'sidewalk-bike-rack') { + const rotationCloneY = variantList[1] === 'sidewalk-parallel' ? 90 : 0; + segmentParentEl.setAttribute( + 'street-generated-clones', + `model: bikerack; length: ${length}; facing: ${rotationCloneY}; cycleOffset: 0.2` + ); + } else if (segments[i].type === 'magic-carpet') { + segmentPreset = 'drive-lane'; + segmentParentEl.setAttribute( + 'street-generated-clones', + `mode: single; model: magic-carpet; + length: ${length}; + positionY: 1.2;` + ); + segmentParentEl.setAttribute( + 'street-generated-clones__2', + `mode: single; model: Character_1_M; + length: ${length}; + positionY: 1.2;` + ); + } else if (segments[i].type === 'outdoor-dining') { + segmentPreset = variantList[1] === 'road' ? 'drive-lane' : 'sidewalk'; + segmentParentEl.setAttribute( + 'street-generated-clones', + `mode: random; model: outdoor_dining; length: ${length}; spacing: 3; count: 5;` + ); + } else if (segments[i].type === 'parklet') { + segmentPreset = 'drive-lane'; + const rotationCloneY = variantList[0] === 'left' ? 90 : 270; + segmentParentEl.setAttribute( + 'street-generated-clones', + `mode: random; model: parklet; length: ${length}; spacing: 5.5; count: 3; facing: ${rotationCloneY};` + ); + } else if (segments[i].type === 'bikeshare') { + const rotationCloneY = variantList[0] === 'left' ? 90 : 270; + segmentParentEl.setAttribute( + 'street-generated-clones', + `mode: single; model: bikeshare; length: ${length}; facing: ${rotationCloneY}; justify: middle;` + ); + } else if (segments[i].type === 'utilities') { + const rotationCloneY = variantList[0] === 'right' ? 180 : 0; + segmentParentEl.setAttribute( + 'street-generated-clones', + `model: utility_pole; length: ${length}; cycleOffset: 0.25; facing: ${rotationCloneY}` + ); + } else if (segments[i].type === 'sidewalk-tree') { + const objectMixinId = + variantList[0] === 'palm-tree' ? 'palm-tree' : 'tree3'; + segmentParentEl.setAttribute( + 'street-generated-clones', + `model: ${objectMixinId}; length: ${length}; randomFacing: true;` + ); + } else if ( + segments[i].type === 'sidewalk-lamp' && + (variantList[1] === 'modern' || variantList[1] === 'pride') + ) { + if (variantList[0] === 'both') { + segmentParentEl.setAttribute( + 'street-generated-clones', + `model: lamp-modern-double; length: ${length}; cycleOffset: 0.4;` + ); + } else { + const rotationCloneY = variantList[0] === 'right' ? 0 : 180; + segmentParentEl.setAttribute( + 'street-generated-clones', + `model: lamp-modern; length: ${length}; facing: ${rotationCloneY}; cycleOffset: 0.4;` + ); + } + // Add the pride flags to the lamp posts + if ( + variantList[1] === 'pride' && + (variantList[0] === 'right' || variantList[0] === 'both') + ) { + segmentParentEl.setAttribute( + 'street-generated-clones__2', + `model: pride-flag; length: ${length}; cycleOffset: 0.4; positionX: 0.409; positionY: 5;` + ); + } + if ( + variantList[1] === 'pride' && + (variantList[0] === 'left' || variantList[0] === 'both') + ) { + segmentParentEl.setAttribute( + 'street-generated-clones__2', + `model: pride-flag; length: ${length}; facing: 180; cycleOffset: 0.4; positionX: -0.409; positionY: 5;` + ); + } + } else if ( + segments[i].type === 'sidewalk-lamp' && + variantList[1] === 'traditional' + ) { + segmentParentEl.setAttribute( + 'street-generated-clones', + `model: lamp-traditional; length: ${length};` + ); + } else if (segments[i].type === 'transit-shelter') { + const rotationBusStopY = variantList[0] === 'left' ? 90 : 270; + segmentParentEl.setAttribute( + 'street-generated-clones', + `mode: single; model: bus-stop; length: ${length}; facing: ${rotationBusStopY};` + ); + } else if (segments[i].type === 'brt-station') { + segmentParentEl.setAttribute( + 'street-generated-clones', + `mode: single; model: brt-station; length: ${length};` + ); + } else if (segments[i].type === 'parking-lane') { + segmentPreset = 'parking-lane'; + let parkingMixin = 'stencils parking-t'; + let carStep = 6; + + const rotationVars = { + // markings rotation + outbound: 90, + inbound: 90, + sideways: 0, + 'angled-front-left': 30, + 'angled-front-right': -30, + 'angled-rear-left': -30, + 'angled-rear-right': 30 + }; + let markingsRotZ = rotationVars[variantList[0]]; + let markingLength; + + // calculate position X and rotation Z for T-markings + let markingPosX = segmentWidthInMeters / 2; + if (markingsRotZ === 90 && variantList[1] === 'right') { + markingsRotZ = -90; + markingPosX = -markingPosX + 0.75; + } else { + markingPosX = markingPosX - 0.75; + } + + if (variantList[0] === 'sideways' || variantList[0].includes('angled')) { + carStep = 3; + markingLength = segmentWidthInMeters; + markingPosX = 0; + parkingMixin = 'solid-stripe'; + if (variantList[1] === 'right') { + // make sure cars face the right way on right side + markingsRotZ = markingsRotZ + 180; + } + } + segmentParentEl.setAttribute( + 'street-generated-clones', + `mode: random; + modelsArray: sedan-rig, self-driving-waymo-car, suv-rig; + length: ${length}; + spacing: ${carStep}; + count: ${getRandomIntInclusive(6, 8)}; + facing: ${markingsRotZ - 90};` + ); + if (variantList[1] === 'left') { + segmentParentEl.setAttribute( + 'street-generated-stencil', + `model: ${parkingMixin}; length: ${length}; cycleOffset: 1; spacing: ${carStep}; positionX: ${markingPosX}; facing: ${markingsRotZ + 90}; stencilHeight: ${markingLength};` + ); + } else { + segmentParentEl.setAttribute( + 'street-generated-stencil', + `model: ${parkingMixin}; length: ${length}; cycleOffset: 1; spacing: ${carStep}; positionX: ${markingPosX}; facing: ${markingsRotZ + 90}; stencilHeight: ${markingLength};` + ); + } + } + + // if this thing is a sidewalk, make segmentPreset sidewalk + if (streetmixParsersTested.isSidewalk(segments[i].type)) { + segmentPreset = 'sidewalk'; + } + + // add new object + segmentParentEl.setAttribute('street-segment', 'type', segmentPreset); + segmentParentEl.setAttribute( + 'street-segment', + 'width', + segmentWidthInMeters + ); + segmentParentEl.setAttribute('street-segment', 'length', length); + segmentParentEl.setAttribute('street-segment', 'level', elevation); + segmentParentEl.setAttribute('street-segment', 'direction', direction); + segmentParentEl.setAttribute( + // find default color for segmentPreset + 'street-segment', + 'color', + segmentColor ?? window.STREET.types[segmentPreset]?.color // no error handling for segmentPreset not found + ); + segmentParentEl.setAttribute( + // find default surface type for segmentPreset + 'street-segment', + 'surface', + window.STREET.types[segmentPreset]?.surface // no error handling for segmentPreset not found + ); + + let currentSegment = segments[i]; + let previousSegment = segments[i - 1]; + let separatorMixinId = getSeparatorMixinId(previousSegment, currentSegment); + + if (separatorMixinId && showStriping) { + segmentParentEl.setAttribute( + 'street-generated-striping', + `striping: ${separatorMixinId}; length: ${length}; segmentWidth: ${segmentWidthInMeters};` + ); + // if previous segment is turn lane and shared, then facing should be 180 + if ( + previousSegment && + previousSegment.type === 'turn-lane' && + previousSegment.variantString.split('|')[1] === 'shared' + ) { + segmentParentEl.setAttribute( + 'street-generated-striping', + 'facing', + 180 + ); + } + } + segmentParentEl.setAttribute('position', segmentPositionX + ' 0 0'); + segmentParentEl.setAttribute( + 'data-layer-name', + '' + segments[i].type + ' • ' + variantList[0] + ); + segmentEls.push(segmentParentEl); + } + return segmentEls; +} diff --git a/src/components/street-generated-clones.js b/src/components/street-generated-clones.js new file mode 100644 index 00000000..79d93e94 --- /dev/null +++ b/src/components/street-generated-clones.js @@ -0,0 +1,158 @@ +/* global AFRAME */ + +AFRAME.registerComponent('street-generated-clones', { + multiple: true, + schema: { + // Common properties + model: { type: 'string' }, + modelsArray: { type: 'array' }, // For random selection from multiple models + length: { type: 'number' }, // length in meters of segment + positionX: { default: 0, type: 'number' }, + positionY: { default: 0, type: 'number' }, + facing: { default: 0, type: 'number' }, // Y Rotation in degrees + randomFacing: { default: false, type: 'boolean' }, + direction: { type: 'string', oneOf: ['none', 'inbound', 'outbound'] }, // not used if facing defined? + + // Mode-specific properties + mode: { default: 'fixed', oneOf: ['fixed', 'random', 'single'] }, + + // Spacing for fixed and random modes + spacing: { default: 15, type: 'number' }, // minimum distance between objects + + // Fixed mode properties + cycleOffset: { default: 0.5, type: 'number' }, // offset as a fraction of spacing, only for fixed + + // Random mode properties + count: { default: 1, type: 'number' }, + + // Single mode properties + justify: { default: 'middle', oneOf: ['start', 'middle', 'end'] }, + padding: { default: 4, type: 'number' } + }, + + init: function () { + this.createdEntities = []; + }, + + remove: function () { + this.createdEntities.forEach((entity) => entity.remove()); + this.createdEntities.length = 0; // Clear the array + }, + + update: function (oldData) { + // Clear existing entities + this.remove(); + + // Generate new entities based on mode + switch (this.data.mode) { + case 'fixed': + this.generateFixed(); + break; + case 'random': + this.generateRandom(); + break; + case 'single': + this.generateSingle(); + break; + } + }, + + generateFixed: function () { + const data = this.data; + const correctedSpacing = Math.max(1, data.spacing); + const numClones = Math.floor(data.length / correctedSpacing); + + for (let i = 0; i < numClones; i++) { + const positionZ = + data.length / 2 - (i + data.cycleOffset) * correctedSpacing; + this.createClone(positionZ); + } + }, + + generateRandom: function () { + const data = this.data; + const positions = this.randPlacedElements( + data.length, + data.spacing, + data.count + ); + + positions.forEach((positionZ) => { + this.createClone(positionZ); + }); + }, + + generateSingle: function () { + const data = this.data; + let positionZ = 0; + + if (data.justify === 'start') { + positionZ = data.length / 2 - data.padding; + } else if (data.justify === 'end') { + positionZ = -data.length / 2 + data.padding; + } + + this.createClone(positionZ); + }, + + createClone: function (positionZ) { + const data = this.data; + const mixinId = this.getModelMixin(); + const clone = document.createElement('a-entity'); + + clone.setAttribute('mixin', mixinId); + clone.setAttribute('position', { + x: data.positionX, + y: data.positionY, + z: positionZ + }); + + let rotationY = data.facing; + if (data.direction === 'inbound') { + rotationY = 0 + data.facing; + } + if (data.direction === 'outbound') { + rotationY = 180 - data.facing; + } + if (data.randomFacing) { + rotationY = Math.random() * 360; + } + clone.setAttribute('rotation', `0 ${rotationY} 0`); + + // Add common attributes + clone.classList.add('autocreated'); + clone.setAttribute('data-no-transform', ''); + clone.setAttribute('data-layer-name', 'Cloned Model • ' + mixinId); + + this.el.appendChild(clone); + this.createdEntities.push(clone); + }, + + getModelMixin: function () { + const data = this.data; + if (data.modelsArray && data.modelsArray.length > 0) { + return data.modelsArray[ + Math.floor(Math.random() * data.modelsArray.length) + ]; + } + return data.model; + }, + + randPlacedElements: function (streetLength, spacing, count) { + const correctedSpacing = Math.max(1, spacing); + const start = -streetLength / 2 + correctedSpacing / 2; + const end = streetLength / 2 - correctedSpacing / 2; + + // Calculate positions with offset + const len = Math.floor((end - start) / correctedSpacing) + 1; + const positions = Array(len) + .fill() + .map((_, idx) => { + // Apply the offset similar to fixed mode + return start + idx * correctedSpacing; + }); + + // Randomly select positions + return positions.sort(() => 0.5 - Math.random()).slice(0, count); + } +}); diff --git a/src/components/street-generated-label.js b/src/components/street-generated-label.js new file mode 100644 index 00000000..797f6a99 --- /dev/null +++ b/src/components/street-generated-label.js @@ -0,0 +1,112 @@ +/* global AFRAME */ + +// WIP make managed street labels from canvas +// assumes existing canvas with id label-canvas +// +// + // AFRAME.registerComponent('draw-canvas', { + // schema: { + // myCanvas: { type: 'string' }, + // managedStreet: { type: 'string' } // json of managed street children + // }, + // init: function () { + // // const objects = this.data.managedStreet.children; + // const objects = JSON.parse(this.data.managedStreet).children; + // this.canvas = document.getElementById(this.data); + // this.ctx = this.canvas.getContext('2d'); + // // Calculate total width from all objects + // const totalWidth = objects.reduce((sum, obj) => sum + obj.width, 0); + // ctx = this.ctx; + // canvas = this.canvas; + // // Set up canvas styling + // ctx.fillStyle = '#ffffff'; + // ctx.fillRect(0, 0, canvas.width, canvas.height); + // ctx.font = '24px Arial'; + // ctx.textAlign = 'center'; + // ctx.textBaseline = 'middle'; + // // Track current x position + // let currentX = 0; + // // Draw each segment + // objects.forEach((obj, index) => { + // // Calculate proportional width for this segment + // const segmentWidth = (obj.width / totalWidth) * canvas.width; + // // Draw segment background with alternating colors + // ctx.fillStyle = index % 2 === 0 ? '#f0f0f0' : '#e0e0e0'; + // ctx.fillRect(currentX, 0, segmentWidth, canvas.height); + // // Draw segment border + // ctx.strokeStyle = '#999999'; + // ctx.beginPath(); + // ctx.moveTo(currentX, 0); + // ctx.lineTo(currentX, canvas.height); + // ctx.stroke(); + // // Draw centered label + // ctx.fillStyle = '#000000'; + // const centerX = currentX + (segmentWidth / 2); + // const centerY = canvas.height / 2; + // // Format width number for display + // const label = obj.width.toLocaleString(); + // // Draw label with background for better readability + // const textMetrics = ctx.measureText(label); + // const textHeight = 30; // Approximate height of text + // const padding = 10; + // // Draw text background + // ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; + // ctx.fillRect( + // centerX - (textMetrics.width / 2) - padding, + // centerY - (textHeight / 2) - padding, + // textMetrics.width + (padding * 2), + // textHeight + (padding * 2) + // ); + // // Draw text + // ctx.fillStyle = '#000000'; + // ctx.fillText(label, centerX, centerY); + // // Update x position for next segment + // currentX += segmentWidth; + // }); + // // Draw final border + // ctx.strokeStyle = '#999999'; + // ctx.beginPath(); + // ctx.moveTo(canvas.width, 0); + // ctx.lineTo(canvas.width, canvas.height); + // ctx.stroke(); + // // Draw on canvas... + // } + // }); + // + } +}); diff --git a/src/components/street-generated-pedestrians.js b/src/components/street-generated-pedestrians.js new file mode 100644 index 00000000..7fc75a1e --- /dev/null +++ b/src/components/street-generated-pedestrians.js @@ -0,0 +1,132 @@ +/* global AFRAME */ + +// a-frame component to generate cloned pedestrian models along a street +AFRAME.registerComponent('street-generated-pedestrians', { + multiple: true, + schema: { + segmentWidth: { + // width of the segment in meters + type: 'number', + default: 3 + }, + density: { + type: 'string', + default: 'normal', + oneOf: ['empty', 'sparse', 'normal', 'dense'] + }, + length: { + // length in meters of linear path to fill with clones + type: 'number' + }, + direction: { + type: 'string', + default: 'none', + oneOf: ['none', 'inbound', 'outbound'] + }, + // animated: { + // // load 8 animated characters instead of 16 static characters + // type: 'boolean', + // default: false + // }, + positionY: { + // y position of pedestrians + type: 'number', + default: 0 + } + }, + + init: function () { + this.createdEntities = []; + this.densityFactors = { + empty: 0, + sparse: 0.03, + normal: 0.125, + dense: 0.25 + }; + }, + + remove: function () { + this.createdEntities.forEach((entity) => entity.remove()); + this.createdEntities.length = 0; // Clear the array + }, + + update: function (oldData) { + const data = this.data; + + // Clean up old entities + this.remove(); + + // Calculate x position range based on segment width + const xRange = { + min: -(0.37 * data.segmentWidth), + max: 0.37 * data.segmentWidth + }; + + // Calculate total number of pedestrians based on density and street length + const totalPedestrians = Math.floor( + this.densityFactors[data.density] * data.length + ); + + // Get available z positions + const zPositions = this.getZPositions( + -data.length / 2, + data.length / 2, + 1.5 + ); + + // Create pedestrians + for (let i = 0; i < totalPedestrians; i++) { + const pedestrian = document.createElement('a-entity'); + this.el.appendChild(pedestrian); + + // Set random position within bounds + const position = { + x: this.getRandomArbitrary(xRange.min, xRange.max), + y: data.positionY, + z: zPositions.pop() + }; + pedestrian.setAttribute('position', position); + + // Set model variant + const variantNumber = this.getRandomIntInclusive( + 1, + data.animated ? 8 : 16 + ); + const variantPrefix = data.animated ? 'a_char' : 'char'; + pedestrian.setAttribute('mixin', `${variantPrefix}${variantNumber}`); + + // Set rotation based on direction + if (data.direction === 'none' && Math.random() < 0.5) { + pedestrian.setAttribute('rotation', '0 180 0'); + } else if (data.direction === 'outbound') { + pedestrian.setAttribute('rotation', '0 180 0'); + } + + // Add metadata + pedestrian.classList.add('autocreated'); + pedestrian.setAttribute('data-no-transform', ''); + pedestrian.setAttribute('data-layer-name', 'Generated Pedestrian'); + + this.createdEntities.push(pedestrian); + } + }, + + // Helper methods from legacy function + getRandomIntInclusive: function (min, max) { + min = Math.ceil(min); + max = Math.floor(max); + return Math.floor(Math.random() * (max - min + 1) + min); + }, + + getRandomArbitrary: function (min, max) { + return Math.random() * (max - min) + min; + }, + + getZPositions: function (start, end, step) { + const len = Math.floor((end - start) / step) + 1; + const arr = Array(len) + .fill() + .map((_, idx) => start + idx * step); + return arr.sort(() => 0.5 - Math.random()); + } +}); diff --git a/src/components/street-generated-rail.js b/src/components/street-generated-rail.js new file mode 100644 index 00000000..994a707b --- /dev/null +++ b/src/components/street-generated-rail.js @@ -0,0 +1,67 @@ +/* global AFRAME */ + +AFRAME.registerComponent('street-generated-rail', { + schema: { + length: { + // length in meters of linear path to fill with rail + type: 'number' + }, + gauge: { + // spacing in millimeters between rails + type: 'int', + default: 1435, // standard gauge in mm + oneOf: [1435, 1067] + } + }, + init: function () { + this.createdEntities = []; + }, + remove: function () { + this.createdEntities.forEach((entity) => entity.remove()); + this.createdEntities.length = 0; // Clear the array + }, + update: function (oldData) { + // Clean up old entities + this.remove(); + + const clone = document.createElement('a-entity'); + clone.setAttribute('data-layer-name', 'Cloned Railroad Tracks'); + clone.setAttribute('position', '0 -0.2 0'); + const railsPosX = this.data.gauge / 2 / 1000; + clone.append(this.createRailsElement(this.data.length, railsPosX)); + clone.append(this.createRailsElement(this.data.length, -railsPosX)); + clone.setAttribute('data-no-transform', ''); + clone.setAttribute('data-ignore-raycaster', ''); + clone.classList.add('autocreated'); + + this.el.appendChild(clone); + this.createdEntities.push(clone); + }, + createRailsElement: function (length, railsPosX) { + const placedObjectEl = document.createElement('a-entity'); + const railsGeometry = { + primitive: 'box', + depth: length, + width: 0.1, + height: 0.2 + }; + const railsMaterial = { + // TODO: Add environment map for reflection on metal rails + color: '#8f8f8f', + metalness: 1, + emissive: '#828282', + emissiveIntensity: 0.5, + roughness: 0.1 + }; + placedObjectEl.setAttribute('geometry', railsGeometry); + placedObjectEl.setAttribute('material', railsMaterial); + placedObjectEl.setAttribute('data-layer-name', 'Rail'); + placedObjectEl.setAttribute('data-no-transform', ''); + placedObjectEl.setAttribute('data-ignore-raycaster', ''); + placedObjectEl.setAttribute('position', railsPosX + ' 0.2 0'); // position="1.043 0.100 -3.463" + placedObjectEl.classList.add('autocreated'); + this.createdEntities.push(placedObjectEl); + + return placedObjectEl; + } +}); diff --git a/src/components/street-generated-stencil.js b/src/components/street-generated-stencil.js new file mode 100644 index 00000000..b78e3396 --- /dev/null +++ b/src/components/street-generated-stencil.js @@ -0,0 +1,175 @@ +/* global AFRAME */ + +// a-frame component to generate cloned models along a street +// this moves logic from aframe-streetmix-parsers into this component + +AFRAME.registerComponent('street-generated-stencil', { + multiple: true, + schema: { + model: { + type: 'string', + oneOf: [ + 'sharrow', + 'bike-arrow', + 'left', + 'right', + 'straight', + 'left-straight', + 'right-straight', + 'both', + 'all', + 'word-taxi', + 'word-only', + 'word-bus', + 'word-lane', + 'word-only-small', + 'word-yield', + 'word-slow', + 'word-xing', + 'word-stop', + 'word-loading-small', + 'perpendicular-stalls', + 'parking-t', + 'hash-left', + 'hash-right', + 'hash-chevron', + 'solid-stripe' + ] + }, + stencils: { + // if present, then use this array of stencils instead of 1 model + type: 'array' + }, + padding: { + // distance between stencils within array + default: 0, + type: 'number' + }, + length: { + // length in meters of linear path to fill with clones + type: 'number' + }, + spacing: { + // spacing in meters between clones + default: 15, + type: 'number' + }, + positionX: { + // x position of clones along the length + default: 0, + type: 'number' + }, + positionY: { + // y position of clones along the length + default: 0.05, + type: 'number' + }, + cycleOffset: { + // z (inbound/outbound) offset as a fraction of spacing value + default: 0.5, // this is used to place different models at different z-levels with the same spacing value + type: 'number' + }, + facing: { + default: 0, // this is a Y Rotation value in degrees -- UI could offer a dropdown with options for 0, 90, 180, 270 + type: 'number' + }, + randomFacing: { + // if true, facing is ignored and a random Y Rotation is applied to each clone + default: false, + type: 'boolean' + }, + stencilHeight: { + default: 0, + type: 'number' + }, + direction: { + // specifying inbound/outbound directions will overwrite facing/randomFacing + type: 'string', + oneOf: ['none', 'inbound', 'outbound'] + } + // seed: { // seed not yet supported + // default: 0, + // type: 'number' + // } + }, + init: function () { + this.createdEntities = []; + }, + remove: function () { + this.createdEntities.forEach((entity) => entity.remove()); + this.createdEntities.length = 0; // Clear the array + }, + update: function (oldData) { + const data = this.data; + + // Clean up old entities + this.remove(); + + // Use either stencils array or single model + let stencilsToUse = data.stencils.length > 0 ? data.stencils : [data.model]; + + // Reverse stencil order if inbound + if (data.direction === 'inbound') { + stencilsToUse = stencilsToUse.slice().reverse(); + } + + // Ensure minimum spacing + this.correctedSpacing = Math.max(1, data.spacing); + + // Calculate number of stencil groups that can fit in the length + const numGroups = Math.floor(data.length / this.correctedSpacing); + + // Create stencil groups along the street + for (let groupIndex = 0; groupIndex < numGroups; groupIndex++) { + const groupPosition = + data.length / 2 - + (groupIndex + data.cycleOffset) * this.correctedSpacing; + + // Create each stencil within the group + stencilsToUse.forEach((stencilName, stencilIndex) => { + const clone = document.createElement('a-entity'); + clone.setAttribute('mixin', stencilName); + + // Calculate stencil position within group + const stencilOffset = + (stencilIndex - (stencilsToUse.length - 1) / 2) * data.padding; + + // Set position with group position and stencil offset + clone.setAttribute('position', { + x: data.positionX, + y: data.positionY, + z: groupPosition + stencilOffset + }); + + // Handle stencil height if specified + if (data.stencilHeight > 0) { + clone.addEventListener('loaded', (evt) => { + evt.target.setAttribute('geometry', 'height', data.stencilHeight); + evt.target.components['atlas-uvs'].update(); + }); + } + + // Set rotation - either random, specified facing, or inbound/outbound + let rotationY = data.facing; + if (data.direction === 'inbound') { + rotationY = 180 + data.facing; + } + if (data.direction === 'outbound') { + rotationY = 0 - data.facing; + } + if (data.randomFacing) { + rotationY = Math.random() * 360; + } + clone.setAttribute('rotation', `-90 ${rotationY} 0`); + + // Add metadata + clone.classList.add('autocreated'); + clone.setAttribute('data-no-transform', ''); + clone.setAttribute('data-layer-name', `Cloned Model • ${stencilName}`); + + this.el.appendChild(clone); + this.createdEntities.push(clone); + }); + } + } +}); diff --git a/src/components/street-generated-striping.js b/src/components/street-generated-striping.js new file mode 100644 index 00000000..8714eb72 --- /dev/null +++ b/src/components/street-generated-striping.js @@ -0,0 +1,112 @@ +/* global AFRAME */ + +// a-frame component to generate cloned models along a street +// this moves logic from aframe-streetmix-parsers into this component + +AFRAME.registerComponent('street-generated-striping', { + multiple: true, + schema: { + striping: { + type: 'string' + }, + segmentWidth: { + type: 'number' + }, + side: { + default: 'left', + oneOf: ['left', 'right'] + }, + facing: { + default: 0, // this is a Y Rotation value in degrees -- UI could offer a dropdown with options for 0, 90, 180, 270 + type: 'number' + }, + length: { + // length in meters of linear path to fill with clones + type: 'number' + }, + positionY: { + // y position of clones along the length + default: 0.05, // this is too high, instead this should component should respect elevation to follow street segment + type: 'number' + } + }, + init: function () { + this.createdEntities = []; + }, + remove: function () { + this.createdEntities.forEach((entity) => entity.remove()); + this.createdEntities.length = 0; // Clear the array + }, + update: function (oldData) { + const data = this.data; + + // Clean up old entities + this.remove(); + + if (data.striping === 'invisible') { + return; + } + const clone = document.createElement('a-entity'); + const { stripingTextureId, repeatY, color, stripingWidth } = + this.calculateStripingMaterial(data.striping, data.length); + const positionX = ((data.side === 'left' ? -1 : 1) * data.segmentWidth) / 2; + clone.setAttribute('position', { + x: positionX, + y: data.positionY, + z: 0 + }); + clone.setAttribute('rotation', { + x: -90, + y: data.facing, + z: 0 + }); + clone.setAttribute( + 'material', + `src: #${stripingTextureId}; alphaTest: 0; transparent:true; repeat:1 ${repeatY}; color: ${color}` + ); + clone.setAttribute( + 'geometry', + `primitive: plane; width: ${stripingWidth}; height: ${data.length}; skipCache: true;` + ); + clone.classList.add('autocreated'); + // clone.setAttribute('data-ignore-raycaster', ''); // i still like clicking to zoom to individual clones, but instead this should show the generated-fixed clone settings + clone.setAttribute('data-no-transform', ''); + clone.setAttribute( + 'data-layer-name', + 'Cloned Striping • ' + stripingTextureId + ); + this.el.appendChild(clone); + this.createdEntities.push(clone); + }, + calculateStripingMaterial: function (stripingName, length) { + // calculate the repeatCount for the material + let stripingTextureId = 'striping-solid-stripe'; // drive-lane, bus-lane, bike-lane + let repeatY = length / 6; + let color = '#ffffff'; + let stripingWidth = 0.2; + if (stripingName === 'solid-stripe') { + stripingTextureId = 'striping-solid-stripe'; + } else if (stripingName === 'dashed-stripe') { + stripingTextureId = 'striping-dashed-stripe'; + } else if (stripingName === 'short-dashed-stripe') { + stripingTextureId = 'striping-dashed-stripe'; + repeatY = length / 3; + } else if (stripingName === 'short-dashed-stripe-yellow') { + stripingTextureId = 'striping-dashed-stripe'; + repeatY = length / 3; + color = '#f7d117'; + } else if (stripingName === 'solid-doubleyellow') { + stripingTextureId = 'striping-solid-double'; + stripingWidth = 0.5; + color = '#f7d117'; + } else if (stripingName === 'solid-dashed') { + stripingTextureId = 'striping-solid-dashed'; + stripingWidth = 0.4; + } else if (stripingName === 'solid-dashed-yellow') { + stripingTextureId = 'striping-solid-dashed'; + color = '#f7d117'; + stripingWidth = 0.4; + } + return { stripingTextureId, repeatY, color, stripingWidth }; + } +}); diff --git a/src/components/street-segment.js b/src/components/street-segment.js new file mode 100644 index 00000000..ecac7345 --- /dev/null +++ b/src/components/street-segment.js @@ -0,0 +1,438 @@ +/* global AFRAME */ + +/* + + + + + + +*/ + +const COLORS = { + red: '#ff9393', + blue: '#00b6b6', + green: '#adff83', + yellow: '#f7d117', + lightGray: '#dddddd', + white: '#ffffff', + brown: '#664B00' +}; +STREET.colors = COLORS; + +const TYPES = { + 'drive-lane': { + type: 'drive-lane', + color: COLORS.white, + surface: 'asphalt', + level: 0, + generated: { + clones: [ + { + mode: 'random', + modelsArray: + 'sedan-rig, box-truck-rig, self-driving-waymo-car, suv-rig, motorbike', + spacing: 7.3, + count: 4 + } + ] + } + }, + 'bus-lane': { + type: 'bus-lane', + surface: 'asphalt', + color: COLORS.red, + level: 0, + generated: { + clones: [ + { + mode: 'random', + model: 'bus', + spacing: 15, + count: 1 + } + ], + stencil: [ + { + stencils: 'word-only, word-taxi, word-bus', + spacing: 40, + padding: 10 + } + ] + } + }, + 'bike-lane': { + type: 'bike-lane', + color: COLORS.green, + surface: 'asphalt', + level: 0, + generated: { + stencil: [ + { + model: 'bike-arrow', + cycleOffset: 0.3, + spacing: 20 + } + ], + clones: [ + { + mode: 'random', + modelsArray: + 'cyclist-cargo, cyclist1, cyclist2, cyclist3, cyclist-dutch, cyclist-kid, ElectricScooter_1', + spacing: 2.03, + count: 4 + } + ] + } + }, + sidewalk: { + type: 'sidewalk', + surface: 'sidewalk', + color: COLORS.white, + level: 1, + direction: 'none', + generated: { + pedestrians: [ + { + density: 'normal' + } + ] + } + }, + 'parking-lane': { + surface: 'concrete', + color: COLORS.lightGray, + level: 0, + generated: { + clones: [ + { + mode: 'random', + modelsArray: 'sedan-rig, self-driving-waymo-car, suv-rig', + spacing: 6, + count: 6 + } + ], + stencil: [ + { + model: 'parking-t', + cycleOffset: 1, + spacing: 6 + } + ] + } + }, + divider: { + surface: 'hatched', + color: COLORS.white, + level: 0 + }, + grass: { + surface: 'grass', + color: COLORS.white, + level: -1 + }, + rail: { + surface: 'asphalt', + color: COLORS.white, + level: 0 + } +}; +STREET.types = TYPES; + +AFRAME.registerComponent('street-segment', { + schema: { + type: { + type: 'string', // value not used by component, used in React app instead + oneOf: [ + 'drive-lane', + 'bus-lane', + 'bike-lane', + 'sidewalk', + 'parking-lane', + 'divider', + 'grass', + 'rail' + ] + }, + width: { + type: 'number' + }, + length: { + type: 'number' + }, + level: { + type: 'int', + default: 0 + }, + direction: { + type: 'string', + oneOf: ['none', 'inbound', 'outbound'] + }, + surface: { + type: 'string', + default: 'asphalt', + oneOf: [ + 'asphalt', + 'concrete', + 'grass', + 'sidewalk', + 'gravel', + 'sand', + 'none', + 'solid' + ] + }, + color: { + type: 'color' + } + }, + init: function () { + this.height = 0.2; // default height of segment surface box + this.generatedComponents = []; + this.types = TYPES; // default segment types + }, + generateComponentsFromSegmentObject: function (segmentObject) { + // use global preset data to create the generated components for a given segment type + const componentsToGenerate = segmentObject.generated; + + // for each of clones, stencils, rail, pedestrians, etc. + if (componentsToGenerate?.clones?.length > 0) { + componentsToGenerate.clones.forEach((clone, index) => { + if (clone?.modelsArray?.length > 0) { + this.el.setAttribute(`street-generated-clones__${index}`, { + mode: clone.mode, + modelsArray: clone.modelsArray, + length: this.data.length, + spacing: clone.spacing, + direction: this.data.direction, + count: clone.count + }); + } else { + this.el.setAttribute(`street-generated-clones__${index}`, { + mode: clone.mode, + model: clone.model, + length: this.data.length, + spacing: clone.spacing, + direction: this.data.direction, + count: clone.count + }); + } + }); + } + + if (componentsToGenerate?.stencil?.length > 0) { + componentsToGenerate.stencil.forEach((clone, index) => { + if (clone?.stencils?.length > 0) { + this.el.setAttribute(`street-generated-stencil__${index}`, { + stencils: clone.stencils, + length: this.data.length, + spacing: clone.spacing, + direction: this.data.direction, + padding: clone.padding + }); + } else { + this.el.setAttribute(`street-generated-stencil__${index}`, { + model: clone.model, + length: this.data.length, + spacing: clone.spacing, + direction: this.data.direction, + count: clone.count + }); + } + }); + } + + if (componentsToGenerate?.pedestrians?.length > 0) { + componentsToGenerate.pedestrians.forEach((pedestrian, index) => { + this.el.setAttribute(`street-generated-pedestrians__${index}`, { + segmentWidth: this.data.width, + density: pedestrian.density, + length: this.data.length, + direction: this.data.direction + }); + }); + } + }, + updateSurfaceFromType: function (typeObject) { + // update color, surface, level from segment type preset + this.el.setAttribute( + 'street-segment', + `surface: ${typeObject.surface}; color: ${typeObject.color}; level: ${typeObject.level};` + ); // to do: this should be more elegant to check for undefined and set default values + }, + updateGeneratedComponentsList: function () { + // get all components on entity with prefix 'street-generated' + let generatedComponentList = []; + const components = this.el.components; + for (const componentName in components) { + if (componentName.startsWith('street-generated')) { + generatedComponentList.push(componentName); + } + } + this.generatedComponents = generatedComponentList; + }, + update: function (oldData) { + const data = this.data; + const dataDiff = AFRAME.utils.diff(oldData, data); + const changedProps = Object.keys(dataDiff); + + // regenerate components if only type has changed + if (changedProps.length === 1 && changedProps.includes('type')) { + let typeObject = this.types[this.data.type]; + this.updateGeneratedComponentsList(); // if components were created through streetmix or streetplan import + this.remove(); + this.generateComponentsFromSegmentObject(typeObject); // add components for this type + this.updateSurfaceFromType(typeObject); // update surface color, surface, level + } + // propagate change of direction to generated components is solo changed + if (changedProps.includes('direction')) { + this.updateGeneratedComponentsList(); // if components were created through streetmix or streetplan import + for (const componentName of this.generatedComponents) { + this.el.setAttribute(componentName, 'direction', this.data.direction); + } + } + // propagate change of length to generated components is solo changed + if (changedProps.includes('length')) { + this.updateGeneratedComponentsList(); // if components were created through streetmix or streetplan import + for (const componentName of this.generatedComponents) { + this.el.setAttribute(componentName, 'length', this.data.length); + } + } + this.clearMesh(); + this.height = this.calculateHeight(data.level); + this.tempXPosition = this.el.getAttribute('position').x; + this.tempZPosition = this.el.getAttribute('position').z; + this.el.setAttribute('position', { + x: this.tempXPosition, + y: this.height, + z: this.tempZPosition + }); + this.generateMesh(data); + // if width was changed, trigger re-justification of all street-segments by the managed-street + if (changedProps.includes('width')) { + this.el.parentNode.components['managed-street'].refreshManagedEntities(); + this.el.parentNode.components['managed-street'].applyJustification(); + } + }, + // for streetmix elevation number values of -1, 0, 1, 2, calculate heightLevel in three.js meters units + calculateHeight: function (elevationLevel) { + const stepLevel = 0.15; + if (elevationLevel <= 0) { + return stepLevel; + } + return stepLevel * (elevationLevel + 1); + }, + clearMesh: function () { + // remove the geometry from the entity + this.el.removeAttribute('geometry'); + this.el.removeAttribute('material'); + }, + remove: function () { + this.clearMesh(); + + this.generatedComponents.forEach((componentName) => { + this.el.removeAttribute(componentName); + }); + this.generatedComponents.length = 0; + }, + generateMesh: function (data) { + // create geometry + this.el.setAttribute( + 'geometry', + `primitive: below-box; + height: ${this.height}; + depth: ${data.length}; + width: ${data.width};` + ); + + // create a lookup table to convert UI shortname into A-Frame img id's + const textureMaps = { + asphalt: 'seamless-road', + concrete: 'seamless-bright-road', + grass: 'grass-texture', + sidewalk: 'seamless-sidewalk', + gravel: 'compacted-gravel-texture', + sand: 'sandy-asphalt-texture', + hatched: 'hatched-base', + none: 'none', + solid: '' + }; + let textureSourceId = textureMaps[data.surface]; + + // calculate the repeatCount for the material + let [repeatX, repeatY, offsetX] = this.calculateTextureRepeat( + data.length, + data.width, + textureSourceId + ); + + this.el.setAttribute( + 'material', + `src: #${textureMaps[data.surface]}; + roughness: 0.8; + repeat: ${repeatX} ${repeatY}; + offset: ${offsetX} 0; + color: ${data.color}` + ); + + this.el.setAttribute('shadow', ''); + + this.el.setAttribute( + 'material', + 'visible', + textureMaps[data.surface] !== 'none' + ); + + return; + }, + calculateTextureRepeat: function (length, width, textureSourceId) { + // calculate the repeatCount for the material + let repeatX = 0.3; // drive-lane, bus-lane, bike-lane + let repeatY = length / 6; + let offsetX = 0.55; // we could get rid of this using cropped texture for asphalt + if (textureSourceId === 'seamless-bright-road') { + repeatX = 0.6; + repeatY = 15; + } else if (textureSourceId === 'seamless-sandy-road') { + repeatX = width / 30; + repeatY = length / 30; + offsetX = 0; + } else if (textureSourceId === 'seamless-sidewalk') { + repeatX = width / 2; + repeatY = length / 2; + offsetX = 0; + } else if (textureSourceId === 'grass-texture') { + repeatX = width / 4; + repeatY = length / 6; + offsetX = 0; + } else if (textureSourceId === 'hatched-base') { + repeatX = 1; + repeatY = length / 4; + offsetX = 0; + } + return [repeatX, repeatY, offsetX]; + } +}); + +AFRAME.registerGeometry('below-box', { + schema: { + depth: { default: 1, min: 0 }, + height: { default: 1, min: 0 }, + width: { default: 1, min: 0 }, + segmentsHeight: { default: 1, min: 1, max: 20, type: 'int' }, + segmentsWidth: { default: 1, min: 1, max: 20, type: 'int' }, + segmentsDepth: { default: 1, min: 1, max: 20, type: 'int' } + }, + + init: function (data) { + this.geometry = new THREE.BoxGeometry( + data.width, + data.height, + data.depth, + data.segmentsWidth, + data.segmentsHeight, + data.segmentsDepth + ); + this.geometry.translate(0, -data.height / 2, 0); + } +}); diff --git a/src/editor/components/components/AddLayerPanel/createLayerFunctions.js b/src/editor/components/components/AddLayerPanel/createLayerFunctions.js index 8f0653ee..7a92c83b 100644 --- a/src/editor/components/components/AddLayerPanel/createLayerFunctions.js +++ b/src/editor/components/components/AddLayerPanel/createLayerFunctions.js @@ -50,6 +50,32 @@ export function createMapbox() { }); } +export function createManagedStreet(position) { + // This creates a new Managed Street + let streetmixURL = prompt( + 'Please enter a Streetmix URL', + 'https://streetmix.net/kfarr/3/3dstreet-demo-street' + ); + + if (streetmixURL && streetmixURL !== '') { + const definition = { + id: createUniqueId(), + components: { + position: position ?? '0 0 0', + 'managed-street': { + sourceType: 'streetmix-url', + sourceValue: streetmixURL, + showVehicles: true, + showStriping: true, + synchronize: true + } + } + }; + + AFRAME.INSPECTOR.execute('entitycreate', definition); + } +} + export function createStreetmixStreet(position, streetmixURL, hideBuildings) { // This code snippet allows the creation of an additional Streetmix street // in your 3DStreet scene without replacing any existing streets. diff --git a/src/editor/components/components/AddLayerPanel/layersData.js b/src/editor/components/components/AddLayerPanel/layersData.js index 5ac90282..42278192 100644 --- a/src/editor/components/components/AddLayerPanel/layersData.js +++ b/src/editor/components/components/AddLayerPanel/layersData.js @@ -9,7 +9,8 @@ import { create80ftRightOfWay, create94ftRightOfWay, create150ftRightOfWay, - createImageEntity + createImageEntity, + createManagedStreet } from './createLayerFunctions'; export const streetLayersData = [ @@ -71,6 +72,16 @@ export const streetLayersData = [ 'Create intersection entity. Parameters of intersection component could be changed in properties panel.', id: 7, handlerFunction: createIntersection + }, + { + name: 'Create Managed Street (Beta)', + img: '', + requiresPro: true, + icon: '', + description: + 'Create a new street from Streetmix using the Managed Street component.', + id: 8, + handlerFunction: createManagedStreet } ]; diff --git a/src/editor/components/components/ManagedStreetSidebar.js b/src/editor/components/components/ManagedStreetSidebar.js new file mode 100644 index 00000000..7f2b44ad --- /dev/null +++ b/src/editor/components/components/ManagedStreetSidebar.js @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; +import PropertyRow from './PropertyRow'; + +const ManagedStreetSidebar = ({ entity }) => { + const componentName = 'managed-street'; + // Check if entity and its components exist + const component = entity?.components?.[componentName]; + + return ( +
+
+
+ {component && component.schema && component.data && ( + <> + + +
+
-----
+
+ + )} +
+
+
+ ); +}; + +ManagedStreetSidebar.propTypes = { + entity: PropTypes.object.isRequired +}; + +export default ManagedStreetSidebar; diff --git a/src/editor/components/components/Sidebar.js b/src/editor/components/components/Sidebar.js index 32c3bd57..4d460c6a 100644 --- a/src/editor/components/components/Sidebar.js +++ b/src/editor/components/components/Sidebar.js @@ -11,9 +11,17 @@ import PropTypes from 'prop-types'; import React from 'react'; import capitalize from 'lodash-es/capitalize'; import classnames from 'classnames'; -import { ArrowRightIcon, Object24Icon } from '../../icons'; +import { + ArrowRightIcon, + Object24Icon, + SegmentIcon, + ManagedStreetIcon +} from '../../icons'; import GeoSidebar from './GeoSidebar'; // Make sure to create and import this new component import IntersectionSidebar from './IntersectionSidebar'; +import StreetSegmentSidebar from './StreetSegmentSidebar'; +import ManagedStreetSidebar from './ManagedStreetSidebar'; +import AdvancedComponents from './AdvancedComponents'; export default class Sidebar extends React.Component { static propTypes = { entity: PropTypes.object, @@ -90,7 +98,13 @@ export default class Sidebar extends React.Component { <>
- + {entity.getAttribute('managed-street') ? ( + + ) : entity.getAttribute('street-segment') ? ( + + ) : ( + + )} {entityName || formattedMixin}
@@ -98,7 +112,8 @@ export default class Sidebar extends React.Component {
- {entity.id !== 'reference-layers' ? ( + {entity.id !== 'reference-layers' && + !entity.getAttribute('street-segment') ? ( <> {!!entity.mixinEls.length && } {entity.hasAttribute('data-no-transform') ? ( @@ -128,10 +143,25 @@ export default class Sidebar extends React.Component { {entity.getAttribute('intersection') && ( )} + {entity.getAttribute('managed-street') && ( + + )} ) : ( - + <> + {entity.getAttribute('street-segment') && ( + <> + +
+ +
+ + )} + {entity.id === 'reference-layers' && ( + + )} + )}
@@ -146,7 +176,13 @@ export default class Sidebar extends React.Component { {entityName || formattedMixin}
- + {entity.getAttribute('managed-street') ? ( + + ) : entity.getAttribute('street-segment') ? ( + + ) : ( + + )}
diff --git a/src/editor/components/components/StreetSegmentSidebar.js b/src/editor/components/components/StreetSegmentSidebar.js new file mode 100644 index 00000000..280217f6 --- /dev/null +++ b/src/editor/components/components/StreetSegmentSidebar.js @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import PropertyRow from './PropertyRow'; + +const StreetSegmentSidebar = ({ entity }) => { + const componentName = 'street-segment'; + // Check if entity and its components exist + const component = entity?.components?.[componentName]; + + return ( +
+
+
+ {component && component.schema && component.data && ( + <> + + + +
+
-----
+
+ + + + + )} +
+
+
+ ); +}; + +StreetSegmentSidebar.propTypes = { + entity: PropTypes.object.isRequired +}; + +export default StreetSegmentSidebar; diff --git a/src/editor/icons/icons.jsx b/src/editor/icons/icons.jsx index 7dc55558..e5559ae2 100644 --- a/src/editor/icons/icons.jsx +++ b/src/editor/icons/icons.jsx @@ -1,4 +1,4 @@ -const Camera32Icon = () => ( +export const Camera32Icon = () => ( ( ); -const Rotate24Icon = () => ( +export const Rotate24Icon = () => ( ( ); -const Translate24Icon = () => ( +export const Translate24Icon = () => ( ( ); -const Cloud24Icon = () => ( +export const Cloud24Icon = () => ( ( ); -const Load24Icon = () => ( +export const Load24Icon = () => ( ( ); -const Save24Icon = () => ( +export const Save24Icon = () => ( ( ); -const Upload24Icon = () => ( +export const Upload24Icon = () => ( ( ); -const Cross32Icon = () => ( +export const Cross32Icon = () => ( ( ); -const Cross24Icon = () => ( +export const Cross24Icon = () => ( ( ); -const Compass32Icon = () => ( +export const Compass32Icon = () => ( ( ); -const ArrowDown24Icon = () => ( +export const ArrowDown24Icon = () => ( ( ); -const ArrowUp24Icon = () => ( +export const ArrowUp24Icon = () => ( ( ); -const CheckIcon = (className) => ( +export const CheckIcon = (className) => ( ( ); -const DropdownArrowIcon = () => ( +export const DropdownArrowIcon = () => ( ( ); -const Mangnifier20Icon = () => ( +export const Mangnifier20Icon = () => ( ( ); -const Edit32Icon = () => ( +export const Edit32Icon = () => ( ( ); -const Edit24Icon = () => ( +export const Edit24Icon = () => ( ( ); -const CheckMark32Icon = () => ( +export const CheckMark32Icon = () => ( ( ); -const Action24 = () => ( +export const Action24 = () => ( ( ); -const DownloadIcon = () => ( +export const DownloadIcon = () => ( ( ); -const Copy32Icon = ({ className }) => ( +export const Copy32Icon = ({ className }) => ( ( ); -const DropdownIcon = () => ( +export const DropdownIcon = () => ( ( ); -const Loader = ({ className }) => ( +export const Loader = ({ className }) => ( ( ); -const RemixIcon = ({ className }) => ( +export const RemixIcon = ({ className }) => ( ( ); -const ArrowLeftIcon = ({ className }) => ( +export const ArrowLeftIcon = ({ className }) => ( ( ); -const ArrowRightIcon = ({ className }) => ( +export const ArrowRightIcon = ({ className }) => ( ( ); -const LayersIcon = ({ className }) => ( +export const LayersIcon = ({ className }) => ( ( ); -const GoogleSignInButtonSVG = ({ className }) => ( +export const GoogleSignInButtonSVG = ({ className }) => ( ( ); -const Chevron24Down = ({ className }) => ( +export const Chevron24Down = ({ className }) => ( ( ); -const Plus20Circle = ({ className }) => ( +export const Plus20Circle = ({ className }) => ( ( ); -const QR32Icon = () => ( +export const QR32Icon = () => ( ( ); -const ScreenshotIcon = () => ( +export const ScreenshotIcon = () => ( ( ); -const Object24Icon = () => ( +export const Object24Icon = () => ( ( ); -const SignInMicrosoftIconSVG = () => ( +export const SignInMicrosoftIconSVG = () => ( ( ); -export { - Camera32Icon, - Save24Icon, - Load24Icon, - Cross32Icon, - Cross24Icon, - Compass32Icon, - ArrowDown24Icon, - ArrowUp24Icon, - CheckIcon, - DropdownArrowIcon, - Cloud24Icon, - Mangnifier20Icon, - Upload24Icon, - Edit32Icon, - Edit24Icon, - CheckMark32Icon, - Copy32Icon, - DropdownIcon, - Loader, - RemixIcon, - ArrowLeftIcon, - ArrowRightIcon, - LayersIcon, - GoogleSignInButtonSVG, - Chevron24Down, - Plus20Circle, - QR32Icon, - ScreenshotIcon, - DownloadIcon, - Action24, - SignInMicrosoftIconSVG, - Rotate24Icon, - Translate24Icon, - Object24Icon -}; +export const ManagedStreetIcon = () => ( + + + + + + + + +); + +export const SegmentIcon = () => ( + + + + + + + + + + +); diff --git a/src/editor/icons/index.js b/src/editor/icons/index.js index 5f6c3ed0..58b9bf38 100644 --- a/src/editor/icons/index.js +++ b/src/editor/icons/index.js @@ -1,36 +1 @@ -export { - Camera32Icon, - Save24Icon, - Load24Icon, - Upload24Icon, - Cross32Icon, - Cross24Icon, - Compass32Icon, - ArrowDown24Icon, - ArrowUp24Icon, - CheckIcon, - DropdownArrowIcon, - Cloud24Icon, - Mangnifier20Icon, - Edit32Icon, - Edit24Icon, - CheckMark32Icon, - Copy32Icon, - DropdownIcon, - Loader, - RemixIcon, - ArrowLeftIcon, - ArrowRightIcon, - LayersIcon, - GoogleSignInButtonSVG, - Chevron24Down, - Plus20Circle, - QR32Icon, - Action24, - DownloadIcon, - ScreenshotIcon, - SignInMicrosoftIconSVG, - Rotate24Icon, - Translate24Icon, - Object24Icon -} from './icons.jsx'; +export * from './icons.jsx'; diff --git a/src/editor/index.js b/src/editor/index.js index 60118770..a91149ff 100644 --- a/src/editor/index.js +++ b/src/editor/index.js @@ -153,7 +153,7 @@ Inspector.prototype = { this.select(null); } - if (entity && emit === undefined) { + if (emit === undefined) { Events.emit('entityselect', entity); } diff --git a/src/editor/lib/EditorControls.js b/src/editor/lib/EditorControls.js index cf899f2d..4b92530d 100644 --- a/src/editor/lib/EditorControls.js +++ b/src/editor/lib/EditorControls.js @@ -291,8 +291,16 @@ THREE.EditorControls = function (_object, domElement) { function onMouseWheel(event) { event.preventDefault(); - // Normalize deltaY due to https://bugzilla.mozilla.org/show_bug.cgi?id=1392460 - scope.zoom(delta.set(0, 0, event.deltaY > 0 ? 1 : -1)); + if (Math.abs(event.deltaY) > Math.abs(event.deltaX)) { + // Normalize deltaY due to https://bugzilla.mozilla.org/show_bug.cgi?id=1392460 + scope.zoom(delta.set(0, 0, event.deltaY > 0 ? 1 : -1)); + } else { + if (event.deltaX !== 0) { + // Pan the camera horizontally based on deltaX + // We use a smaller multiplier for horizontal scroll to make it less sensitive + scope.pan(delta.set(event.deltaX > 0 ? 10 : -10, 0, 0)); + } + } } function contextmenu(event) { diff --git a/src/index.js b/src/index.js index b8817c5e..cdbb7b76 100644 --- a/src/index.js +++ b/src/index.js @@ -2,9 +2,9 @@ import 'aframe-cursor-teleport-component'; import 'aframe-extras/controls/index.js'; import useStore from './store.js'; +require('./json-utils_1.1.js'); var streetmixParsers = require('./aframe-streetmix-parsers'); var streetmixUtils = require('./tested/streetmix-utils'); -require('./json-utils_1.1.js'); var streetUtils = require('./street-utils.js'); require('./components/gltf-part'); require('./components/ocean'); @@ -21,6 +21,13 @@ require('./components/street-geo.js'); require('./components/street-environment.js'); require('./components/intersection.js'); require('./components/obb-clipping.js'); +require('./components/street-segment.js'); +require('./components/managed-street.js'); +require('./components/street-generated-stencil.js'); +require('./components/street-generated-striping.js'); +require('./components/street-generated-pedestrians.js'); +require('./components/street-generated-rail.js'); +require('./components/street-generated-clones.js'); require('./editor/index.js'); const state = useStore.getState(); diff --git a/src/segments-variants.js b/src/segments-variants.js index 5680c6ec..4d5afd17 100644 --- a/src/segments-variants.js +++ b/src/segments-variants.js @@ -59,6 +59,14 @@ const segmentVariants = { 'inbound|red|typical', 'outbound|red|typical' ], + 'brt-lane': [ + 'inbound|colored', + 'outbound|colored', + 'inbound|regular', + 'outbound|regular', + 'inbound|red', + 'outbound|red' + ], 'drive-lane': [ 'inbound|car', 'outbound|car', @@ -134,7 +142,7 @@ const segmentVariants = { 'outbound|grass' ], // stations - 'brt-station': ['center'], + 'brt-station': ['center', 'left', 'right'], 'transit-shelter': [ 'left|street-level', 'right|street-level',