diff --git a/src/assets.js b/src/assets.js index 0ebdd4a50..4c9f6db3c 100644 --- a/src/assets.js +++ b/src/assets.js @@ -147,6 +147,7 @@ function buildAssetHTML(assetUrl, categories) { + diff --git a/src/components/managed-street.js b/src/components/managed-street.js index 32bdcf0ff..285ce8d88 100644 --- a/src/components/managed-street.js +++ b/src/components/managed-street.js @@ -5,6 +5,206 @@ const { segmentVariants } = require('../segments-variants.js'); const streetmixUtils = require('../tested/streetmix-utils'); const streetmixParsersTested = require('../tested/aframe-streetmix-parsers-tested'); +// STREETPLAN HELPER FUNCTIONS +// Material mapping from Streetplan to 3DStreet surfaces +const STREETPLAN_MATERIAL_MAPPING = { + 'asphalt black': { surface: 'asphalt', color: '#aaaaaa' }, + 'asphalt blue': { surface: 'asphalt', color: '#aaaaff' }, + 'asphalt red 1': { surface: 'asphalt', color: '#ffaaaa' }, + 'asphalt red 2': { surface: 'asphalt', color: '#ff0000' }, + 'asphalt green': { surface: 'asphalt', color: '#aaffaa' }, + 'asphalt old': { surface: 'asphalt' }, + 'standard concrete': { surface: 'concrete' }, + grass: { surface: 'grass' }, + 'grass dead': { surface: 'grass' }, + 'pavers tan': { surface: 'sidewalk' }, + 'pavers brown': { surface: 'sidewalk' }, + 'pavers mixed': { surface: 'sidewalk' }, + 'pavers red': { surface: 'sidewalk', color: '#ffaaaa' }, + 'tint conc. or dirt': { surface: 'gravel' }, + dirt: { surface: 'gravel' }, + gravel: { surface: 'gravel' }, + stonetan: { surface: 'sidewalk' }, + 'sidewalk 2': { surface: 'sidewalk' }, + 'cobble stone': { surface: 'sidewalk' }, + 'solid black': { surface: 'solid' }, + 'painted intersection': { surface: 'asphalt' }, + 'grass with edging': { surface: 'grass' }, + xeriscape: { surface: 'grass' }, + 'grassslopemedian 12ft': { surface: 'grass' }, + 'grassslopemedian 24ft': { surface: 'grass' }, + 'grassslope 12ft-left': { surface: 'grass' }, + 'grassslope 12ft-right': { surface: 'grass' }, + 'grassslope 24ft-left': { surface: 'grass' }, + 'grassslope 24ft-right': { surface: 'grass' }, + sand: { surface: 'sand' } +}; + +const STREETPLAN_OBJECT_MAPPING = { + 'away, left park, head in': '', + 'barrier 1-ft': 'temporary-jersey-barrier-concrete', + 'barrier 2-ft': 'temporary-jersey-barrier-concrete', + 'bike food cart': '', + 'bikelane sharecar': '', + 'bikerack bollard': '', + 'blank pedrefuge (8ft)': '', + 'blue car': 'sedan-rig', + 'blue mailbox': 'usps-mailbox', + 'bollard plastic yellow': 'bollard', + boulevardcirculator: 'minibus', + 'boulevardcirculator rev': 'minibus', + 'boxwood planter 2ft': 'dividers-planter-box', + 'boxwood planter 3ft': 'dividers-planter-box', + 'boxwood planter 5ft': 'dividers-planter-box', + 'bur oak': 'tree3', + bus: 'bus', + 'bus rev': 'bus', + 'cactus median (10ft)': 'dividers-bush', + 'cactus median (12ft)': 'dividers-bush', + 'cactus median (4ft)': 'dividers-bush', + 'cactus median (6ft)': 'dividers-bush', + 'cactus median (8ft)': 'dividers-bush', + 'casual woman': '', + couple: '', + 'couple biking': '', + 'desertwillow texas': 'tree3', + 'dog walker': '', + 'empty place holder': '', + 'english oak': 'tree3', + 'fleamarket stuff': '', + 'flower median (10ft)': 'dividers-flowers', + 'flower median (12ft)': 'dividers-flowers', + 'flower median (4ft)': 'dividers-flowers', + 'flower median (6ft)': 'dividers-flowers', + 'flower median (8ft)': 'dividers-flowers', + 'flower pot 4ft': 'dividers-flowers', + 'floweringpear 18ft': 'tree3', + 'flowers pedrefuge (8ft)': 'dividers-flowers', + goldenraintree: 'tree3', + 'golfcart red 4ft back': 'tuk-tuk', + 'grassmound (10ft)': '', + 'grassmound (12ft)': '', + 'grassmound (4ft)': '', + 'grassmound (6ft)': '', + 'grassmound (8ft)': '', + 'grassy median (10ft)': '', + 'grassy median (12ft)': '', + 'grassy median (4ft)': '', + 'grassy median (6ft)': '', + 'grassy median (8ft)': '', + 'green car': 'sedan-rig', + 'heavy rail': 'tram', + 'heavy rail rev': 'tram', + 'historic light': 'lamp-traditional', + 'historic no banner': 'lamp-traditional', + 'historic with banners': 'lamp-traditional', + 'historic with flowers 1': 'lamp-traditional', + 'historic with flowers 2': 'lamp-traditional', + honeylocust: 'tree3', + 'japanese lilac': 'tree3', + 'japanese zelkova': 'tree3', + 'jerusalem thorn': 'tree3', + 'kentucky coffeetree': 'tree3', + 'large food cart': '', + 'large oak': 'tree3', + 'light rail poles': '', + 'moto highway rider': 'motorbike', + 'mountable barrier 1-ft': '', + 'nev shuttle back': 'minibus', + 'nev shuttle front': 'minibus', + 'nyc bike rack': 'bikerack', + 'orange barrel': 'temporary-traffic-cone', + 'palm tree': 'palm-tree', + 'palmtree 20ft': 'palm-tree', + 'palmtree 28ft': 'palm-tree', + 'pine tree': 'tree3', + 'pink flower 16ft': 'tree3', + 'planter flowers': 'dividers-flowers', + 'planter with bench': 'bench', + 'polaris gem e4': 'tuk-tuk', + 'power tower 30ft': '', + 'purpendicular right side, blue': '', + 'purpendicular right side, red': '', + 'purpleleaf plum': 'tree3', + 'random trashcan': 'trash-bin', + 'red berries 14ft': 'tree3', + 'red car': 'sedan-rig', + 'red jeep': 'suv-rig', + 'rock median (10ft)': '', + 'rock median (12ft)': '', + 'rock median (4ft)': '', + 'rock median (6ft)': '', + 'rock median (8ft)': '', + 'semi truck': 'box-truck-rig', + 'serious man': '', + shelter: 'bus-stop', + 'shelter roundroof': 'bus-stop', + 'sign directory': 'wayfinding', + 'silver suv': 'suv-rig', + 'small tree': 'tree3', + smallnev: 'minibus', + 'smartcar 5ft': 'self-driving-cruise-car-rig', + 'soundwall (12ft)': '', + 'soundwall (8ft)': '', + 'soundwall plants (12ft)': '', + 'soundwall plants (8ft)': '', + 'street light': 'lamp-modern', + 'streetcar blue': 'trolley', + 'streetcar red 1': 'trolley', + 'streetcar red 2': 'trolley', + 'streetcar yellow': 'trolley', + 'streetlight solar': 'lamp-modern', + 'streetlight solar banners 1': 'lamp-modern', + 'streetlight solar banners 2': 'lamp-modern', + tallgrass: '', + 'tallplantbox (10ft)': '', + 'tallplantbox (12ft)': 'dividers-bush', + 'tallplantbox (4ft)': '', + 'tallplantbox (6ft)': '', + 'tallplantbox (8ft)': '', + 'tallplantbox pedref (10ft)': '', + 'tallplantbox pedref (12ft)': '', + 'tallplantbox pedref (6ft)': '', + 'tallplantbox pedref (8ft)': '', + 'telephone pole': 'utility_pole', + 'tent bluewhite': '', + 'tent veggie': '', + 'toward, right park, head in': '', + trashcan: 'trash-bin', + 'tropical median (4ft)': 'palm-tree', + 'two bikes back': '', + 'uta bus': 'bus', + 'uta lightrail': 'tram', + 'uta lightrail rev': 'tram', + 'weeds median (4ft)': '', + 'weeds median (6ft)': '', + 'weeds median (8ft)': '', + 'white coup': 'sedan-rig', + 'white sedan': 'sedan-rig', + 'white truck': 'box-truck-rig', + 'yellow sedan': 'sedan-rig' +}; + +// Streetplan Helper function to parse O-Tags string into array +function parseOTags(tags) { + if (!tags || tags === '-') return []; + return tags.split('", "').map((t) => t.replace(/"/g, '').trim()); +} + +// Streetplan Helper function to create clone configuration +function createCloneConfig(name, tags) { + if (!name || name === '-') return null; + + const model = STREETPLAN_OBJECT_MAPPING[name.toLowerCase()]; + if (!model) return null; + + return { + mode: 'fixed', // default to fixed mode + model: model, + spacing: 15 // default spacing + }; +} + AFRAME.registerComponent('managed-street', { schema: { width: { @@ -36,53 +236,179 @@ AFRAME.registerComponent('managed-street', { type: 'boolean', default: true }, - justifyWidth: { - default: 'center', - type: 'string', - oneOf: ['center', 'left', 'right'] + enableAlignment: { + type: 'boolean', + default: true }, - justifyLength: { - default: 'middle', - type: 'string', - oneOf: ['middle', 'start', 'end'] + showGround: { + type: 'boolean', + default: true } }, init: function () { this.managedEntities = []; this.pendingEntities = []; + this.actualWidth = 0; // Bind the method to preserve context this.refreshFromSource = this.refreshFromSource.bind(this); + this.onSegmentWidthChanged = this.onSegmentWidthChanged.bind(this); + + if (this.data.enableAlignment && !this.el.hasAttribute('street-align')) { + this.el.setAttribute('street-align', ''); + } + if (this.data.showGround && !this.el.hasAttribute('street-ground')) { + this.el.setAttribute('street-ground', ''); + } + if (!this.el.hasAttribute('street-label')) { + this.el.setAttribute('street-label', ''); + } + + this.setupEventDispatcher(); + + setTimeout(() => { + this.attachListenersToExistingSegments(); + }, 0); }, - setupMutationObserver: function () { - // Create mutation observer + attachListenersToExistingSegments: function () { + const segments = this.el.querySelectorAll('[street-segment]'); + segments.forEach((segment) => { + console.log('Attaching width change listener to existing segment'); + segment.addEventListener( + 'segment-width-changed', + this.onSegmentWidthChanged + ); + }); + }, + /** + * Inserts a new street segment at the specified index + * @param {number} index - The index at which to insert the new segment + * @param {string} type - The segment type (e.g., 'drive-lane', 'bike-lane') + * @param {Object} [segmentObject] - Optional configuration object for the segment + * @returns {Element} The created segment element + */ + insertSegment: function (index, type, segmentObject = null) { + // Validate index + if (index < 0 || index > this.managedEntities.length) { + console.error('[managed-street] Invalid index for insertion:', index); + return; + } + + // Create new segment entity + const segmentEl = document.createElement('a-entity'); + + // Get default properties for this segment type from STREET.types + const defaultProps = window.STREET.types[type] || {}; + + // Set up basic segment properties, merging defaults with any provided custom properties + const segmentProps = { + type: type, + width: segmentObject?.width || defaultProps.width || 3, + length: this.data.length, + level: segmentObject?.level ?? defaultProps.level ?? 0, + direction: + segmentObject?.direction || defaultProps.direction || 'outbound', + color: + segmentObject?.color || + defaultProps.color || + window.STREET.colors.white, + surface: segmentObject?.surface || defaultProps.surface || 'asphalt' + }; + + // Set the segment component with properties + segmentEl.setAttribute('street-segment', segmentProps); + + // Set the layer name for the segment + const layerName = segmentObject?.name || `${type} • default`; + segmentEl.setAttribute('data-layer-name', layerName); + + // If custom segment object is provided, wait for segment to load then generate its components + if (segmentObject) { + segmentEl.addEventListener('loaded', () => { + // Use the generateComponentsFromSegmentObject method from street-segment component + const streetSegmentComponent = segmentEl.components['street-segment']; + if (streetSegmentComponent) { + streetSegmentComponent.generateComponentsFromSegmentObject( + segmentObject + ); + } + }); + } + + // Insert the segment at the specified index in the DOM + const referenceNode = this.managedEntities[index] ?? null; + this.el.insertBefore(segmentEl, referenceNode); + + // Wait for the segment to be fully loaded + segmentEl.addEventListener('loaded', () => { + // Refresh the managed entities list + this.refreshManagedEntities(); + + // Update the total width + const totalWidth = this.managedEntities.reduce((sum, segment) => { + return sum + (segment.getAttribute('street-segment').width || 0); + }, 0); + this.el.setAttribute('managed-street', 'width', totalWidth); + + // If we have a previous segment, check if we need to add stripe separators + // TODO: Check striping here in the future + }); + + return segmentEl; + }, + setupEventDispatcher: function () { + // Remove if existing mutation observer if (this.observer) { this.observer.disconnect(); } - this.observer = new MutationObserver((mutations) => { - let needsReflow = false; + // Mutation observer for add/remove + const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { - if (mutation.type === 'childList' && mutation.removedNodes.length > 0) { - // Check if any of the removed nodes were street segments - mutation.removedNodes.forEach((node) => { - if (node.hasAttribute && node.hasAttribute('street-segment')) { - needsReflow = true; - } + if (mutation.type === 'childList') { + const addedSegments = Array.from(mutation.addedNodes).filter( + (node) => node.hasAttribute && node.hasAttribute('street-segment') + ); + const removedSegments = Array.from(mutation.removedNodes).filter( + (node) => node.hasAttribute && node.hasAttribute('street-segment') + ); + + // Add listeners to new segments + addedSegments.forEach((segment) => { + segment.addEventListener( + 'segment-width-changed', + this.onSegmentWidthChanged + ); + }); + + // Remove listeners from removed segments + removedSegments.forEach((segment) => { + segment.removeEventListener( + 'segment-width-changed', + this.onSegmentWidthChanged + ); }); + + if (addedSegments.length || removedSegments.length) { + this.el.emit('segments-changed', { + changeType: 'structure', + added: addedSegments, + removed: removedSegments + }); + } } }); - - // If segments were removed, trigger reflow - if (needsReflow) { - this.refreshManagedEntities(); - this.applyJustification(); - this.createOrUpdateJustifiedDirtBox(); - } }); - // Start observing the managed-street element - this.observer.observe(this.el, { - childList: true // watch for child additions/removals + observer.observe(this.el, { childList: true }); + }, + onSegmentWidthChanged: function (event) { + console.log('segment width changed handler called', event); + this.el.emit('segments-changed', { + changeType: 'property', + property: 'width', + segment: event.target, + oldValue: event.detail.oldWidth, + newValue: event.detail.newWidth }); }, update: function (oldData) { @@ -95,35 +421,21 @@ AFRAME.registerComponent('managed-street', { } 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 + this.setupEventDispatcher(); }, refreshFromSource: function () { const data = this.data; if (data.sourceType === 'streetmix-url') { this.loadAndParseStreetmixURL(data.sourceValue); } else if (data.sourceType === 'streetplan-url') { - // this function is not yet implemented - this.refreshFromStreetplanURL(data.sourceValue); + this.loadAndParseStreetplanURL(data.sourceValue); } else if (data.sourceType === 'json-blob') { // if data.sourceValue is a string convert string to object for parsing but keep string for saving if (typeof data.sourceValue === 'string') { @@ -145,98 +457,16 @@ AFRAME.registerComponent('managed-street', { 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]') ); - this.setupMutationObserver(); - }, - 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'); - dirtBox.setAttribute('data-no-transform', ''); - dirtBox.setAttribute('data-ignore-raycaster', ''); - } - 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}` - ); + // calculate actual width + this.actualWidth = this.managedEntities.reduce((sum, segment) => { + return sum + (segment.getAttribute('street-segment')?.width || 0); + }, 0); + console.log('actual width', this.actualWidth); }, parseStreetObject: function (streetObject) { // reset and delete all existing entities @@ -252,6 +482,7 @@ AFRAME.registerComponent('managed-street', { for (let i = 0; i < streetObject.segments.length; i++) { const segment = streetObject.segments[i]; + const previousSegment = streetObject.segments[i - 1]; const segmentEl = document.createElement('a-entity'); this.el.appendChild(segmentEl); @@ -267,13 +498,211 @@ AFRAME.registerComponent('managed-street', { segmentEl.setAttribute('data-layer-name', segment.name); // wait for street-segment to be loaded, then generate components from segment object segmentEl.addEventListener('loaded', () => { + if (!segment.generated?.striping) { + const stripingVariant = this.getStripingFromSegments( + previousSegment, + segment + ); + if (stripingVariant) { + // Only add striping if variant is not null + if (!segment.generated) { + segment.generated = {}; + } + segment.generated.striping = [ + { + striping: stripingVariant, + length: streetObject.length, + segmentWidth: segment.width + } + ]; + } + } segmentEl.components[ 'street-segment' ].generateComponentsFromSegmentObject(segment); - this.applyJustification(); }); } }, + loadAndParseStreetplanURL: async function (streetplanURL) { + console.log( + '[managed-street] loader', + 'sourceType: `streetplan-url`, loading from', + streetplanURL + ); + + try { + const response = await fetch(streetplanURL); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const streetplanData = await response.json(); + const boulevard = streetplanData.project['My Street']['Boulevard Alt 1']; + + const streetLength = + parseFloat(streetplanData.project['My Street'].LengthMiles) * + 5280 * + 0.3048 || 100; // Convert miles to meters + // Convert StreetPlan format to managed-street format + const streetObject = { + name: streetplanData.project.ProjectName, + width: 0, // Will be calculated from segments + length: streetLength, + segments: [] + }; + + // Process streetplan segments + const segments = boulevard.segments; + for (const segmentKey in segments) { + const segment = segments[segmentKey]; + + // Skip Buildings and Setback segments + if (segment.Type === 'Buildings' || segment.Type === 'Setback') { + continue; + } + + const segmentWidth = parseFloat(segment.width) * 0.3048; // Convert feet to meters + streetObject.width += segmentWidth; + + // Convert streetplan segment type based on your schema + let segmentType = 'drive-lane'; // Default type + let segmentDirection = 'inbound'; + + // convert from streetplan type to managed street default type + switch (segment.Type) { + case 'BikesPaths': + segmentType = 'bike-lane'; + break; + case 'Walkways': + segmentType = 'sidewalk'; + break; + case 'Transit': + segmentType = 'bus-lane'; + break; + case 'Median/Buffer': + segmentType = 'divider'; + break; + case 'Curbside': + segmentType = 'divider'; + break; + case 'Gutter': + segmentType = 'parking-lane'; + break; + case 'Furniture': + segmentType = 'sidewalk-tree'; + break; + // Add more type mappings as needed + } + + // Determine direction based on segment data + if (segment.Direction === 'Coming') { + segmentDirection = 'inbound'; + } else if (segment.Direction === 'Going') { + segmentDirection = 'outbound'; + } + + // Map the material using the STREETPLAN_MATERIAL_MAPPING, fallback to 'asphalt' if not found + const material = segment.Material?.toLowerCase() || ''; + const mappedSurface = + STREETPLAN_MATERIAL_MAPPING[material]?.surface || 'asphalt'; + const mappedColor = STREETPLAN_MATERIAL_MAPPING[material]?.color; + + // Map the O-Tags to clone configurations + const generated = {}; + const clones = []; + // Process O1, O2, O3 configurations + ['O1', 'O2', 'O3'].forEach((prefix) => { + const name = segment[`${prefix}-Name`]; + const tags = parseOTags(segment[`${prefix}-Tags`]); + const cloneConfig = createCloneConfig(name, tags); + if (cloneConfig) { + clones.push(cloneConfig); + } + }); + if (clones.length > 0) { + generated.clones = clones; + } + + streetObject.segments.push({ + type: segmentType, + width: segmentWidth, + name: segment.title, + level: parseFloat(segment.MaterialH) === 0.5 ? 1 : 0, + direction: segmentDirection, + color: mappedColor || window.STREET.types[segmentType]?.color, + surface: mappedSurface, + generated: clones.length > 0 ? generated : undefined + }); + } + + // Parse the street object + this.parseStreetObject(streetObject); + } catch (error) { + console.error('[managed-street] loader', 'Loading Error:', error); + STREET.notify.warningMessage( + 'Error loading StreetPlan data: ' + error.message + ); + } + }, + + getStripingFromSegments: function (previousSegment, currentSegment) { + if (!previousSegment || !currentSegment) { + return null; + } + + // Valid lane types that should have striping + const validLaneTypes = [ + 'drive-lane', + 'bus-lane', + 'bike-lane', + 'parking-lane' + ]; + + // Only add striping between valid lane types + if ( + !validLaneTypes.includes(previousSegment.type) || + !validLaneTypes.includes(currentSegment.type) + ) { + return null; + } + + // Default to solid line + let variantString = 'solid-stripe'; + + // Check for opposite directions + if ( + previousSegment.direction !== currentSegment.direction && + previousSegment.direction !== 'none' && + currentSegment.direction !== 'none' + ) { + variantString = 'solid-doubleyellow'; + + // Special case for bike lanes + if ( + currentSegment.type === 'bike-lane' && + previousSegment.type === 'bike-lane' + ) { + variantString = 'short-dashed-stripe-yellow'; + } + } else { + // Same direction cases + if (currentSegment.type === previousSegment.type) { + variantString = 'dashed-stripe'; + } + + // Drive lane and turn lane combination would go here if needed + } + + // Special case for parking lanes - use dashed line between parking and drive lanes + if ( + currentSegment.type === 'parking-lane' || + previousSegment.type === 'parking-lane' + ) { + variantString = 'solid-stripe'; + } + + return variantString; + }, loadAndParseStreetmixURL: async function (streetmixURL) { const data = this.data; const streetmixAPIURL = streetmixUtils.streetmixUserToAPI(streetmixURL); @@ -338,8 +767,6 @@ AFRAME.registerComponent('managed-street', { // When all entities are loaded, do something with them this.allLoadedPromise.then(() => { this.refreshManagedEntities(); - this.applyJustification(); - this.createOrUpdateJustifiedDirtBox(); AFRAME.INSPECTOR.selectEntity(this.el); }); } catch (error) { @@ -456,7 +883,7 @@ function getSeparatorMixinId(previousSegment, currentSegment) { currentSegment.type === 'parking-lane' || previousSegment.type === 'parking-lane' ) { - variantString = 'invisible'; + variantString = 'solid-stripe'; } return variantString; diff --git a/src/components/polygon-offset.js b/src/components/polygon-offset.js new file mode 100644 index 000000000..030cebe99 --- /dev/null +++ b/src/components/polygon-offset.js @@ -0,0 +1,80 @@ +// Component to fix z-fighting by adding polygon offset to meshes +AFRAME.registerComponent('polygon-offset', { + schema: { + // Negative values move fragments closer to camera + factor: { type: 'number', default: -2 }, + units: { type: 'number', default: -2 } + }, + + init: function () { + // Initial update when mesh is loaded + const mesh = this.el.getObject3D('mesh'); + if (mesh) { + this.applyPolygonOffsetToObject(mesh); + } + + // Listen for model-loaded event + this.el.addEventListener('model-loaded', (evt) => { + const mesh = this.el.getObject3D('mesh'); + this.applyPolygonOffsetToObject(mesh); + }); + }, + + applyPolygonOffsetToObject: function (object3D) { + if (!object3D) return; + + object3D.traverse((obj) => { + if (obj.isMesh) { + if (Array.isArray(obj.material)) { + obj.material.forEach((material) => { + this.updateMaterial(material); + }); + } else { + this.updateMaterial(obj.material); + } + } + }); + }, + + updateMaterial: function (material) { + if (!material) return; + + material.polygonOffset = true; + material.polygonOffsetFactor = this.data.factor; + material.polygonOffsetUnits = this.data.units; + + // Ensure material updates + material.needsUpdate = true; + }, + + update: function (oldData) { + // Handle property updates + const mesh = this.el.getObject3D('mesh'); + if (mesh) { + this.applyPolygonOffsetToObject(mesh); + } + }, + + remove: function () { + const mesh = this.el.getObject3D('mesh'); + if (mesh) { + mesh.traverse((obj) => { + if (obj.isMesh) { + if (Array.isArray(obj.material)) { + obj.material.forEach((material) => { + material.polygonOffset = false; + material.polygonOffsetFactor = 0; + material.polygonOffsetUnits = 0; + material.needsUpdate = true; + }); + } else if (obj.material) { + obj.material.polygonOffset = false; + obj.material.polygonOffsetFactor = 0; + obj.material.polygonOffsetUnits = 0; + obj.material.needsUpdate = true; + } + } + }); + } + } +}); diff --git a/src/components/street-align.js b/src/components/street-align.js new file mode 100644 index 000000000..4a214a5ec --- /dev/null +++ b/src/components/street-align.js @@ -0,0 +1,96 @@ +/* global AFRAME */ + +AFRAME.registerComponent('street-align', { + dependencies: ['managed-street'], + schema: { + width: { + default: 'center', + type: 'string', + oneOf: ['center', 'left', 'right'] + }, + length: { + default: 'start', + type: 'string', + oneOf: ['middle', 'start', 'end'] + } + }, + + init: function () { + // Listen for any segment changes from managed-street + this.realignStreet = this.realignStreet.bind(this); + this.el.addEventListener('segments-changed', this.realignStreet); + + // wait for all components, including managed-street to be initialized + setTimeout(() => { + this.realignStreet(); + }, 0); + }, + + update: function (oldData) { + const data = this.data; + const diff = AFRAME.utils.diff(oldData, data); + + // Only realign if width or length alignment changed + if (diff.width !== undefined || diff.length !== undefined) { + this.el.emit('alignment-changed', { + changeType: 'alignment', + oldData: oldData, + newData: data + }); + this.realignStreet(); + } + }, + + realignStreet: function () { + const data = this.data; + + // Get all segments + const segments = Array.from(this.el.querySelectorAll('[street-segment]')); + if (segments.length === 0) return; + + // Calculate total width + const totalWidth = segments.reduce((sum, segment) => { + return sum + (segment.getAttribute('street-segment')?.width || 0); + }, 0); + console.log('total width', totalWidth); + + // Get street length from managed-street component + const streetLength = this.el.getAttribute('managed-street')?.length || 0; + + // Calculate starting positions + let xPosition = 0; + if (data.width === 'center') { + xPosition = -totalWidth / 2; + } else if (data.width === 'right') { + xPosition = -totalWidth; + } + + let zPosition = 0; + if (data.length === 'start') { + zPosition = -streetLength / 2; + } else if (data.length === 'end') { + zPosition = streetLength / 2; + } + + // Position segments + segments.forEach((segment) => { + const width = segment.getAttribute('street-segment')?.width; + const currentPos = segment.getAttribute('position'); + + xPosition += width / 2; + + segment.setAttribute('position', { + x: xPosition, + y: currentPos.y, + z: zPosition + }); + + xPosition += width / 2; + }); + }, + + remove: function () { + // Clean up event listener + this.el.removeEventListener('segments-changed', this.realignStreet); + } +}); diff --git a/src/components/street-generated-clones.js b/src/components/street-generated-clones.js index b29d5e0ef..419f604be 100644 --- a/src/components/street-generated-clones.js +++ b/src/components/street-generated-clones.js @@ -41,6 +41,39 @@ AFRAME.registerComponent('street-generated-clones', { this.createdEntities.length = 0; // Clear the array }, + detach: function () { + const commands = []; + commands.push([ + 'componentremove', + { entity: this.el, component: this.attrName } + ]); + let entityObjToPushAtTheEnd = null; // so that the entity is selected after executing the multi command + this.createdEntities.forEach((entity) => { + const position = entity.getAttribute('position'); + const rotation = entity.getAttribute('rotation'); + const entityObj = { + parentEl: this.el, // you can also put this.el.id here that way the command is fully json serializable but el currently doesn't have an id + mixin: entity.getAttribute('mixin'), + 'data-layer-name': entity + .getAttribute('data-layer-name') + .replace('Cloned Model', 'Detached Model'), + components: { + position: { x: position.x, y: position.y, z: position.z }, + rotation: { x: rotation.x, y: rotation.y, z: rotation.z } + } + }; + if (AFRAME.INSPECTOR?.selectedEntity === entity) { + entityObjToPushAtTheEnd = entityObj; + } else { + commands.push(['entitycreate', entityObj]); + } + }); + if (entityObjToPushAtTheEnd !== null) { + commands.push(['entitycreate', entityObjToPushAtTheEnd]); + } + AFRAME.INSPECTOR.execute('multi', commands); + }, + update: function (oldData) { // If mode is random or randomFacing and seed is 0, generate a random seed and return, // the update will be called again because of the setAttribute. @@ -137,6 +170,7 @@ AFRAME.registerComponent('street-generated-clones', { clone.classList.add('autocreated'); clone.setAttribute('data-no-transform', ''); clone.setAttribute('data-layer-name', 'Cloned Model • ' + mixinId); + clone.setAttribute('data-parent-component', this.attrName); this.el.appendChild(clone); this.createdEntities.push(clone); diff --git a/src/components/street-generated-label.js b/src/components/street-generated-label.js deleted file mode 100644 index 797f6a998..000000000 --- a/src/components/street-generated-label.js +++ /dev/null @@ -1,112 +0,0 @@ -/* 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 index 5fef3cb6b..4c99aefba 100644 --- a/src/components/street-generated-pedestrians.js +++ b/src/components/street-generated-pedestrians.js @@ -45,6 +45,38 @@ AFRAME.registerComponent('street-generated-pedestrians', { this.createdEntities.forEach((entity) => entity.remove()); this.createdEntities.length = 0; }, + detach: function () { + const commands = []; + commands.push([ + 'componentremove', + { entity: this.el, component: this.attrName } + ]); + let entityObjToPushAtTheEnd = null; // so that the entity is selected after executing the multi command + this.createdEntities.forEach((entity) => { + const position = entity.getAttribute('position'); + const rotation = entity.getAttribute('rotation'); + const entityObj = { + parentEl: this.el, // you can also put this.el.id here that way the command is fully json serializable but el currently doesn't have an id + mixin: entity.getAttribute('mixin'), + 'data-layer-name': entity + .getAttribute('data-layer-name') + .replace('Cloned Pedestrian', 'Detached Pedestrian'), + components: { + position: { x: position.x, y: position.y, z: position.z }, + rotation: { x: rotation.x, y: rotation.y, z: rotation.z } + } + }; + if (AFRAME.INSPECTOR?.selectedEntity === entity) { + entityObjToPushAtTheEnd = entityObj; + } else { + commands.push(['entitycreate', entityObj]); + } + }); + if (entityObjToPushAtTheEnd !== null) { + commands.push(['entitycreate', entityObjToPushAtTheEnd]); + } + AFRAME.INSPECTOR.execute('multi', commands); + }, update: function (oldData) { const data = this.data; @@ -109,7 +141,8 @@ AFRAME.registerComponent('street-generated-pedestrians', { // Add metadata pedestrian.classList.add('autocreated'); pedestrian.setAttribute('data-no-transform', ''); - pedestrian.setAttribute('data-layer-name', 'Generated Pedestrian'); + pedestrian.setAttribute('data-layer-name', 'Cloned Pedestrian'); + pedestrian.setAttribute('data-parent-component', this.attrName); this.createdEntities.push(pedestrian); } diff --git a/src/components/street-generated-stencil.js b/src/components/street-generated-stencil.js index b78e33969..542b6dfd1 100644 --- a/src/components/street-generated-stencil.js +++ b/src/components/street-generated-stencil.js @@ -99,6 +99,38 @@ AFRAME.registerComponent('street-generated-stencil', { this.createdEntities.forEach((entity) => entity.remove()); this.createdEntities.length = 0; // Clear the array }, + detach: function () { + const commands = []; + commands.push([ + 'componentremove', + { entity: this.el, component: this.attrName } + ]); + let entityObjToPushAtTheEnd = null; // so that the entity is selected after executing the multi command + this.createdEntities.forEach((entity) => { + const position = entity.getAttribute('position'); + const rotation = entity.getAttribute('rotation'); + const entityObj = { + parentEl: this.el, // you can also put this.el.id here that way the command is fully json serializable but el currently doesn't have an id + mixin: entity.getAttribute('mixin'), + 'data-layer-name': entity + .getAttribute('data-layer-name') + .replace('Cloned Model', 'Detached Model'), + components: { + position: { x: position.x, y: position.y, z: position.z }, + rotation: { x: rotation.x, y: rotation.y, z: rotation.z } + } + }; + if (AFRAME.INSPECTOR?.selectedEntity === entity) { + entityObjToPushAtTheEnd = entityObj; + } else { + commands.push(['entitycreate', entityObj]); + } + }); + if (entityObjToPushAtTheEnd !== null) { + commands.push(['entitycreate', entityObjToPushAtTheEnd]); + } + AFRAME.INSPECTOR.execute('multi', commands); + }, update: function (oldData) { const data = this.data; @@ -166,6 +198,8 @@ AFRAME.registerComponent('street-generated-stencil', { clone.classList.add('autocreated'); clone.setAttribute('data-no-transform', ''); clone.setAttribute('data-layer-name', `Cloned Model • ${stencilName}`); + clone.setAttribute('data-parent-component', this.attrName); + clone.setAttribute('polygon-offset', { factor: -2, units: -2 }); this.el.appendChild(clone); this.createdEntities.push(clone); diff --git a/src/components/street-generated-striping.js b/src/components/street-generated-striping.js index 8714eb722..f9f43d50c 100644 --- a/src/components/street-generated-striping.js +++ b/src/components/street-generated-striping.js @@ -7,7 +7,18 @@ AFRAME.registerComponent('street-generated-striping', { multiple: true, schema: { striping: { - type: 'string' + type: 'string', + oneOf: [ + 'none', + 'solid-stripe', + 'dashed-stripe', + 'short-dashed-stripe', + 'short-dashed-stripe-yellow', + 'solid-doubleyellow', + 'solid-dashed', + 'solid-dashed-yellow', + 'solid-dashed-yellow-mirror' + ] }, segmentWidth: { type: 'number' @@ -43,7 +54,7 @@ AFRAME.registerComponent('street-generated-striping', { // Clean up old entities this.remove(); - if (data.striping === 'invisible') { + if (!data.striping || data.striping === 'none') { return; } const clone = document.createElement('a-entity'); @@ -75,6 +86,8 @@ AFRAME.registerComponent('street-generated-striping', { 'data-layer-name', 'Cloned Striping • ' + stripingTextureId ); + clone.setAttribute('polygon-offset', { factor: -2, units: -2 }); + this.el.appendChild(clone); this.createdEntities.push(clone); }, @@ -106,6 +119,10 @@ AFRAME.registerComponent('street-generated-striping', { stripingTextureId = 'striping-solid-dashed'; color = '#f7d117'; stripingWidth = 0.4; + } else if (stripingName === 'solid-dashed-yellow-mirror') { + stripingTextureId = 'striping-solid-dashed-mirror'; + color = '#f7d117'; + stripingWidth = 0.4; } return { stripingTextureId, repeatY, color, stripingWidth }; } diff --git a/src/components/street-ground.js b/src/components/street-ground.js new file mode 100644 index 000000000..d2ec7e901 --- /dev/null +++ b/src/components/street-ground.js @@ -0,0 +1,93 @@ +AFRAME.registerComponent('street-ground', { + dependencies: ['managed-street', 'street-align'], + + init: function () { + this.createOrUpdateDirtbox = this.createOrUpdateDirtbox.bind(this); + + // Listen for any changes from managed-street + this.el.addEventListener('segments-changed', this.createOrUpdateDirtbox); + + // Listen for alignment changes + this.el.addEventListener('alignment-changed', this.createOrUpdateDirtbox); + + // Create initial dirtbox + setTimeout(() => { + this.createOrUpdateDirtbox(); + }, 0); + }, + + createOrUpdateDirtbox: function () { + // Find or create dirtbox element + if (!this.dirtbox) { + this.dirtbox = this.el.querySelector('.dirtbox'); + } + if (!this.dirtbox) { + this.dirtbox = document.createElement('a-box'); + this.dirtbox.classList.add('autocreated'); + this.dirtbox.classList.add('.dirtbox'); + this.el.append(this.dirtbox); + + this.dirtbox.setAttribute( + 'material', + `color: ${window.STREET.colors.brown};` + ); + this.dirtbox.setAttribute('data-layer-name', 'Underground'); + this.dirtbox.setAttribute('data-no-transform', ''); + this.dirtbox.setAttribute('data-ignore-raycaster', ''); + this.dirtbox.setAttribute('polygon-offset', { + factor: 4, + units: 4 + }); + } + + // Get all segments + const segments = Array.from(this.el.querySelectorAll('[street-segment]')); + if (segments.length === 0) return; + + const totalWidth = segments.reduce((sum, segment) => { + return sum + (segment.getAttribute('street-segment')?.width || 0); + }, 0); + const streetLength = this.el.getAttribute('managed-street')?.length || 0; + + // Update dirtbox dimensions + this.dirtbox.setAttribute('height', 2); + this.dirtbox.setAttribute('width', totalWidth); + this.dirtbox.setAttribute('depth', streetLength - 0.2); + + // Get alignment from street-align component + const streetAlign = this.el.components['street-align']; + const alignWidth = streetAlign?.data.width || 'center'; + const alignLength = streetAlign?.data.length || 'start'; + + // Calculate position based on alignment + let xPosition = 0; + if (alignWidth === 'center') { + xPosition = 0; + } else if (alignWidth === 'left') { + xPosition = totalWidth / 2; + } else if (alignWidth === 'right') { + xPosition = -totalWidth / 2; + } + + let zPosition = 0; + if (alignLength === 'start') { + zPosition = -streetLength / 2; + } else if (alignLength === 'end') { + zPosition = streetLength / 2; + } + + this.dirtbox.setAttribute('position', `${xPosition} -1 ${zPosition}`); + }, + + remove: function () { + // Clean up + if (this.dirtbox) { + this.dirtbox.remove(); + } + this.el.removeEventListener('segments-changed', this.createOrUpdateDirtbox); + this.el.removeEventListener( + 'alignment-changed', + this.createOrUpdateDirtbox + ); + } +}); diff --git a/src/components/street-label.js b/src/components/street-label.js new file mode 100644 index 000000000..8ce211272 --- /dev/null +++ b/src/components/street-label.js @@ -0,0 +1,276 @@ +/* global AFRAME */ + +AFRAME.registerComponent('street-label', { + dependencies: ['managed-street', 'street-align'], + + schema: { + enabled: { type: 'boolean', default: true }, + heightOffset: { type: 'number', default: -2 }, + rotation: { type: 'vec3', default: { x: 0, y: 0, z: 0 } }, + zOffset: { type: 'number', default: 1 }, + labelHeight: { type: 'number', default: 2.5 }, + baseCanvasWidth: { type: 'number', default: 4096 } + }, + + init: function () { + this.createdEntities = []; + this.canvas = null; + this.ctx = null; + + // Create and setup canvas + this.createAndSetupCanvas(); + + // Listen for segment & alignment changes + this.updateLabels = this.updateLabels.bind(this); + this.el.addEventListener('segments-changed', this.updateLabels); + this.el.addEventListener('alignment-changed', this.updateLabels); + + // Handle loading from saved scene + setTimeout(() => { + if (this.data.enabled) { + this.updateLabels(); + } + }, 0); + }, + + update: function (oldData) { + if (oldData && this.data.enabled !== oldData.enabled) { + if (!this.data.enabled) { + // Hide existing labels + this.createdEntities.forEach((entity) => { + entity.setAttribute('visible', false); + }); + } else { + // Show and update labels + this.createdEntities.forEach((entity) => { + entity.setAttribute('visible', true); + }); + this.updateLabels(); + } + } else if (this.data.enabled) { + this.updateLabels(); + } + }, + + updateLabels: function () { + const segments = Array.from(this.el.querySelectorAll('[street-segment]')); + if (segments.length === 0) return; + + const widthsArray = []; + const labelsArray = []; + + segments.forEach((segmentEl) => { + const segmentWidth = segmentEl.getAttribute('street-segment')?.width; + if (!segmentWidth) return; + + widthsArray.push(segmentWidth); + labelsArray.push(segmentEl.getAttribute('data-layer-name') || ''); + }); + + if (widthsArray.length !== labelsArray.length) { + console.error('Mismatch between widths and labels arrays'); + return; + } + + const totalWidth = widthsArray.reduce( + (sum, width) => sum + parseFloat(width), + 0 + ); + + this.updateCanvasDimensions(totalWidth); + this.drawLabels(widthsArray, labelsArray, totalWidth); + this.createLabelPlane(totalWidth); + }, + + createAndSetupCanvas: function () { + this.canvas = document.createElement('canvas'); + this.canvas.id = 'street-label-canvas'; + this.canvas.style.display = 'none'; + document.body.appendChild(this.canvas); + this.ctx = this.canvas.getContext('2d'); + }, + + updateCanvasDimensions: function (totalWidth) { + const aspectRatio = totalWidth / this.data.labelHeight; + + this.canvas.width = this.data.baseCanvasWidth; + this.canvas.height = Math.round(this.data.baseCanvasWidth / aspectRatio); + + this.fontSize = Math.round(this.canvas.height * 0.18); + this.subFontSize = Math.round(this.canvas.height * 0.14); + }, + + wrapText: function (text, maxWidth) { + const words = text.split(' '); + const lines = []; + let currentLine = words[0]; + + for (let i = 1; i < words.length; i++) { + const word = words[i]; + const width = this.ctx.measureText(currentLine + ' ' + word).width; + + if (width < maxWidth) { + currentLine += ' ' + word; + } else { + lines.push(currentLine); + currentLine = word; + } + } + lines.push(currentLine); + return lines; + }, + + drawMultilineText: function (lines, x, y, lineHeight) { + const totalHeight = (lines.length - 1) * lineHeight; + const startY = y - totalHeight / 2; + + lines.forEach((line, index) => { + const yPos = startY + index * lineHeight; + this.ctx.fillText(line, x, yPos); + }); + }, + + drawLabels: function (widthsArray, labelsArray, totalWidth) { + const { ctx, canvas } = this; + + // Clear canvas + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + let currentX = 0; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + widthsArray.forEach((width, index) => { + const segmentWidth = (parseFloat(width) / totalWidth) * canvas.width; + const maxLabelWidth = segmentWidth * 0.9; + + // Draw segment background + 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 width value + ctx.fillStyle = '#000000'; + ctx.font = `${this.fontSize}px Arial`; + const centerX = currentX + segmentWidth / 2; + const centerY = canvas.height / 2 - 50; + + const widthText = parseFloat(width).toFixed(1) + 'm'; + ctx.fillText(widthText, centerX, centerY - this.fontSize * 0.8); + + // Draw wrapped label text + if (labelsArray[index]) { + ctx.font = `${this.subFontSize}px Arial`; + const lines = this.wrapText(labelsArray[index], maxLabelWidth); + const lineHeight = this.subFontSize * 1.2; + this.drawMultilineText( + lines, + centerX, + centerY + this.fontSize * 0.4 + 75, + lineHeight + ); + } + + currentX += segmentWidth; + }); + + // Draw final border + ctx.strokeStyle = '#999999'; + ctx.beginPath(); + ctx.moveTo(canvas.width, 0); + ctx.lineTo(canvas.width, canvas.height); + ctx.stroke(); + }, + + createLabelPlane: function (totalWidth) { + // Remove existing entities + this.createdEntities.forEach((entity) => { + if (entity.parentNode) { + entity.parentNode.removeChild(entity); + } + }); + this.createdEntities = []; + + // Create new label plane + const plane = document.createElement('a-entity'); + + plane.setAttribute('geometry', { + primitive: 'plane', + width: totalWidth, + height: this.data.labelHeight + }); + + plane.setAttribute('material', { + src: '#street-label-canvas', + transparent: true, + alphaTest: 0.5 + }); + + // Get alignment from street-align component + const streetAlign = this.el.components['street-align']; + const alignWidth = streetAlign?.data.width || 'center'; + const alignLength = streetAlign?.data.length || 'start'; + + // Get street length from managed-street component + const streetLength = this.el.getAttribute('managed-street')?.length || 0; + + // Calculate x position based on width alignment + let xPosition = 0; + if (alignWidth === 'center') { + xPosition = 0; + } else if (alignWidth === 'left') { + xPosition = totalWidth / 2; + } else if (alignWidth === 'right') { + xPosition = -totalWidth / 2; + } + + // Calculate z position based on length alignment + let zPosition = this.data.zOffset; // for 'start' alignment + if (alignLength === 'middle') { + zPosition = streetLength / 2 + this.data.zOffset; + } else if (alignLength === 'end') { + zPosition = streetLength + this.data.zOffset; + } + + plane.setAttribute( + 'position', + `${xPosition} ${this.data.heightOffset} ${zPosition}` + ); + plane.setAttribute( + 'rotation', + `${this.data.rotation.x} ${this.data.rotation.y} ${this.data.rotation.z}` + ); + plane.setAttribute('data-layer-name', 'Segment Labels'); + plane.classList.add('autocreated'); + + this.el.appendChild(plane); + this.createdEntities.push(plane); + }, + + remove: function () { + // Clean up canvas + if (this.canvas && this.canvas.parentNode) { + this.canvas.parentNode.removeChild(this.canvas); + } + + // Remove created entities + this.createdEntities.forEach((entity) => { + if (entity.parentNode) { + entity.parentNode.removeChild(entity); + } + }); + this.createdEntities = []; + + // Remove event listener + this.el.removeEventListener('segments-changed', this.updateLabels); + this.el.removeEventListener('alignment-changed', this.updateLabels); + } +}); diff --git a/src/components/street-segment.js b/src/components/street-segment.js index ecac7345c..4bdaed899 100644 --- a/src/components/street-segment.js +++ b/src/components/street-segment.js @@ -223,20 +223,23 @@ AFRAME.registerComponent('street-segment', { if (componentsToGenerate?.stencil?.length > 0) { componentsToGenerate.stencil.forEach((clone, index) => { if (clone?.stencils?.length > 0) { + // case where there are multiple stencils such as bus-only this.el.setAttribute(`street-generated-stencil__${index}`, { stencils: clone.stencils, length: this.data.length, spacing: clone.spacing, - direction: this.data.direction, - padding: clone.padding + direction: clone.direction ?? this.data.direction, + padding: clone.padding, + cycleOffset: clone.cycleOffset }); } 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 + direction: clone.direction ?? this.data.direction, + count: clone.count, + cycleOffset: clone.cycleOffset }); } }); @@ -252,6 +255,19 @@ AFRAME.registerComponent('street-segment', { }); }); } + + if (componentsToGenerate?.striping?.length > 0) { + componentsToGenerate.striping.forEach((stripe, index) => { + this.el.setAttribute(`street-generated-striping__${index}`, { + striping: stripe.striping, + segmentWidth: this.data.width, + length: this.data.length, + positionY: stripe.positionY ?? 0.05, // Default to 0.05 if not specified + side: stripe.side ?? 'left', // Default to left if not specified + facing: stripe.facing ?? 0 // Default to 0 if not specified + }); + }); + } }, updateSurfaceFromType: function (typeObject) { // update color, surface, level from segment type preset @@ -310,8 +326,11 @@ AFRAME.registerComponent('street-segment', { 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(); + console.log('segment width changed'); + this.el.emit('segment-width-changed', { + oldWidth: oldData.width, + newWidth: data.width + }); } }, // for streetmix elevation number values of -1, 0, 1, 2, calculate heightLevel in three.js meters units diff --git a/src/components/streetplan-loader.js b/src/components/streetplan-loader.js deleted file mode 100644 index a6f5429b9..000000000 --- a/src/components/streetplan-loader.js +++ /dev/null @@ -1,139 +0,0 @@ -/* global AFRAME, XMLHttpRequest */ -import useStore from '../store.js'; -var streetplanUtils = require('../streetplan/streetplan-utils.js'); - -AFRAME.registerComponent('streetplan-loader', { - dependencies: ['street'], - schema: { - streetplanStreetURL: { type: 'string' }, - streetplanAPIURL: { type: 'string' }, - streetplanEncJSON: { type: 'string' }, - showBuildings: { default: true }, - name: { default: '' }, - synchronize: { default: false } - }, - streetplanResponseParse: function (streetplanResponseObject) { - const el = this.el; - const data = this.data; - if (!streetplanResponseObject || !streetplanResponseObject.project) { - console.error('[streetplan-loader] Invalid streetplan data structure'); - return; - } - try { - // convert Streetplan structure to Streetmix-like structure - const streetData = streetplanUtils.convertStreetStruct( - streetplanResponseObject - ); - - const streetplanSegments = streetData.segments; - const streetplanName = streetData.name; - // const projectName = streetData.projectName || streetplanName; - - // Update layer name with project and street names - el.setAttribute('data-layer-name', `StreetPlan • ${streetplanName}`); - - const state = useStore.getState(); - if (!state.sceneTitle) { - state.setSceneTitle(streetplanName); - } - - // Handle buildings if enabled - if (data.showBuildings) { - // Find building segments in the full data - const buildingSegments = streetplanSegments.filter( - (segment) => segment.type === 'Buildings' - ); - - // Set building variants based on side - const leftBuilding = buildingSegments.find((b) => b.side === 'left'); - const rightBuilding = buildingSegments.find((b) => b.side === 'right'); - - if (leftBuilding) { - el.setAttribute('street', 'left', leftBuilding.title); - } - if (rightBuilding) { - el.setAttribute('street', 'right', rightBuilding.title); - } - } - // Set street type - el.setAttribute('street', 'type', 'streetmixSegmentsMetric'); - - // Filter out building segments for the main street data if needed - const finalSegments = data.showBuildings - ? streetplanSegments.filter((s) => s.type !== 'Buildings') - : streetplanSegments; - - // Set JSON attribute last - el.setAttribute( - 'street', - 'JSON', - JSON.stringify({ streetmixSegmentsMetric: finalSegments }) - ); - el.emit('streetplan-loader-street-loaded'); - } catch (error) { - console.error('[streetplan-loader] Error parsing street data:', error); - el.emit('streetplan-loader-error', { error }); - } - }, - init: function () { - this.el.setAttribute('streetplan-loader', 'synchronize', true); - }, - update: function (oldData) { - const data = this.data; - - // Skip update if synchronization is disabled - if (!data.synchronize) return; - - // Handle URL encoded JSON data - if (data.streetplanEncJSON) { - try { - const streetplanJSON = decodeURIComponent(data.streetplanEncJSON); - this.streetplanResponseParse(JSON.parse(streetplanJSON)); - } catch (error) { - console.error('[streetplan-loader] Error parsing encoded JSON:', error); - this.el.emit('streetplan-loader-error', { error }); - } - return; - } - - // Skip if URLs haven't changed - if ( - oldData.streetplanStreetURL === data.streetplanStreetURL && - oldData.streetplanAPIURL === data.streetplanAPIURL - ) { - return; - } - - // Load from API - const request = new XMLHttpRequest(); - console.log('[streetplan-loader]', 'GET ' + data.streetplanAPIURL); - - request.open('GET', data.streetplanAPIURL, true); - request.onload = () => { - if (request.status >= 200 && request.status < 400) { - try { - const streetplanResponseObject = JSON.parse(request.response); - this.streetplanResponseParse(streetplanResponseObject); - this.el.setAttribute('streetplan-loader', 'synchronize', false); - } catch (error) { - console.error( - '[streetplan-loader] Error parsing API response:', - error - ); - this.el.emit('streetplan-loader-error', { error }); - } - } else { - const error = new Error(`Server returned status ${request.status}`); - console.error('[streetplan-loader] API request failed:', error); - this.el.emit('streetplan-loader-error', { error }); - } - }; - - request.onerror = () => { - const error = new Error('Network request failed'); - console.error('[streetplan-loader] Network error:', error); - this.el.emit('streetplan-loader-error', { error }); - }; - request.send(); - } -}); diff --git a/src/editor/components/components/AddLayerPanel/AddLayerPanel.module.scss b/src/editor/components/components/AddLayerPanel/AddLayerPanel.module.scss index af61b965f..092c558ab 100644 --- a/src/editor/components/components/AddLayerPanel/AddLayerPanel.module.scss +++ b/src/editor/components/components/AddLayerPanel/AddLayerPanel.module.scss @@ -74,6 +74,10 @@ .description { color: rgba(182, 182, 182, 1); + max-width: 182px; // Same as image width + word-wrap: break-word; + overflow-wrap: break-word; + white-space: normal; } } diff --git a/src/editor/components/components/AddLayerPanel/createLayerFunctions.js b/src/editor/components/components/AddLayerPanel/createLayerFunctions.js index eed592dd6..6125766d9 100644 --- a/src/editor/components/components/AddLayerPanel/createLayerFunctions.js +++ b/src/editor/components/components/AddLayerPanel/createLayerFunctions.js @@ -77,6 +77,32 @@ export function createManagedStreetFromStreetmixURLPrompt(position) { } } +export function createManagedStreetFromStreetplanURLPrompt(position) { + // This creates a new Managed Street + let streetplanURL = prompt( + 'Please enter a StreetPlan URL', + 'https://streetplan.net/3dstreet/89474' + ); + + if (streetplanURL && streetplanURL !== '') { + const definition = { + id: createUniqueId(), + components: { + position: position ?? '0 0 0', + 'managed-street': { + sourceType: 'streetplan-url', + sourceValue: streetplanURL, + showVehicles: true, + showStriping: true, + synchronize: true + } + } + }; + + AFRAME.INSPECTOR.execute('entitycreate', definition); + } +} + export function createManagedStreetFromStreetObject(position, streetObject) { // This creates a new Managed Street if (streetObject && streetObject !== '') { @@ -151,6 +177,50 @@ export function create60ftRightOfWayManagedStreet(position) { ); } +export function create40ftRightOfWayManagedStreet(position) { + console.log( + 'create40ftRightOfWayManagedStreet', + defaultStreetObjects.stroad40ftROW + ); + createManagedStreetFromStreetObject( + position, + defaultStreetObjects.stroad40ftROW + ); +} + +export function create80ftRightOfWayManagedStreet(position) { + console.log( + 'create80ftRightOfWayManagedStreet', + defaultStreetObjects.stroad80ftROW + ); + createManagedStreetFromStreetObject( + position, + defaultStreetObjects.stroad80ftROW + ); +} + +export function create94ftRightOfWayManagedStreet(position) { + console.log( + 'create94ftRightOfWayManagedStreet', + defaultStreetObjects.stroad94ftROW + ); + createManagedStreetFromStreetObject( + position, + defaultStreetObjects.stroad94ftROW + ); +} + +export function create150ftRightOfWayManagedStreet(position) { + console.log( + 'create150ftRightOfWayManagedStreet', + defaultStreetObjects.stroad150ftROW + ); + createManagedStreetFromStreetObject( + position, + defaultStreetObjects.stroad150ftROW + ); +} + export function create80ftRightOfWay(position) { createStreetmixStreet( position, diff --git a/src/editor/components/components/AddLayerPanel/defaultStreets.js b/src/editor/components/components/AddLayerPanel/defaultStreets.js index 098f9f305..76b8a882f 100644 --- a/src/editor/components/components/AddLayerPanel/defaultStreets.js +++ b/src/editor/components/components/AddLayerPanel/defaultStreets.js @@ -322,3 +322,1150 @@ export const exampleStreet = { } ] }; + +export const stroad40ftROW = { + id: '727dbbf4-692a-48ee-8f99-a056fd60fedd', + name: '40ft Right of Way 24ft Road Width', + width: 12.192, // Original 40ft converted to meters + length: 100, + justifyWidth: 'center', + justifyLength: 'start', + segments: [ + { + id: 'JCWzsLQHmyfDHzQhi9_pU', + name: 'Dense Sidewalk', + type: 'sidewalk', + surface: 'sidewalk', + color: '#ffffff', + level: 1, + width: 1.829, // Original 6ft + direction: 'none', + generated: { + pedestrians: [ + { + density: 'dense' + } + ] + } + }, + { + id: 'RsLZFtSi3oJH7uufQ5rc4', + name: 'Tree Planting Strip', + type: 'sidewalk', + surface: 'sidewalk', + color: '#ffffff', + level: 1, + width: 0.61, // Original 2ft + direction: 'none', + generated: { + clones: [ + { + mode: 'fixed', + model: 'tree3', + spacing: 15 + } + ] + } + }, + { + id: 'GbEHhCMPmVom_IJK-xIn3', + name: 'Inbound Parking', + type: 'parking-lane', + surface: 'concrete', + color: '#dddddd', + level: 0, + width: 2.134, // Original 7ft + direction: 'inbound', + 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 + } + ] + } + }, + { + id: 'z4gZgzYoM7sQ7mzIV01PC', + name: 'Drive Lane', + type: 'drive-lane', + surface: 'asphalt', + color: '#ffffff', + level: 0, + width: 3.048, // Original 10ft + direction: 'inbound', + generated: { + clones: [ + { + mode: 'random', + modelsArray: + 'sedan-rig, box-truck-rig, self-driving-waymo-car, suv-rig, motorbike', + spacing: 7.3, + count: 4 + } + ] + } + }, + { + id: 'ARosTXeWGXp17QyfZgSKB', + name: 'Outbound Parking', + type: 'parking-lane', + surface: 'concrete', + color: '#dddddd', + level: 0, + width: 2.134, // Original 7ft + direction: 'outbound', + 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 + } + ] + } + }, + { + id: 'vL9qDNp5neZt32zlZ9ExG', + name: 'Tree Planting Strip', + type: 'sidewalk', + surface: 'sidewalk', + color: '#ffffff', + level: 1, + width: 0.61, // Original 2ft + direction: 'none', + generated: { + clones: [ + { + mode: 'fixed', + model: 'tree3', + spacing: 15 + } + ] + } + }, + { + id: 'RClRRZoof9_BYnqQm7mz-', + name: 'Normal Sidewalk', + type: 'sidewalk', + surface: 'sidewalk', + color: '#ffffff', + level: 1, + width: 1.829, // Original 6ft + direction: 'none', + generated: { + pedestrians: [ + { + density: 'normal' + } + ] + } + } + ] +}; + +export const stroad80ftROW = { + id: 'dea1980d-2a13-481b-b318-9f757ca114f7', + name: '80ft Right of Way 56ft Road Width', + width: 24.384, // Original 80ft converted to meters + length: 100, + justifyWidth: 'center', + justifyLength: 'start', + segments: [ + { + id: 'JCWzsLQHmyfDHzQhi9_pU', + name: 'Dense Sidewalk', + type: 'sidewalk', + surface: 'sidewalk', + color: '#ffffff', + level: 1, + width: 1.829, // Original 6ft + direction: 'none', + generated: { + pedestrians: [ + { + density: 'dense' + } + ] + } + }, + { + id: 'RsLZFtSi3oJH7uufQ5rc4', + name: 'Tree Planting Strip', + type: 'sidewalk', + surface: 'sidewalk', + color: '#ffffff', + level: 1, + width: 0.914, // Original 3ft + direction: 'none', + generated: { + clones: [ + { + mode: 'fixed', + model: 'tree3', + spacing: 15 + } + ] + } + }, + { + id: 'X2tAKuwUDc728RIPfhJUS', + name: 'Modern Street Lamp', + type: 'sidewalk', + surface: 'sidewalk', + color: '#ffffff', + level: 1, + width: 0.914, // Original 3ft + direction: 'none', + generated: { + clones: [ + { + mode: 'fixed', + model: 'lamp-modern', + spacing: 30, + facing: 0 + } + ] + } + }, + { + id: 'GbEHhCMPmVom_IJK-xIn3', + name: 'Inbound Parking', + type: 'parking-lane', + surface: 'concrete', + color: '#dddddd', + level: 0, + width: 2.438, // Original 8ft + direction: 'inbound', + 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 + } + ] + } + }, + { + id: 'z4gZgzYoM7sQ7mzIV01PC', + name: 'Inbound Drive Lane 1', + type: 'drive-lane', + surface: 'asphalt', + color: '#ffffff', + level: 0, + width: 3.048, // Original 10ft + direction: 'inbound', + generated: { + clones: [ + { + mode: 'random', + modelsArray: + 'sedan-rig, box-truck-rig, self-driving-waymo-car, suv-rig, motorbike', + spacing: 7.3, + count: 4 + } + ] + } + }, + { + id: 'n9A8XDtjRSpgxElVhxoWB', + name: 'Inbound Drive Lane 2', + type: 'drive-lane', + surface: 'asphalt', + color: '#ffffff', + level: 0, + width: 3.048, // Original 10ft + direction: 'inbound', + generated: { + clones: [ + { + mode: 'random', + modelsArray: + 'sedan-rig, box-truck-rig, self-driving-waymo-car, suv-rig, motorbike', + spacing: 7.3, + count: 4 + } + ] + } + }, + { + id: 'O08G5Br9w6vwdomdhUmwk', + name: 'Outbound Drive Lane 1', + type: 'drive-lane', + surface: 'asphalt', + color: '#ffffff', + level: 0, + width: 3.048, // Original 10ft + direction: 'outbound', + generated: { + clones: [ + { + mode: 'random', + modelsArray: + 'sedan-rig, box-truck-rig, self-driving-waymo-car, suv-rig, motorbike', + spacing: 7.3, + count: 4 + } + ] + } + }, + { + id: '1w9jjehQwnvBfJeSVOd6M', + name: 'Outbound Drive Lane 2', + type: 'drive-lane', + surface: 'asphalt', + color: '#ffffff', + level: 0, + width: 3.048, // Original 10ft + direction: 'outbound', + generated: { + clones: [ + { + mode: 'random', + modelsArray: + 'sedan-rig, box-truck-rig, self-driving-waymo-car, suv-rig, motorbike', + spacing: 7.3, + count: 4 + } + ] + } + }, + { + id: 'ARosTXeWGXp17QyfZgSKB', + name: 'Outbound Parking', + type: 'parking-lane', + surface: 'concrete', + color: '#dddddd', + level: 0, + width: 2.438, // Original 8ft + direction: 'outbound', + 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 + } + ] + } + }, + { + id: '2p_cReSRF4748HV9Fyejr', + name: 'Modern Street Lamp', + type: 'sidewalk', + surface: 'sidewalk', + color: '#ffffff', + level: 1, + width: 0.914, // Original 3ft + direction: 'none', + generated: { + clones: [ + { + mode: 'fixed', + model: 'lamp-modern', + spacing: 30, + facing: 180 + } + ] + } + }, + { + id: 'vL9qDNp5neZt32zlZ9ExG', + name: 'Tree Planting Strip', + type: 'sidewalk', + surface: 'sidewalk', + color: '#ffffff', + level: 1, + width: 0.914, // Original 3ft + direction: 'none', + generated: { + clones: [ + { + mode: 'fixed', + model: 'tree3', + spacing: 15 + } + ] + } + }, + { + id: 'RClRRZoof9_BYnqQm7mz-', + name: 'Normal Sidewalk', + type: 'sidewalk', + surface: 'sidewalk', + color: '#ffffff', + level: 1, + width: 1.829, // Original 6ft + direction: 'none', + generated: { + pedestrians: [ + { + density: 'normal' + } + ] + } + } + ] +}; + +export const stroad94ftROW = { + id: 'a55d288c-215d-49a2-b67f-3efb9ec9ff41', + name: '94ft Right of Way 70ft Road Width', + width: 28.651, // Original 94ft converted to meters + length: 100, + justifyWidth: 'center', + justifyLength: 'start', + segments: [ + { + id: 'JCWzsLQHmyfDHzQhi9_pU', + name: 'Dense Sidewalk', + type: 'sidewalk', + surface: 'sidewalk', + color: '#ffffff', + level: 1, + width: 1.829, // Original 6ft + direction: 'none', + generated: { + pedestrians: [ + { + density: 'dense' + } + ] + } + }, + { + id: 'RsLZFtSi3oJH7uufQ5rc4', + name: 'Tree Planting Strip', + type: 'sidewalk', + surface: 'sidewalk', + color: '#ffffff', + level: 1, + width: 0.914, // Original 3ft + direction: 'none', + generated: { + clones: [ + { + mode: 'fixed', + model: 'tree3', + spacing: 15 + } + ] + } + }, + { + id: 'X2tAKuwUDc728RIPfhJUS', + name: 'Modern Street Lamp', + type: 'sidewalk', + surface: 'sidewalk', + color: '#ffffff', + level: 1, + width: 0.914, // Original 3ft + direction: 'none', + generated: { + clones: [ + { + mode: 'fixed', + model: 'lamp-modern', + spacing: 30, + facing: 0 + } + ] + } + }, + { + id: 'GbEHhCMPmVom_IJK-xIn3', + name: 'Inbound Parking', + type: 'parking-lane', + surface: 'concrete', + color: '#dddddd', + level: 0, + width: 2.438, // Original 8ft + direction: 'inbound', + 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 + } + ] + } + }, + { + id: 'z4gZgzYoM7sQ7mzIV01PC', + name: 'Inbound Drive Lane 1', + type: 'drive-lane', + surface: 'asphalt', + color: '#ffffff', + level: 0, + width: 3.353, // Original 11ft + direction: 'inbound', + generated: { + clones: [ + { + mode: 'random', + modelsArray: + 'sedan-rig, box-truck-rig, self-driving-waymo-car, suv-rig, motorbike', + spacing: 7.3, + count: 4 + } + ] + } + }, + { + id: 'n9A8XDtjRSpgxElVhxoWB', + name: 'Inbound Drive Lane 2', + type: 'drive-lane', + surface: 'asphalt', + color: '#ffffff', + level: 0, + width: 3.353, // Original 11ft + direction: 'inbound', + generated: { + clones: [ + { + mode: 'random', + modelsArray: + 'sedan-rig, box-truck-rig, self-driving-waymo-car, suv-rig, motorbike', + spacing: 7.3, + count: 4 + } + ] + } + }, + { + id: 'zUl55HA-DUaJpyQEelUhW', + name: 'Center Turn Lane', + type: 'drive-lane', + surface: 'asphalt', + color: '#ffffff', + level: 0, + width: 3.048, // Original 10ft + generated: { + stencil: [ + { + model: 'left', + cycleOffset: 0.6, + spacing: 20, + direction: 'outbound' + }, + { + model: 'left', + cycleOffset: 0.4, + spacing: 20, + direction: 'inbound' + } + ], + striping: [ + { + striping: 'solid-dashed-yellow' + }, + { + striping: 'solid-dashed-yellow-mirror', + side: 'right' + } + ] + } + }, + { + id: 'O08G5Br9w6vwdomdhUmwk', + name: 'Outbound Drive Lane 1', + type: 'drive-lane', + surface: 'asphalt', + color: '#ffffff', + level: 0, + width: 3.353, // Original 11ft + direction: 'outbound', + generated: { + clones: [ + { + mode: 'random', + modelsArray: + 'sedan-rig, box-truck-rig, self-driving-waymo-car, suv-rig, motorbike', + spacing: 7.3, + count: 4 + } + ], + striping: [ + { + striping: 'none' + } + ] + } + }, + { + id: '1w9jjehQwnvBfJeSVOd6M', + name: 'Outbound Drive Lane 2', + type: 'drive-lane', + surface: 'asphalt', + color: '#ffffff', + level: 0, + width: 3.353, // Original 11ft + direction: 'outbound', + generated: { + clones: [ + { + mode: 'random', + modelsArray: + 'sedan-rig, box-truck-rig, self-driving-waymo-car, suv-rig, motorbike', + spacing: 7.3, + count: 4 + } + ] + } + }, + { + id: 'ARosTXeWGXp17QyfZgSKB', + name: 'Outbound Parking', + type: 'parking-lane', + surface: 'concrete', + color: '#dddddd', + level: 0, + width: 2.438, // Original 8ft + direction: 'outbound', + 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 + } + ] + } + }, + { + id: '2p_cReSRF4748HV9Fyejr', + name: 'Modern Street Lamp', + type: 'sidewalk', + surface: 'sidewalk', + color: '#ffffff', + level: 1, + width: 0.914, // Original 3ft + direction: 'none', + generated: { + clones: [ + { + mode: 'fixed', + model: 'lamp-modern', + spacing: 30, + facing: 180 + } + ] + } + }, + { + id: 'vL9qDNp5neZt32zlZ9ExG', + name: 'Tree Planting Strip', + type: 'sidewalk', + surface: 'sidewalk', + color: '#ffffff', + level: 1, + width: 0.914, // Original 3ft + direction: 'none', + generated: { + clones: [ + { + mode: 'fixed', + model: 'tree3', + spacing: 15 + } + ] + } + }, + { + id: 'RClRRZoof9_BYnqQm7mz-', + name: 'Normal Sidewalk', + type: 'sidewalk', + surface: 'sidewalk', + color: '#ffffff', + level: 1, + width: 1.829, // Original 6ft + direction: 'none', + generated: { + pedestrians: [ + { + density: 'normal' + } + ] + } + } + ] +}; + +export const stroad150ftROW = { + id: 'f8eeb25c-f68c-4f0b-9435-3f87e6be705a', + name: '150ft Right of Way 124ft Road Width', + width: 45.72, // Original 150ft converted to meters + length: 100, + justifyWidth: 'center', + justifyLength: 'start', + segments: [ + { + id: 'JCWzsLQHmyfDHzQhi9_pU', + name: 'Dense Sidewalk', + type: 'sidewalk', + surface: 'sidewalk', + color: '#ffffff', + level: 1, + width: 2.134, // Original 7ft + direction: 'none', + generated: { + pedestrians: [ + { + density: 'dense' + } + ] + } + }, + { + id: 'RsLZFtSi3oJH7uufQ5rc4', + name: 'Tree Planting Strip', + type: 'sidewalk', + surface: 'sidewalk', + color: '#ffffff', + level: 1, + width: 0.914, // Original 3ft + direction: 'none', + generated: { + clones: [ + { + mode: 'fixed', + model: 'tree3', + spacing: 15 + } + ] + } + }, + { + id: 'X2tAKuwUDc728RIPfhJUS', + name: 'Modern Street Lamp', + type: 'sidewalk', + surface: 'sidewalk', + color: '#ffffff', + level: 1, + width: 0.914, // Original 3ft + direction: 'none', + generated: { + clones: [ + { + mode: 'fixed', + model: 'lamp-modern', + spacing: 30, + facing: 0 + } + ] + } + }, + { + id: 'GbEHhCMPmVom_IJK-xIn3', + name: 'Inbound Parking', + type: 'parking-lane', + surface: 'concrete', + color: '#dddddd', + level: 0, + width: 2.438, // Original 8ft + direction: 'inbound', + 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 + } + ] + } + }, + { + id: 'vLBLQS2VraoTL2sJRmD4J', + name: 'Inbound Left Turn Lane', + type: 'drive-lane', + surface: 'asphalt', + color: '#ffffff', + level: 0, + width: 3.048, // Original 10ft + direction: 'inbound', + generated: { + clones: [ + { + mode: 'random', + modelsArray: 'sedan-rig, suv-rig', + spacing: 20, + count: 2 + } + ], + stencil: [ + { + model: 'turn-lane-left', + cycleOffset: 1, + spacing: 20 + } + ] + } + }, + { + id: 'z4gZgzYoM7sQ7mzIV01PC', + name: 'Inbound Drive Lane 1', + type: 'drive-lane', + surface: 'asphalt', + color: '#ffffff', + level: 0, + width: 3.353, // Original 11ft + direction: 'inbound', + generated: { + clones: [ + { + mode: 'random', + modelsArray: + 'sedan-rig, self-driving-waymo-car, suv-rig, motorbike', + spacing: 7.3, + count: 4 + } + ] + } + }, + { + id: '3a42u-6x8OsoGsI7bjb4z', + name: 'Inbound Truck Lane', + type: 'drive-lane', + surface: 'asphalt', + color: '#ffffff', + level: 0, + width: 3.353, // Original 11ft + direction: 'inbound', + generated: { + clones: [ + { + mode: 'random', + modelsArray: 'box-truck-rig, trailer-truck-rig', + spacing: 15, + count: 2 + } + ] + } + }, + { + id: 'n9A8XDtjRSpgxElVhxoWB', + name: 'Inbound Drive Lane 2', + type: 'drive-lane', + surface: 'asphalt', + color: '#ffffff', + level: 0, + width: 3.353, // Original 11ft + direction: 'inbound', + generated: { + clones: [ + { + mode: 'random', + modelsArray: + 'sedan-rig, self-driving-waymo-car, suv-rig, motorbike', + spacing: 7.3, + count: 4 + } + ] + } + }, + { + id: 'E1UvC71Nkre2H2-hg2gMd', + name: 'Inbound Right Turn Lane', + type: 'drive-lane', + surface: 'asphalt', + color: '#ffffff', + level: 0, + width: 3.048, // Original 10ft + direction: 'inbound', + generated: { + clones: [ + { + mode: 'random', + modelsArray: 'sedan-rig, suv-rig', + spacing: 20, + count: 2 + } + ], + stencil: [ + { + model: 'turn-lane-right', + cycleOffset: 1, + spacing: 20 + } + ] + } + }, + { + id: 'WisaQ2Pfc5K51O8k_Mrnb', + name: 'Planted Median', + type: 'divider', + surface: 'planting-strip', + color: '#338833', + level: 1, + width: 0.61, // Original 2ft + direction: 'none', + generated: { + clones: [ + { + mode: 'fixed', + model: 'flowers1', + spacing: 3 + } + ] + } + }, + { + id: 'qvQftgSPmiA7afQles5EK', + name: 'Outbound Left Turn Lane', + type: 'drive-lane', + surface: 'asphalt', + color: '#ffffff', + level: 0, + width: 3.048, // Original 10ft + direction: 'outbound', + generated: { + clones: [ + { + mode: 'random', + modelsArray: 'sedan-rig, suv-rig', + spacing: 20, + count: 2 + } + ], + stencil: [ + { + model: 'turn-lane-left', + cycleOffset: 1, + spacing: 20 + } + ] + } + }, + { + id: 'O08G5Br9w6vwdomdhUmwk', + name: 'Outbound Drive Lane 1', + type: 'drive-lane', + surface: 'asphalt', + color: '#ffffff', + level: 0, + width: 3.353, // Original 11ft + direction: 'outbound', + generated: { + clones: [ + { + mode: 'random', + modelsArray: + 'sedan-rig, self-driving-waymo-car, suv-rig, motorbike', + spacing: 7.3, + count: 4 + } + ] + } + }, + { + id: '1w9jjehQwnvBfJeSVOd6M', + name: 'Outbound Truck Lane', + type: 'drive-lane', + surface: 'asphalt', + color: '#ffffff', + level: 0, + width: 3.353, // Original 11ft + direction: 'outbound', + generated: { + clones: [ + { + mode: 'random', + modelsArray: 'box-truck-rig, trailer-truck-rig', + spacing: 15, + count: 2 + } + ] + } + }, + { + id: 'RnfgVJLk2oJv7QTW_s3WR', + name: 'Outbound Drive Lane 2', + type: 'drive-lane', + surface: 'asphalt', + color: '#ffffff', + level: 0, + width: 3.353, // Original 11ft + direction: 'outbound', + generated: { + clones: [ + { + mode: 'random', + modelsArray: + 'sedan-rig, self-driving-waymo-car, suv-rig, motorbike', + spacing: 7.3, + count: 4 + } + ] + } + }, + { + id: 'va_9kr_Dtr9q8ddlzAl02', + name: 'Outbound Right Turn Lane', + type: 'drive-lane', + surface: 'asphalt', + color: '#ffffff', + level: 0, + width: 3.048, // Original 10ft + direction: 'outbound', + generated: { + clones: [ + { + mode: 'random', + modelsArray: 'sedan-rig, suv-rig', + spacing: 20, + count: 2 + } + ], + stencil: [ + { + model: 'turn-lane-right', + cycleOffset: 1, + spacing: 20 + } + ] + } + }, + { + id: 'ARosTXeWGXp17QyfZgSKB', + name: 'Outbound Parking', + type: 'parking-lane', + surface: 'concrete', + color: '#dddddd', + level: 0, + width: 2.438, // Original 8ft + direction: 'outbound', + 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 + } + ] + } + }, + { + id: '2p_cReSRF4748HV9Fyejr', + name: 'Modern Street Lamp', + type: 'sidewalk', + surface: 'sidewalk', + color: '#ffffff', + level: 1, + width: 0.914, // Original 3ft + direction: 'none', + generated: { + clones: [ + { + mode: 'fixed', + model: 'lamp-modern', + spacing: 30, + facing: 180 + } + ] + } + }, + { + id: 'vL9qDNp5neZt32zlZ9ExG', + name: 'Tree Planting Strip', + type: 'sidewalk', + surface: 'sidewalk', + color: '#ffffff', + level: 1, + width: 0.914, // Original 3ft + direction: 'none', + generated: { + clones: [ + { + mode: 'fixed', + model: 'tree3', + spacing: 15 + } + ] + } + }, + { + id: 'RClRRZoof9_BYnqQm7mz-', + name: 'Normal Sidewalk', + type: 'sidewalk', + surface: 'sidewalk', + color: '#ffffff', + level: 1, + width: 2.134, // Original 7ft + direction: 'none', + generated: { + pedestrians: [ + { + density: 'normal' + } + ] + } + } + ] +}; diff --git a/src/editor/components/components/AddLayerPanel/layersData.js b/src/editor/components/components/AddLayerPanel/layersData.js index c20b2b372..fd21fd782 100644 --- a/src/editor/components/components/AddLayerPanel/layersData.js +++ b/src/editor/components/components/AddLayerPanel/layersData.js @@ -1,13 +1,20 @@ import * as createFunctions from './createLayerFunctions'; export const streetLayersData = [ + { + name: 'Create Intersection', + img: '', + requiresPro: true, + icon: 'ui_assets/cards/icons/3dst24.png', + description: 'Create 90º intersection entity.', + handlerFunction: createFunctions.createIntersection + }, { name: 'Street from Streetmix URL', img: 'ui_assets/cards/streetmix.jpg', icon: 'ui_assets/cards/icons/streetmix24.png', description: 'Create an additional Streetmix street in your 3DStreet scene without replacing any existing streets.', - id: 1, handlerFunction: createFunctions.createStreetmixStreet }, { @@ -15,7 +22,6 @@ export const streetLayersData = [ img: 'ui_assets/cards/street-preset-40-24.jpg', icon: 'ui_assets/cards/icons/streetmix24.png', description: 'Premade Street 40ft Right of Way / 24ft Roadway Width', - id: 2, handlerFunction: createFunctions.create40ftRightOfWay }, { @@ -23,7 +29,6 @@ export const streetLayersData = [ img: 'ui_assets/cards/street-preset-60-36.jpg', icon: 'ui_assets/cards/icons/streetmix24.png', description: 'Premade Street 60ft Right of Way / 36ft Roadway Width', - id: 3, handlerFunction: createFunctions.create60ftRightOfWay }, { @@ -31,7 +36,6 @@ export const streetLayersData = [ img: 'ui_assets/cards/street-preset-80-56.jpg', icon: 'ui_assets/cards/icons/streetmix24.png', description: 'Premade Street 80ft Right of Way / 56ft Roadway Width', - id: 4, handlerFunction: createFunctions.create80ftRightOfWay }, { @@ -39,7 +43,6 @@ export const streetLayersData = [ img: 'ui_assets/cards/street-preset-94-70.jpg', icon: 'ui_assets/cards/icons/streetmix24.png', description: 'Premade Street 94ft Right of Way / 70ft Roadway Width', - id: 5, handlerFunction: createFunctions.create94ftRightOfWay }, { @@ -47,38 +50,62 @@ export const streetLayersData = [ img: 'ui_assets/cards/street-preset-150-124.jpg', icon: 'ui_assets/cards/icons/streetmix24.png', description: 'Premade Street 150ft Right of Way / 124ft Roadway Width', - id: 6, handlerFunction: createFunctions.create150ftRightOfWay }, - { - name: 'Create intersection', - img: '', - requiresPro: true, - icon: '', - description: - 'Create intersection entity. Parameters of intersection component could be changed in properties panel.', - id: 7, - handlerFunction: createFunctions.createIntersection - }, { name: '(Beta) Managed Street from Streetmix URL', img: '', requiresPro: true, - icon: '', + icon: 'ui_assets/cards/icons/streetmix24.png', description: 'Create a new street from Streetmix URL using the Managed Street component.', - id: 8, handlerFunction: createFunctions.createManagedStreetFromStreetmixURLPrompt }, + { + name: '(Beta) Managed Street 40ft RoW / 24ft Roadway Width', + img: 'ui_assets/cards/street-preset-40-24.jpg', + icon: 'ui_assets/cards/icons/3dst24.png', + description: 'Premade Street 40ft Right of Way / 24ft Roadway Width', + handlerFunction: createFunctions.create40ftRightOfWayManagedStreet + }, { name: '(Beta) Managed Street 60ft RoW / 36ft Roadway Width', img: 'ui_assets/cards/street-preset-60-36.jpg', icon: 'ui_assets/cards/icons/3dst24.png', description: 'Premade Street 60ft Right of Way / 36ft Roadway Width', - id: 9, handlerFunction: createFunctions.create60ftRightOfWayManagedStreet + }, + { + name: '(Beta) Managed Street 80ft RoW / 56ft Roadway Width', + img: 'ui_assets/cards/street-preset-80-56.jpg', + icon: 'ui_assets/cards/icons/3dst24.png', + description: 'Premade Street 80ft Right of Way / 56ft Roadway Width', + handlerFunction: createFunctions.create80ftRightOfWayManagedStreet + }, + { + name: '(Beta) Managed Street 94ft RoW / 70ft Roadway Width', + img: 'ui_assets/cards/street-preset-94-70.jpg', + icon: 'ui_assets/cards/icons/3dst24.png', + description: 'Premade Street 94ft Right of Way / 70ft Roadway Width', + handlerFunction: createFunctions.create94ftRightOfWayManagedStreet + }, + { + name: '(Beta) Managed Street 150ft RoW / 124ft Roadway Width', + img: 'ui_assets/cards/street-preset-150-124.jpg', + icon: 'ui_assets/cards/icons/3dst24.png', + description: 'Premade Street 150ft Right of Way / 124ft Roadway Width', + handlerFunction: createFunctions.create150ftRightOfWayManagedStreet + }, + { + name: '(Beta) Managed Street from Streetplan URL', + img: '', + requiresPro: true, + icon: '', + description: + 'Create a new street from Streetplan URL using the Managed Street component.', + handlerFunction: createFunctions.createManagedStreetFromStreetplanURLPrompt } -]; +].map((layer, index) => ({ ...layer, id: index + 1 })); export const customLayersData = [ { @@ -88,7 +115,6 @@ export const customLayersData = [ requiresPro: true, description: 'Create entity with svg-extruder component, that accepts a svgString and creates a new entity with geometry extruded from the svg and applies the default mixin material grass.', - id: 1, handlerFunction: createFunctions.createSvgExtrudedEntity }, { @@ -98,7 +124,6 @@ export const customLayersData = [ icon: '', description: 'Create entity with model from path for a glTF (or Glb) file hosted on any publicly accessible HTTP server.', - id: 2, handlerFunction: createFunctions.createCustomModel }, { @@ -108,7 +133,6 @@ export const customLayersData = [ icon: '', description: 'Create entity with A-Frame primitive geometry. Geometry type could be changed in properties panel.', - id: 3, handlerFunction: createFunctions.createPrimitiveGeometry }, { @@ -118,7 +142,6 @@ export const customLayersData = [ icon: 'ui_assets/cards/icons/gallery24.png', description: 'Place an image such as a sign, reference photo, custom map, etc.', - id: 4, handlerFunction: createFunctions.createImageEntity } -]; +].map((layer, index) => ({ ...layer, id: index + 1 })); diff --git a/src/editor/components/components/Sidebar.js b/src/editor/components/components/Sidebar.js index 4d460c6a6..817a1eeb4 100644 --- a/src/editor/components/components/Sidebar.js +++ b/src/editor/components/components/Sidebar.js @@ -15,7 +15,10 @@ import { ArrowRightIcon, Object24Icon, SegmentIcon, - ManagedStreetIcon + ManagedStreetIcon, + AutoIcon, + ManualIcon, + ArrowLeftHookIcon } from '../../icons'; import GeoSidebar from './GeoSidebar'; // Make sure to create and import this new component import IntersectionSidebar from './IntersectionSidebar'; @@ -35,6 +38,24 @@ export default class Sidebar extends React.Component { }; } + getParentComponentName = (entity) => { + const componentName = entity.getAttribute('data-parent-component'); + const parentEntity = entity.parentElement; + return componentName + ? `${parentEntity.getAttribute('data-layer-name') || 'Entity'}:${componentName}` + : 'Unknown'; + }; + + fireParentComponentDetach = (entity) => { + const componentName = entity.getAttribute('data-parent-component'); + const parentEntity = entity.parentElement; + parentEntity.components[componentName].detach(); + }; + + selectParentEntity = (entity) => { + AFRAME.INSPECTOR.selectEntity(entity.parentElement); + }; + onEntityUpdate = (detail) => { if (detail.entity !== this.props.entity) { return; @@ -115,7 +136,48 @@ export default class Sidebar extends React.Component { {entity.id !== 'reference-layers' && !entity.getAttribute('street-segment') ? ( <> - {!!entity.mixinEls.length && } + {entity.classList.contains('autocreated') && ( + <> +
+
+ +
+ Autocreated Clone +
+
+
+ + +
+
+ + + )} + {!!entity.mixinEls.length && + !entity.classList.contains('autocreated') && ( + + )} {entity.hasAttribute('data-no-transform') ? ( <> ) : ( diff --git a/src/editor/icons/icons.jsx b/src/editor/icons/icons.jsx index e5559ae24..10d15a79a 100644 --- a/src/editor/icons/icons.jsx +++ b/src/editor/icons/icons.jsx @@ -1,3 +1,63 @@ +export const ArrowLeftHookIcon = () => ( + + + +); + +export const ManualIcon = () => ( + + + + +); + +export const AutoIcon = () => ( + + + + +); export const Camera32Icon = () => ( { this.editor.selectEntity(entity); + this.callback?.(entity); + nextCommandCallback?.(entity); }; - const parentEl = - this.definition.parentEl ?? - document.querySelector(this.editor.config.defaultParent); + let parentEl; + if (this.definition.parentEl) { + if (typeof this.definition.parentEl === 'string') { + parentEl = document.getElementById(this.definition.parentEl); + } else { + parentEl = this.definition.parentEl; + } + } + if (!parentEl) { + parentEl = document.querySelector(this.editor.config.defaultParent); + } // If we undo and redo, use the previous id so next redo actions (for example entityupdate to move the position) works correctly if (this.entityId) { definition = { ...this.definition, id: this.entityId }; @@ -30,12 +48,13 @@ export class EntityCreateCommand extends Command { return entity; } - undo() { + undo(nextCommandCallback) { const entity = document.getElementById(this.entityId); if (entity) { entity.parentNode.removeChild(entity); Events.emit('entityremoved', entity); this.editor.selectEntity(null); + nextCommandCallback?.(entity); } } } diff --git a/src/editor/lib/commands/EntityRemoveCommand.js b/src/editor/lib/commands/EntityRemoveCommand.js index f206d09e4..d550e9df8 100644 --- a/src/editor/lib/commands/EntityRemoveCommand.js +++ b/src/editor/lib/commands/EntityRemoveCommand.js @@ -16,7 +16,7 @@ export class EntityRemoveCommand extends Command { this.index = Array.from(this.parentEl.children).indexOf(entity); } - execute() { + execute(nextCommandCallback) { const closest = findClosestEntity(this.entity); // Keep a clone not attached to DOM for undo @@ -31,9 +31,10 @@ export class EntityRemoveCommand extends Command { this.entity = clone; this.editor.selectEntity(closest); + nextCommandCallback?.(null); } - undo() { + undo(nextCommandCallback) { // Reinsert the entity at its original position using the stored index const referenceNode = this.parentEl.children[this.index] ?? null; this.parentEl.insertBefore(this.entity, referenceNode); @@ -44,6 +45,7 @@ export class EntityRemoveCommand extends Command { () => { Events.emit('entitycreated', this.entity); this.editor.selectEntity(this.entity); + nextCommandCallback?.(this.entity); }, { once: true } ); diff --git a/src/editor/lib/commands/EntityUpdateCommand.js b/src/editor/lib/commands/EntityUpdateCommand.js index 5361eb80b..bab7535ff 100644 --- a/src/editor/lib/commands/EntityUpdateCommand.js +++ b/src/editor/lib/commands/EntityUpdateCommand.js @@ -87,7 +87,7 @@ export class EntityUpdateCommand extends Command { } } - execute() { + execute(nextCommandCallback) { const entity = document.getElementById(this.entityId); if (entity) { if (this.editor.selectedEntity && this.editor.selectedEntity !== entity) { @@ -119,10 +119,11 @@ export class EntityUpdateCommand extends Command { if (this.component === 'id') { this.entityId = this.newValue; } + nextCommandCallback?.(entity); } } - undo() { + undo(nextCommandCallback) { const entity = document.getElementById(this.entityId); if (entity) { if (this.editor.selectedEntity && this.editor.selectedEntity !== entity) { @@ -149,6 +150,7 @@ export class EntityUpdateCommand extends Command { if (this.component === 'id') { this.entityId = this.oldValue; } + nextCommandCallback?.(entity); } } diff --git a/src/editor/lib/commands/MultiCommand.js b/src/editor/lib/commands/MultiCommand.js new file mode 100644 index 000000000..627d4e8bb --- /dev/null +++ b/src/editor/lib/commands/MultiCommand.js @@ -0,0 +1,50 @@ +import { Command } from '../command.js'; +import { commandsByType } from './index.js'; + +/** + * @param editor Editor + * @param commands + * @param callback Optional callback to call after all commands are executed, + * get as argument the created entity or null if last command is entityremove. + * @constructor + */ +export class MultiCommand extends Command { + constructor(editor, commands, callback = undefined) { + super(editor); + + this.type = 'multi'; + this.name = 'Multiple changes'; + this.updatable = false; + this.callback = callback; + this.commands = commands + .map((cmdTuple) => { + const Cmd = commandsByType.get(cmdTuple[0]); + if (!Cmd) { + console.error(`Command ${cmdTuple[0]} not found`); + return null; + } + return new Cmd(editor, cmdTuple[1], cmdTuple[2]); + }) + .filter(Boolean); + } + + execute() { + const run = this.commands + .toReversed() + .reduce((nextCommandCallback, command) => { + return (entityIgnored) => { + return command.execute(nextCommandCallback); + }; + }, this.callback); // latest callback uses the entity as parameter + return run(); + } + + undo() { + const run = this.commands.reduce((nextCommandCallback, command) => { + return (entityIgnored) => { + return command.undo(nextCommandCallback); + }; + }, this.callback); // latest callback uses the entity as parameter + return run(); + } +} diff --git a/src/editor/lib/commands/index.js b/src/editor/lib/commands/index.js index 2f1a6afbc..1efeb43ed 100644 --- a/src/editor/lib/commands/index.js +++ b/src/editor/lib/commands/index.js @@ -4,6 +4,7 @@ import { EntityCloneCommand } from './EntityCloneCommand.js'; import { EntityCreateCommand } from './EntityCreateCommand.js'; import { EntityRemoveCommand } from './EntityRemoveCommand.js'; import { EntityUpdateCommand } from './EntityUpdateCommand.js'; +import { MultiCommand } from './MultiCommand.js'; export const commandsByType = new Map(); commandsByType.set('componentadd', ComponentAddCommand); @@ -12,3 +13,4 @@ commandsByType.set('entityclone', EntityCloneCommand); commandsByType.set('entitycreate', EntityCreateCommand); commandsByType.set('entityremove', EntityRemoveCommand); commandsByType.set('entityupdate', EntityUpdateCommand); +commandsByType.set('multi', MultiCommand); diff --git a/src/index.js b/src/index.js index 252420d9e..0eae960a7 100644 --- a/src/index.js +++ b/src/index.js @@ -18,7 +18,6 @@ require('./components/notify.js'); require('./components/create-from-json'); require('./components/screentock.js'); require('aframe-atlas-uvs-component'); -require('./components/streetplan-loader'); require('./components/street-geo.js'); require('./components/street-environment.js'); require('./components/intersection.js'); @@ -30,6 +29,10 @@ 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('./components/polygon-offset.js'); +require('./components/street-align.js'); +require('./components/street-ground.js'); +require('./components/street-label.js'); require('./editor/index.js'); var firebase = require('./editor/services/firebase.js'); diff --git a/src/json-utils_1.1.js b/src/json-utils_1.1.js index dac6e2cba..91bb719c9 100644 --- a/src/json-utils_1.1.js +++ b/src/json-utils_1.1.js @@ -1,4 +1,5 @@ import useStore from './store'; +import { createUniqueId } from './editor/lib/entity'; /* global AFRAME, Node */ /* version: 1.0 */ @@ -529,18 +530,30 @@ AFRAME.registerComponent('set-loader-from-hash', { streetURL ); } else if (streetURL.includes('streetplan.net/')) { - // load from Streetplan encoded JSON in URL + // instead, load streetplan via managed street the new addlayerpanel console.log( '[set-loader-from-hash]', - 'Set streetplan-loader streetplanAPIURL to', + 'Create new Managed Street with StreetPlan URL', streetURL ); + if (streetURL && streetURL !== '') { + const definition = { + id: createUniqueId(), + components: { + 'managed-street': { + sourceType: 'streetplan-url', + sourceValue: streetURL, + showVehicles: true, + showStriping: true, + synchronize: true + } + } + }; - this.el.setAttribute( - 'streetplan-loader', - 'streetplanAPIURL', - streetURL - ); + setTimeout(() => { + AFRAME.INSPECTOR.execute('entitycreate', definition); + }, 1000); + } } else { // try to load JSON file from remote resource console.log( diff --git a/src/streetplan/conversion-map.js b/src/streetplan/conversion-map.js deleted file mode 100644 index 82db4ded5..000000000 --- a/src/streetplan/conversion-map.js +++ /dev/null @@ -1,347 +0,0 @@ -// conversion map StreetPan -> Streetmix sidewalk segment types mapping -/* -StreetPlanType1: - { - StreetPlanSubtype: StreetmixType, - --- or --- - StreetPlanSubtype: { - "tag": StreetPlanTag, - "type": StreetmixType, - "variantString": Streetmix VariantString, can be formed based on other Streetplan parameters - (Name or Tag) or be constant, like: 'sidewalk', - - "variantStringAdd": get parameter values from this list and generate variantString. - Often variantString looks like this: 'outbound|regular|road' - example for bike-path. - variantStringAdd will be: 'direction|material|variantString', - - "nameToVariantMap": mapping rule StreetPlan O1-Name -> VariantString, - "tagToVariantMap": mapping rule StreetPlan O1-Tags -> VariantString, - "names": names (StreetPlan O1-Name) for this Streetmix Segment type - }, - --- or --- - // for one (O1-Tags) there can be different streetmix segment types, - // which are determined by the name (O1-Name) - StreetPlanSubtype: [ - different options of tags (O1-Tags) and streetMix data for each - ] - } -*/ -const mapping = { - Setback: { - '': { type: 'sidewalk', variantString: 'empty' }, - Trees: { type: 'sidewalk-tree', variantString: 'big' }, - tree: { type: 'divider', variantString: 'palm-tree' }, - Benchs: { type: 'sidewalk-bench', variantStringAdd: 'side' } - }, - Walkways: { - '': { type: 'sidewalk', variantString: 'empty' }, - Trees: { type: 'sidewalk-tree', variantString: 'big' }, - pedestrian: { type: 'sidewalk', variantString: 'dense' }, - Benchs: { type: 'sidewalk-bench', variantStringAdd: 'side' }, - Tables: { type: 'outdoor-dining', variantString: 'occupied|sidewalk' } - }, - Furniture: { - '': { type: 'sidewalk', variantString: 'empty' }, - Trees: { type: 'sidewalk-tree', variantString: 'big' }, - season_tree: { type: 'sidewalk-tree', variantString: 'big' }, - Shelters: { - type: 'transit-shelter', - variantString: 'street-level', - variantStringAdd: 'side|variantString' - }, - Pedestrian: { type: 'sidewalk', variantString: 'dense' } - }, - Curbside: { - '': { type: 'sidewalk', variantString: 'empty' }, - Lights: { - type: 'sidewalk-lamp', - tagToVariantMap: { - 'Historic Lights': 'traditional', - 'Regular Lights': 'modern' - }, - variantStringAdd: 'side|variantString' - }, - Poles: { type: 'utilities', variantStringAdd: 'side' }, - BikeRacks: { - type: 'sidewalk-bike-rack', - nameToVariantMap: { - 'Sideview Modern': 'sidewalk-parallel', - Sideview: 'sidewalk-parallel', - 'NYC Bike Rack': 'sidewalk' - }, - variantStringAdd: 'side|variantString' - } - }, - BikesPaths: { - '': { type: 'bike-lane', variantString: 'sidewalk' }, - Bikes: { - type: 'bike-lane', - variantString: 'sidewalk', - variantStringAdd: 'direction|material|variantString' - } - }, - Gutter: { - '': { type: 'divider', variantString: 'median' }, - Gutter: { type: 'divider', variantString: 'median' } - }, - Transit: { - '': { - tag: 'Bus Vehicles', - type: 'bus-lane', - variantString: 'typical', - variantStringAdd: 'direction|material|variantString' - }, - Transit: [ - { - tag: 'Rail Vehicles', - type: 'streetcar', - names: [ - 'StreetCar Yellow', - 'StreetCar Blue', - 'StreetCar Red 1', - 'StreetCar Red 2' - ], - variantStringAdd: 'direction|material' - }, - { - tag: 'Rail Vehicles', - type: 'light-rail', - names: ['UTA LightRail'], - variantStringAdd: 'direction|material' - }, - // there are only reversed light rail vehicles in Streetplan - { - tag: 'Rail Vehicles Reversed', - type: 'light-rail', - variantStringAdd: 'direction|material' - }, - { - tag: 'Bus Vehicles', - type: 'bus-lane', - variantString: 'typical', - variantStringAdd: 'direction|material|variantString' - } - ] - }, - Cars: { - '': { - type: 'drive-lane', - variantString: 'car', - variantStringAdd: 'direction|variantString' - }, - Autos: { - type: 'drive-lane', - variantString: 'car', - variantStringAdd: 'direction|variantString' - }, - Truck: { - type: 'drive-lane', - variantString: 'truck', - variantStringAdd: 'direction|variantString' - } - }, - Parking: { - '': { - tag: 'Parking - Parallel', - type: 'parking-lane', - variantStringAdd: 'direction|side' - }, - Parallel: { - tag: 'Parking - Parallel', - type: 'parking-lane', - variantStringAdd: 'direction|side' - }, - AngleNormal: { - tag: 'Parking - Angle', - type: 'parking-lane', - nameToVariantMap: { - 'Away, L. Park, Head In': 'angled-rear-left', - 'Toward, R. Park, Head In': 'angled-front-right', - 'Toward, L. Park, Head In': 'angled-front-left', - 'Away, R. Park, Head In': 'angled-rear-right' - }, - variantStringAdd: 'side' - }, - Perpendicular: { - type: 'parking-lane', - variantString: 'sideways', - variantStringAdd: 'variantString|side' - } - }, - Buffers: { - '': { type: 'divider', variantString: 'median' }, - Trees: { type: 'divider', variantString: 'big-tree' }, - tree: { type: 'divider', variantString: 'palm-tree' }, - season_tree: { type: 'divider', variantString: 'big-tree' }, - median: { type: 'divider', variantString: 'planting-strip' }, - planter: { type: 'divider', variantString: 'planting-strip' } - } -}; -// copy repeating rules -mapping['Buffers']['AngleNormal'] = mapping['Parking']['AngleNormal']; -mapping['Buffers']['Autos'] = mapping['Cars']['Autos']; -mapping['Buffers']['Purpendicular'] = mapping['Parking']['Perpendicular']; -mapping['Median/Buffer'] = mapping['Buffers']; -mapping['Setback']['tree'] = mapping['Buffers']['tree']; -mapping['Setback']['Trees'] = mapping['Buffers']['Trees']; -mapping['Setback']['season_tree'] = mapping['Buffers']['season_tree']; -// fix for typo Purpendicular -mapping['Parking']['Purpendicular'] = mapping['Parking']['Perpendicular']; -mapping['Setback']['Purpendicular'] = mapping['Parking']['Perpendicular']; -mapping['Setback']['AngleNormal'] = mapping['Parking']['AngleNormal']; -mapping['Setback']['planter'] = mapping['Buffers']['planter']; -mapping['Setback']['BikeRacks'] = mapping['Curbside']['BikeRacks']; -mapping['Setback']['Tables'] = mapping['Walkways']['Tables']; -mapping['Setback']['Poles'] = mapping['Curbside']['Poles']; - -mapping['Curbside']['Shelters'] = mapping['Furniture']['Shelters']; -mapping['Curbside']['Benchs'] = mapping['Walkways']['Benchs']; - -mapping['Furniture']['planter'] = mapping['Buffers']['planter']; -mapping['Furniture']['Benchs'] = mapping['Walkways']['Benchs']; -mapping['Furniture']['BikeRacks'] = mapping['Curbside']['BikeRacks']; -mapping['Furniture']['Tables'] = mapping['Walkways']['Tables']; - -const directionMap = { - Coming: 'inbound', - Going: 'outbound', - // make default outbound direction for both variant - Both: 'both', - NA: '' -}; - -const materialMap = { - 'Asphalt Black': 'regular', - 'Asphalt Blue': 'blue', - 'Asphalt Red 1': 'red', - 'Asphalt Red 2': 'red', - 'Asphalt Green': 'green', - 'Asphalt Old': 'regular', - Grass: 'grass', - 'Grass Dead': 'grass' -}; - -// StreetMix variantString often has additional parameters via |, for example: taxi|outbound|right -// generate a streetMix like variantString from the listed parameters in variantStringAdd -function generateVariantString(variantStringKeys, streetmixData) { - const variantString = variantStringKeys - .split('|') - .map((currKey) => streetmixData[currKey]) - .join('|'); - return variantString; -} - -function getDataFromSubtypeMap(convertRule, streetmixData, streetplanData) { - if (typeof convertRule === 'string') { - // convertRule is a Streetmix type. - // Later will add another options for this case - streetmixData['type'] = convertRule; - } else if (Array.isArray(convertRule)) { - // in this case, different segment subtype options - // are associated with the different Streetmix types - - // find the desired Streetmix segment data from the array by Streetplan tag and names(?) - const variantData = convertRule.find((element) => { - const tagValMatches = element['tag'] === streetplanData['O1-Tags']; - if (tagValMatches && element['names']) { - return element['names'].includes(streetplanData['O1-Name']); - } - return tagValMatches; - }); - - streetmixData['variantString'] = ''; - - const variantString = variantData['variantString']; - if (variantString && typeof variantString === 'string') { - streetmixData['variantString'] = variantString; - } - - // generate a streetMix like variantString from the listed parameter values - streetmixData['type'] = variantData['type']; - const variantStringKeys = variantData['variantStringAdd']; - if (variantStringKeys) { - streetmixData['variantString'] = generateVariantString( - variantStringKeys, - streetmixData - ); - } - } else if (typeof convertRule === 'object') { - // in this case, different variants of the segment subtype - // are associated with different variantString of the Streetmix segment - - streetmixData['type'] = convertRule['type']; - streetmixData['variantString'] = ''; - - const variantString = convertRule['variantString']; - if (variantString && typeof variantString === 'string') { - streetmixData['variantString'] = variantString; - } - - // get variantString from {"O1-Name" (StreetPlan Object Name) : variantString} mapping data - const nameToVariantMap = convertRule['nameToVariantMap']; - if (nameToVariantMap && nameToVariantMap[streetplanData['O1-Name']]) { - streetmixData['variantString'] = - nameToVariantMap[streetplanData['O1-Name']]; - } - - // get variantString from {"O1-Tags" (StreetPlan Tag) : variantString} mapping data - const tagToVariantMap = convertRule['tagToVariantMap']; - if (tagToVariantMap && tagToVariantMap[streetplanData['O1-Tags']]) { - streetmixData['variantString'] = - tagToVariantMap[streetplanData['O1-Tags']]; - } - - // generate a streetMix like variantString from the listed parameter values - const variantStringKeys = convertRule['variantStringAdd']; - if (variantStringKeys) { - streetmixData['variantString'] = generateVariantString( - variantStringKeys, - streetmixData - ); - } - } - - return streetmixData; -} - -// convert streetPlan segment data to Streetmix segment data -function convertSegment(data) { - let streetmixData = {}; - const streetplanType = data['Type']; - const streetplanSubtype = data['Subtype']; - // mapping rule for current Streetplan subtypes - const subtypeMap = mapping[streetplanType]; - - // convert elevation value to Streetmix format: 0, 1, 2 - streetmixData['elevation'] = data['MaterialH'] / 0.5; - streetmixData['width'] = data['width']; - streetmixData['direction'] = directionMap[data['Direction']]; - if (data['side']) { - streetmixData['side'] = data['side']; - } - if (data['Material']) { - streetmixData['material'] = materialMap[data['Material']]; - } - - if (subtypeMap) { - const convertRule = subtypeMap[streetplanSubtype]; - if (convertRule) { - streetmixData = getDataFromSubtypeMap(convertRule, streetmixData, data); - } else { - streetmixData['type'] = streetplanType; - // STREET.notify.warningMessage(`The '${streetplanSubtype}' subtype of StreetPlan segment '${segmentType}' is not yet supported in 3DStreet`); - console.log( - `The '${streetplanSubtype}' subtype of StreetPlan segment '${streetplanType}' is not yet supported in 3DStreet` - ); - } - } else { - streetmixData['type'] = streetplanType; - // STREET.notify.warningMessage(`The '${streetplanType}' StreetPlan segment type is not yet supported in 3DStreet`); - console.log( - `The '${streetplanType}' StreetPlan segment type is not yet supported in 3DStreet` - ); - } - return streetmixData; -} - -module.exports.convertSegment = convertSegment; diff --git a/src/streetplan/streetplan-utils.js b/src/streetplan/streetplan-utils.js deleted file mode 100644 index 5d91f46af..000000000 --- a/src/streetplan/streetplan-utils.js +++ /dev/null @@ -1,87 +0,0 @@ -// utils for StreetPlan parsing -const mappingUtils = require('./conversion-map.js'); - -/** - * Convert width from feet to meters - * @param {Object} streetData - Street data containing segments - */ -function convertStreetValues(streetData) { - streetData.segments.forEach((segmentData) => { - segmentData.width *= 0.3048; - }); -} - -/** - * Convert street structure to match Streetmix JSON Schema - * @param {Object} projectData - Full project data from StreetPlan - * @returns {Object} Converted street structure - */ -function convertStreetStruct(projectData) { - // Validate input - if (!projectData || !projectData.project) { - throw new Error('Invalid project data structure'); - } - - const newStruct = { - projectName: projectData.project.ProjectName || 'Unnamed Project', - units: projectData.project.DistanceUnits || 'Feet' - }; - - // Find the first street in the project (excluding metadata keys) - const streets = Object.keys(projectData.project).filter( - (key) => key !== 'ProjectName' && key !== 'DistanceUnits' - ); - - if (streets.length === 0) { - throw new Error('No streets found in project'); - } - - const streetName = streets[0]; - newStruct.name = streetName; - - // Get the street variations (e.g. "Boulevard Alt 1", "Existing Conditions") - const variations = Object.keys(projectData.project[streetName]).filter( - (key) => key !== 'LengthMiles' - ); - - // Use the first variation by default - const selectedVariation = variations[0]; - newStruct.altName = selectedVariation; - newStruct.lengthMiles = projectData.project[streetName].LengthMiles; - - // Get segments from the selected variation - const streetData = projectData.project[streetName][selectedVariation]; - - // Remove segment indexes and convert to array - newStruct.segments = Object.values(streetData.segments); - - // Convert measurements if needed - convertStreetValues(newStruct); - - // Remove buildings and setback segments, convert remaining data - newStruct.segments = convertSegmentData(newStruct.segments).filter( - (segmentData) => { - return !['Buildings', 'setback'].includes(segmentData['type']); - } - ); - - // Add new metadata fields if present - newStruct.segments = newStruct.segments.map((segment) => { - if (segment.Group1) segment.group1 = segment.Group1; - if (segment.Group2) segment.group2 = segment.Group2; - if (segment.Cost) segment.cost = segment.Cost; - return segment; - }); - - return newStruct; -} - -function convertSegmentData(segments) { - return segments.map(mappingUtils.convertSegment); -} - -module.exports = { - convertStreetStruct, - convertSegmentData, - convertStreetValues -};