From f9e9c943ac94b8135089bc86ce945aaaad8170c5 Mon Sep 17 00:00:00 2001 From: HMAZonderland Date: Fri, 1 Aug 2025 22:09:51 +0200 Subject: [PATCH 01/15] feat: add dynamic mapping support for attribute values - Add DynamicValue type for static or function-based values - Make containsDynamicValues public in MappingValidator - Implement re-evaluation of dynamic mappings on selection changes - Add hasDynamicMapping() method to detect dynamic configurations - Add comprehensive tests for dynamic mapping behavior Dynamic mappings allow attribute values to depend on other selections, enabling complex product configurations where available options change based on previous choices. --- src/attributes/attribute-service.ts | 285 +++++++++++++++--- src/attributes/mapping-types.ts | 22 +- src/attributes/mapping-validator.ts | 60 ++++ .../attribute-dynamic-context.test.ts | 82 +++++ 4 files changed, 407 insertions(+), 42 deletions(-) create mode 100644 test/unit/attributes/attribute-dynamic-context.test.ts diff --git a/src/attributes/attribute-service.ts b/src/attributes/attribute-service.ts index 2258387..db2a8a8 100644 --- a/src/attributes/attribute-service.ts +++ b/src/attributes/attribute-service.ts @@ -1,11 +1,11 @@ import { Attribute } from './attribute'; import { AttributeValue } from './attribute-value'; -import type { AttributeValueConfig, MappingConfiguration } from './mapping-types'; +import type { MappingConfiguration, MappingContext, DynamicValue, AttributeConfig } from './mapping-types'; import { MappingValidator } from './mapping-validator'; import type { EventBus } from '../events/event-bus'; import { EVENT_NAMES } from '../events/event-names'; import { createMutationMessage } from '../messaging/message-utils'; -import type { Mutation } from '../mutations/mutation'; +import { Mutation } from '../mutations/mutation'; import type { ModelNode } from '../nodes/node'; import { logger } from '../utils/logger'; @@ -14,12 +14,21 @@ import { logger } from '../utils/logger'; * Handles business logic and coordinates with other domains via events */ export class AttributeService { - private readonly attributes = new Map(); + private attributes = new Map(); private readonly eventBus: EventBus; private isInitialized = false; + // Store original config for re-evaluation + private mappingConfig: MappingConfiguration | null = null; + + // Store current selections + private readonly selections = new Map(); + + // Store current visible nodes per attribute + private readonly currentState = new Map>(); + constructor(eventBus: EventBus) { this.eventBus = eventBus; @@ -35,33 +44,28 @@ export class AttributeService { public loadMapping(config: MappingConfiguration): void { MappingValidator.validate(config); + // Store config for re-evaluation + this.mappingConfig = config; + if (this.isInitialized) { this.attributes.clear(); + this.selections.clear(); + this.currentState.clear(); } - const mutations: Mutation[] = []; - - config.attributes.forEach(attrConfig => { - const attribute = new Attribute(attrConfig.name); - - attrConfig.values.forEach((valueConfig: AttributeValueConfig) => { - const attributeValue = new AttributeValue( - valueConfig.value, - valueConfig.nodeIds, - valueConfig.isSelected, - ); - attribute.addValue(attributeValue); + // Initial evaluation + this.attributes = this.evaluateMapping(); - logger.debug('Created attribute value', attributeValue); - }); + // Initialize selections from default values + this.initializeSelections(); - this.attributes.set(attribute.name, attribute); - - mutations.push(...attribute.getDefaultMutations()); - }); + // Update current state + this.updateCurrentState(); this.isInitialized = true; + // Send initial mutations + const mutations = this.getInitialMutations(); if (mutations.length > 0) { const mutationMessage = createMutationMessage(mutations); this.eventBus.emit(EVENT_NAMES.MUTATION_MESSAGE, { @@ -71,19 +75,45 @@ export class AttributeService { } public selectAttributeValue(attributeName: string, value: string): void { + // Check if attribute exists const attribute = this.attributes.get(attributeName); if (attribute === undefined) { + logger.warn('Attribute not found', { attributeName }); return; } - const mutations = attribute.select(value); - if (mutations.length === 0) { + // Check if value exists in attribute + if (!attribute.hasValue(value)) { + logger.warn('Value not found in attribute', { attributeName, value }); return; } - this.eventBus.emit(EVENT_NAMES.MUTATION_MESSAGE, { - message: createMutationMessage(mutations), - }); + // 1. Update selection + this.selections.set(attributeName, value); + + // 2. Store current visible nodes + const oldState = new Map(this.currentState); + + // 3. Check if we need to re-evaluate the entire mapping + // This happens when we have dynamic mappings with dependencies + if (this.hasDynamicMapping()) { + // Re-evaluate the entire mapping with updated selections + this.attributes = this.evaluateMapping(); + } + + // 4. Calculate new state based on selections + // (AttributeValues will be updated when server responds with STATE_CHANGED) + this.updateCurrentState(); + + // 5. Calculate mutations + const mutations = this.calculateMutations(oldState, this.currentState); + + // 6. Send mutations + if (mutations.length > 0) { + this.eventBus.emit(EVENT_NAMES.MUTATION_MESSAGE, { + message: createMutationMessage(mutations), + }); + } } public getAttribute(name: string): Attribute | undefined { @@ -103,26 +133,205 @@ export class AttributeService { return; } - nodes.forEach(node => { - const attributeValue = this.getAttributeValueForNode(node.id); - if (attributeValue !== undefined) { - attributeValue.setSelected(node.isVisible); - } - }); - } + // Create a map of node visibility for quick lookup + const nodeVisibility = new Map(); + for (const node of nodes) { + nodeVisibility.set(node.id, node.isVisible); + } - private getAttributeValueForNode(nodeId: string): AttributeValue | undefined { + // For each attribute, update the selected state of values based on node visibility for (const attribute of this.attributes.values()) { - const value = attribute.getAllValues().find(v => v.hasNode(nodeId)); - if (value !== undefined) { - return value; + for (const value of attribute.getAllValues()) { + // Check if all nodes for this value are visible + const allNodesVisible = value.nodeList.every( + nodeId => nodeVisibility.get(nodeId) ?? false, + ); + + // Update the selected state + value.setSelected(allNodesVisible); } } - return undefined; } public clear(): void { this.attributes.clear(); + this.selections.clear(); + this.currentState.clear(); + this.mappingConfig = null; this.isInitialized = false; } + + /** + * Create context for dynamic evaluation + */ + private createContext(): MappingContext { + return { + getValue: (attributeName: string) => this.selections.get(attributeName), + getAllValues: () => new Map(this.selections), + }; + } + + /** + * Evaluate a property that might be static or dynamic + */ + private evaluateProperty(prop: DynamicValue, context: MappingContext): T { + return typeof prop === 'function' ? (prop as (context: MappingContext) => T)(context) : prop; + } + + /** + * Evaluate the entire mapping with current context + */ + private evaluateMapping(): Map { + if (this.mappingConfig === null) { + return new Map(); + } + + const context = this.createContext(); + const evaluatedAttributes = new Map(); + + for (const attrConfig of this.mappingConfig.attributes) { + const attribute = this.createAttributeFromConfig(attrConfig, context); + evaluatedAttributes.set(attribute.name, attribute); + } + + return evaluatedAttributes; + } + + /** + * Create a single attribute from configuration + */ + private createAttributeFromConfig( + attrConfig: AttributeConfig, + context: MappingContext, + ): Attribute { + const attribute = new Attribute(attrConfig.name); + + // Evaluate values (might be function or array) + const values = this.evaluateProperty(attrConfig.values, context); + + for (const valueConfig of values) { + // Evaluate each property + const value = this.evaluateProperty(valueConfig.value, context); + const nodeIds = this.evaluateProperty(valueConfig.nodeIds, context); + const isSelected = this.evaluateProperty(valueConfig.isSelected ?? false, context); + + // Check if this value is currently selected + const currentSelection = this.selections.get(attrConfig.name); + const shouldBeSelected = currentSelection !== undefined + ? currentSelection === value + : isSelected; + + const attributeValue = new AttributeValue(value, nodeIds, shouldBeSelected); + attribute.addValue(attributeValue); + + logger.debug('Created attribute value', attributeValue); + } + + return attribute; + } + + /** + * Initialize selections from default values + */ + private initializeSelections(): void { + for (const [name, attribute] of this.attributes) { + const selectedValue = attribute.getCurrentValue(); + if (selectedValue !== undefined) { + this.selections.set(name, selectedValue.value); + } + } + } + + /** + * Check if the mapping configuration contains dynamic elements + */ + private hasDynamicMapping(): boolean { + if (this.mappingConfig === null) { + return false; + } + return MappingValidator.containsDynamicValues(this.mappingConfig); + } + + /** + * Update current state map with visible nodes per attribute + */ + private updateCurrentState(): void { + this.currentState.clear(); + + for (const [name, attribute] of this.attributes) { + const visibleNodes = this.getVisibleNodesForAttribute(name, attribute); + this.currentState.set(name, visibleNodes); + } + } + + private getVisibleNodesForAttribute(name: string, attribute: Attribute): Set { + const visibleNodes = new Set(); + const selectedValueName = this.selections.get(name); + + if (selectedValueName === undefined) { + return visibleNodes; + } + + const selectedValue = attribute.getValue(selectedValueName); + if (selectedValue !== undefined) { + selectedValue.nodeList.forEach(nodeId => visibleNodes.add(nodeId)); + } + + return visibleNodes; + } + + /** + * Get initial mutations for default selections + */ + private getInitialMutations(): Mutation[] { + const mutations: Mutation[] = []; + + for (const attribute of this.attributes.values()) { + mutations.push(...attribute.getDefaultMutations()); + } + + return mutations; + } + + /** + * Calculate mutations by comparing old and new state + */ + private calculateMutations( + oldState: Map>, + newState: Map>, + ): Mutation[] { + const mutations: Mutation[] = []; + const allNodeIds = new Set(); + + // Collect all nodeIds from both states + oldState.forEach(nodes => nodes.forEach(id => allNodeIds.add(id))); + newState.forEach(nodes => nodes.forEach(id => allNodeIds.add(id))); + + // For each node, determine if visibility changed + for (const nodeId of allNodeIds) { + const wasVisible = this.isNodeVisible(nodeId, oldState); + const isVisible = this.isNodeVisible(nodeId, newState); + + if (wasVisible && !isVisible) { + mutations.push(Mutation.hide(nodeId)); + } + if (!wasVisible && isVisible) { + mutations.push(Mutation.show(nodeId)); + } + } + + return mutations; + } + + /** + * Check if a node is visible in the given state + */ + private isNodeVisible(nodeId: string, state: Map>): boolean { + for (const nodes of state.values()) { + if (nodes.has(nodeId)) { + return true; + } + } + return false; + } } diff --git a/src/attributes/mapping-types.ts b/src/attributes/mapping-types.ts index 2c11939..b861e17 100644 --- a/src/attributes/mapping-types.ts +++ b/src/attributes/mapping-types.ts @@ -1,10 +1,24 @@ +/** + * Context provided to dynamic value functions + */ +export interface MappingContext { + getValue: (attributeName: string) => string | undefined; + getAllValues: () => Map; +} + +/** + * A value that can be static or dynamically computed + */ +export type DynamicValue = T | ((context: MappingContext) => T); + /** * Configuration for a single attribute value + * All properties can be static or dynamic */ export interface AttributeValueConfig { - readonly value: string; - readonly nodeIds: string[]; - readonly isSelected?: boolean; + readonly value: DynamicValue; + readonly nodeIds: DynamicValue; + readonly isSelected?: DynamicValue; } /** @@ -12,7 +26,7 @@ export interface AttributeValueConfig { */ export interface AttributeConfig { readonly name: string; - readonly values: AttributeValueConfig[]; + readonly values: DynamicValue; } /** diff --git a/src/attributes/mapping-validator.ts b/src/attributes/mapping-validator.ts index 8a2d2f1..24f6ac9 100644 --- a/src/attributes/mapping-validator.ts +++ b/src/attributes/mapping-validator.ts @@ -17,6 +17,18 @@ export class MappingValidator { * @throws {VirtualdisplayError} When mapping configuration is invalid */ public static validate(config: MappingConfiguration): void { + if (config === null || config === undefined) { + throw VirtualdisplayError.invalidMapping('Mapping configuration cannot be null or undefined'); + } + + // Check if mapping contains dynamic values (functions) + if (this.containsDynamicValues(config)) { + // Skip JSON schema validation for dynamic mappings + // We can't validate functions with JSON Schema + this.validateBasicStructure(config); + return; + } + if (!this.validateFn(config)) { // AJV already provides good error messages const errors = this.validateFn.errors! @@ -26,4 +38,52 @@ export class MappingValidator { throw VirtualdisplayError.invalidMapping(errors); } } + + /** + * Check if configuration contains dynamic values (functions) + */ + public static containsDynamicValues(config: MappingConfiguration): boolean { + if (config.attributes === undefined || !Array.isArray(config.attributes)) { + return false; + } + + return config.attributes.some(attr => { + // Check if values is a function + if (typeof attr.values === 'function') { + return true; + } + + // Check if any value properties are functions + if (Array.isArray(attr.values)) { + return attr.values.some(val => typeof val.value === 'function' || + typeof val.nodeIds === 'function' || + typeof val.isSelected === 'function'); + } + + return false; + }); + } + + /** + * Basic structure validation for dynamic mappings + */ + private static validateBasicStructure(config: MappingConfiguration): void { + if (config.attributes === undefined || !Array.isArray(config.attributes)) { + throw VirtualdisplayError.invalidMapping('attributes must be an array'); + } + + if (config.attributes.length === 0) { + throw VirtualdisplayError.invalidMapping('attributes array must not be empty'); + } + + config.attributes.forEach((attr, index) => { + if (attr.name === undefined || attr.name === '' || typeof attr.name !== 'string') { + throw VirtualdisplayError.invalidMapping(`attribute at index ${index} must have a name`); + } + + if (attr.values === undefined) { + throw VirtualdisplayError.invalidMapping(`attribute '${attr.name}' must have values`); + } + }); + } } diff --git a/test/unit/attributes/attribute-dynamic-context.test.ts b/test/unit/attributes/attribute-dynamic-context.test.ts new file mode 100644 index 0000000..00215bb --- /dev/null +++ b/test/unit/attributes/attribute-dynamic-context.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, beforeEach } from 'vitest'; + +import { AttributeService } from '../../../src/attributes/attribute-service'; +import type { MappingConfiguration, MappingContext } from '../../../src/attributes/mapping-types'; +import { EventBus } from '../../../src/events/event-bus'; + +describe('AttributeService - Dynamic mapping context', () => { + let attributeService: AttributeService = null as unknown as AttributeService; + let eventBus: EventBus = null as unknown as EventBus; + + beforeEach(() => { + eventBus = new EventBus(); + attributeService = new AttributeService(eventBus); + }); + + it('should have empty context during initial evaluation - preventing attribute dependencies', () => { + const mapping: MappingConfiguration = { + attributes: [ + { + name: 'Color', + values: [ + { value: 'Red', nodeIds: ['color-red'], isSelected: true }, + { value: 'Blue', nodeIds: ['color-blue'] }, + ], + }, + { + name: 'Size', + values: (context: MappingContext) => { + // This documents an important limitation: + // You cannot read other attributes during initial mapping load + const color = context.getValue('Color'); + expect(color).toBeUndefined(); + + // Real world scenario: conditional sizing based on color would fail + // This forces developers to handle this case properly + return [ + { value: 'Default', nodeIds: ['size-default'], isSelected: true }, + ]; + }, + }, + ], + }; + + attributeService.loadMapping(mapping); + + const sizeAttr = attributeService.getAttribute('Size'); + expect(sizeAttr?.getCurrentValue()?.value).toBe('Default'); + }); + + it('should re-evaluate dynamic mappings when selections change - enables dependencies', () => { + let evaluationCount = 0; + + const mapping: MappingConfiguration = { + attributes: [ + { + name: 'DynamicOptions', + values: (): Array<{ value: string; nodeIds: string[]; isSelected?: boolean }> => { + evaluationCount++; + // Dynamic mappings re-evaluate to handle dependencies + return [ + { value: 'Option1', nodeIds: ['opt-1'], isSelected: true }, + { value: 'Option2', nodeIds: ['opt-2'] }, + ]; + }, + }, + ], + }; + + attributeService.loadMapping(mapping); + expect(evaluationCount).toBe(1); + + // Each selection triggers re-evaluation for dynamic mappings + attributeService.selectAttributeValue('DynamicOptions', 'Option2'); + expect(evaluationCount).toBe(2); + + attributeService.selectAttributeValue('DynamicOptions', 'Option1'); + expect(evaluationCount).toBe(3); + + attributeService.selectAttributeValue('DynamicOptions', 'Option2'); + expect(evaluationCount).toBe(4); + }); +}); From cf2379b3c984eef7450c3ddca4d74ea805b3885e Mon Sep 17 00:00:00 2001 From: HMAZonderland Date: Fri, 1 Aug 2025 22:11:34 +0200 Subject: [PATCH 02/15] test: add comprehensive state synchronization tests - Add basic state sync tests for server state changes - Add edge case tests for mapping changes and state updates - Add 3D viewer synchronization tests for onChange callbacks - Add UI synchronization tests for live value updates - Add edge case tests for onChange callback behavior These tests ensure proper separation between client mutations and server state updates, validating that state only changes via STATE_CHANGED events from the server. --- .../attribute-onchange-3d-viewer-sync.test.ts | 95 +++++++++++++++++++ .../attribute-onchange-edge-cases.test.ts | 88 +++++++++++++++++ .../attribute-onchange-ui-sync.test.ts | 71 ++++++++++++++ .../attribute-state-basic-sync.test.ts | 91 ++++++++++++++++++ .../attribute-state-edge-cases.test.ts | 52 ++++++++++ 5 files changed, 397 insertions(+) create mode 100644 test/feature/attribute-onchange-3d-viewer-sync.test.ts create mode 100644 test/feature/attribute-onchange-edge-cases.test.ts create mode 100644 test/feature/attribute-onchange-ui-sync.test.ts create mode 100644 test/unit/attributes/attribute-state-basic-sync.test.ts create mode 100644 test/unit/attributes/attribute-state-edge-cases.test.ts diff --git a/test/feature/attribute-onchange-3d-viewer-sync.test.ts b/test/feature/attribute-onchange-3d-viewer-sync.test.ts new file mode 100644 index 0000000..c5ba7f9 --- /dev/null +++ b/test/feature/attribute-onchange-3d-viewer-sync.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, beforeEach } from 'vitest'; + +import { type MappingConfiguration, EVENT_NAMES } from '../../src'; +import { AttributeService } from '../../src/attributes/attribute-service'; +import { EventBus } from '../../src/events/event-bus'; +import { createTestNodes } from '../helpers/node-test-helpers'; + +describe('Feature: onChange - 3D viewer synchronization', () => { + let attributeService: AttributeService = null as unknown as AttributeService; + let eventBus: EventBus = null as unknown as EventBus; + let changeLog: Array<{ attribute: string; value: string; selected: boolean }> = []; + + beforeEach(() => { + eventBus = new EventBus(); + attributeService = new AttributeService(eventBus); + changeLog = []; + + const mapping: MappingConfiguration = { + attributes: [ + { + name: 'Material', + values: [ + { value: 'Wood', nodeIds: ['mat-wood'], isSelected: true }, + { value: 'Metal', nodeIds: ['mat-metal'] }, + { value: 'Glass', nodeIds: ['mat-glass'] }, + ], + }, + { + name: 'Finish', + values: [ + { value: 'Matte', nodeIds: ['finish-matte'], isSelected: true }, + { value: 'Glossy', nodeIds: ['finish-glossy'] }, + ], + }, + ], + }; + + attributeService.loadMapping(mapping); + + // Register onChange for all values + const materialAttr = attributeService.getAttribute('Material'); + materialAttr?.getAllValues().forEach(value => { + value.setOnChange(() => { + changeLog.push({ + attribute: 'Material', + value: value.value, + selected: value.isSelected, + }); + }); + }); + + const finishAttr = attributeService.getAttribute('Finish'); + finishAttr?.getAllValues().forEach(value => { + value.setOnChange(() => { + changeLog.push({ + attribute: 'Finish', + value: value.value, + selected: value.isSelected, + }); + }); + }); + }); + + it('should not trigger onChange for API selections - prevents infinite loops', () => { + // API selection should NOT trigger onChange to prevent UI feedback loops + attributeService.selectAttributeValue('Material', 'Metal'); + expect(changeLog).toHaveLength(0); + }); + + it('should trigger onChange for 3D viewer state changes - enables UI sync', () => { + // 3D viewer state change (user clicks in 3D viewer) + const nodes = createTestNodes([ + { id: 'mat-wood', name: 'Wood', isVisible: false }, + { id: 'mat-metal', name: 'Metal', isVisible: false }, + { id: 'mat-glass', name: 'Glass', isVisible: true }, + { id: 'finish-matte', name: 'Matte', isVisible: false }, + { id: 'finish-glossy', name: 'Glossy', isVisible: true }, + ]); + + eventBus.emit(EVENT_NAMES.STATE_CHANGED, { nodes }); + + // Should trigger onChange for all affected values + expect(changeLog).toHaveLength(5); // All 5 values changed state + + // Verify specific changes + const woodChange = changeLog.find(c => c.attribute === 'Material' && c.value === 'Wood'); + expect(woodChange?.selected).toBe(false); + + const glassChange = changeLog.find(c => c.attribute === 'Material' && c.value === 'Glass'); + expect(glassChange?.selected).toBe(true); + + const glossyChange = changeLog.find(c => c.attribute === 'Finish' && c.value === 'Glossy'); + expect(glossyChange?.selected).toBe(true); + }); +}); diff --git a/test/feature/attribute-onchange-edge-cases.test.ts b/test/feature/attribute-onchange-edge-cases.test.ts new file mode 100644 index 0000000..5f4906e --- /dev/null +++ b/test/feature/attribute-onchange-edge-cases.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest'; + +import { type MappingConfiguration, EVENT_NAMES } from '../../src'; +import { AttributeService } from '../../src/attributes/attribute-service'; +import { EventBus } from '../../src/events/event-bus'; +import { createTestNodes } from '../helpers/node-test-helpers'; + +describe('Feature: onChange - Edge cases', () => { + let attributeService: AttributeService = null as unknown as AttributeService; + let eventBus: EventBus = null as unknown as EventBus; + + beforeEach(() => { + eventBus = new EventBus(); + attributeService = new AttributeService(eventBus); + }); + + it('should trigger onChange even when external state matches current', () => { + const mapping: MappingConfiguration = { + attributes: [ + { + name: 'Status', + values: [ + { value: 'On', nodeIds: ['status-on'], isSelected: true }, + { value: 'Off', nodeIds: ['status-off'], isSelected: false }, + ], + }, + ], + }; + + attributeService.loadMapping(mapping); + + const onChange = vi.fn(); + const statusAttr = attributeService.getAttribute('Status'); + statusAttr?.getValue('On')?.setOnChange(onChange); + statusAttr?.getValue('Off')?.setOnChange(onChange); + + // External state same as current + const nodes = createTestNodes([ + { id: 'status-on', name: 'On', isVisible: true }, + { id: 'status-off', name: 'Off', isVisible: false }, + ]); + + eventBus.emit(EVENT_NAMES.STATE_CHANGED, { nodes }); + + // Should still trigger because syncStateWithAttributes always calls setSelected + expect(onChange).toHaveBeenCalledTimes(2); + }); + + it('should handle onChange added after initialization', () => { + const mapping: MappingConfiguration = { + attributes: [ + { + name: 'Mode', + values: [ + { value: 'Auto', nodeIds: ['mode-auto'], isSelected: true }, + { value: 'Manual', nodeIds: ['mode-manual'] }, + ], + }, + ], + }; + + attributeService.loadMapping(mapping); + + // First state change without onChange + const nodes1 = createTestNodes([ + { id: 'mode-auto', name: 'Auto', isVisible: false }, + { id: 'mode-manual', name: 'Manual', isVisible: true }, + ]); + + eventBus.emit(EVENT_NAMES.STATE_CHANGED, { nodes: nodes1 }); + + // Add onChange after state change + const onChange = vi.fn(); + const modeAttr = attributeService.getAttribute('Mode'); + modeAttr?.getValue('Manual')?.setOnChange(onChange); + + // Second state change + const nodes2 = createTestNodes([ + { id: 'mode-auto', name: 'Auto', isVisible: true }, + { id: 'mode-manual', name: 'Manual', isVisible: false }, + ]); + + eventBus.emit(EVENT_NAMES.STATE_CHANGED, { nodes: nodes2 }); + + // Should trigger for the second change + expect(onChange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/feature/attribute-onchange-ui-sync.test.ts b/test/feature/attribute-onchange-ui-sync.test.ts new file mode 100644 index 0000000..6010ec9 --- /dev/null +++ b/test/feature/attribute-onchange-ui-sync.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, beforeEach } from 'vitest'; + +import { EVENT_NAMES, type MappingConfiguration } from '../../src'; +import { AttributeService } from '../../src/attributes/attribute-service'; +import { EventBus } from '../../src/events/event-bus'; +import { createTestNodes } from '../helpers/node-test-helpers'; + +describe('Feature: onChange - UI sync', () => { + let attributeService: AttributeService = null as unknown as AttributeService; + let eventBus: EventBus = null as unknown as EventBus; + + beforeEach(() => { + eventBus = new EventBus(); + attributeService = new AttributeService(eventBus); + }); + + it('should support live UI updates via onChange', () => { + const mapping: MappingConfiguration = { + attributes: [ + { + name: 'Color', + values: [ + { value: 'Red', nodeIds: ['color-red'], isSelected: false }, + { value: 'Blue', nodeIds: ['color-blue'], isSelected: true }, + { value: 'Green', nodeIds: ['color-green'], isSelected: false }, + ], + }, + ], + }; + + attributeService.loadMapping(mapping); + + // UI state tracker + const uiState = { + buttons: { + Red: { highlighted: false }, + Blue: { highlighted: true }, + Green: { highlighted: false }, + }, + }; + + // Setup onChange to update UI state + const colorAttr = attributeService.getAttribute('Color'); + + colorAttr?.getValue('Red')?.setOnChange(() => { + uiState.buttons.Red.highlighted = colorAttr.getValue('Red')?.isSelected ?? false; + }); + + colorAttr?.getValue('Blue')?.setOnChange(() => { + uiState.buttons.Blue.highlighted = colorAttr.getValue('Blue')?.isSelected ?? false; + }); + + colorAttr?.getValue('Green')?.setOnChange(() => { + uiState.buttons.Green.highlighted = colorAttr.getValue('Green')?.isSelected ?? false; + }); + + // External state change - user clicks Green in 3D viewer + const nodes = createTestNodes([ + { id: 'color-red', name: 'Red', isVisible: false }, + { id: 'color-blue', name: 'Blue', isVisible: false }, + { id: 'color-green', name: 'Green', isVisible: true }, + ]); + + eventBus.emit(EVENT_NAMES.STATE_CHANGED, { nodes }); + + // UI state should update automatically + expect(uiState.buttons.Red.highlighted).toBe(false); + expect(uiState.buttons.Blue.highlighted).toBe(false); + expect(uiState.buttons.Green.highlighted).toBe(true); + }); +}); diff --git a/test/unit/attributes/attribute-state-basic-sync.test.ts b/test/unit/attributes/attribute-state-basic-sync.test.ts new file mode 100644 index 0000000..9ae88fc --- /dev/null +++ b/test/unit/attributes/attribute-state-basic-sync.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, beforeEach } from 'vitest'; + +import { type MappingConfiguration, EVENT_NAMES } from '../../../src'; +import { AttributeService } from '../../../src/attributes/attribute-service'; +import { EventBus } from '../../../src/events/event-bus'; +import { createTestNodes } from '../../helpers/node-test-helpers'; + +describe('AttributeService - Basic state sync', () => { + let attributeService: AttributeService = null as unknown as AttributeService; + let eventBus: EventBus = null as unknown as EventBus; + + beforeEach(() => { + eventBus = new EventBus(); + attributeService = new AttributeService(eventBus); + }); + + it('should sync attribute values with node visibility', () => { + const mapping: MappingConfiguration = { + attributes: [ + { + name: 'Material', + values: [ + { value: 'Wood', nodeIds: ['mat-wood-1', 'mat-wood-2'], isSelected: true }, + { value: 'Metal', nodeIds: ['mat-metal'], isSelected: false }, + { value: 'Plastic', nodeIds: ['mat-plastic'], isSelected: false }, + ], + }, + ], + }; + + attributeService.loadMapping(mapping); + + // Initial state + const materialAttr = attributeService.getAttribute('Material'); + expect(materialAttr?.getCurrentValue()?.value).toBe('Wood'); + + // Simulate external state change from 3D viewer + const nodes = createTestNodes([ + { id: 'mat-wood-1', name: 'Wood1', isVisible: false }, + { id: 'mat-wood-2', name: 'Wood2', isVisible: false }, + { id: 'mat-metal', name: 'Metal', isVisible: true }, + { id: 'mat-plastic', name: 'Plastic', isVisible: false }, + ]); + + eventBus.emit(EVENT_NAMES.STATE_CHANGED, { nodes }); + + // Check that values are synced + const woodValue = materialAttr?.getValue('Wood'); + const metalValue = materialAttr?.getValue('Metal'); + const plasticValue = materialAttr?.getValue('Plastic'); + + expect(woodValue?.isSelected).toBe(false); + expect(metalValue?.isSelected).toBe(true); + expect(plasticValue?.isSelected).toBe(false); + }); + + it('should handle partial node visibility correctly', () => { + const mapping: MappingConfiguration = { + attributes: [ + { + name: 'Component', + values: [ + { value: 'Full', nodeIds: ['comp-1', 'comp-2', 'comp-3'], isSelected: true }, + { value: 'Partial', nodeIds: ['comp-4', 'comp-5'], isSelected: false }, + ], + }, + ], + }; + + attributeService.loadMapping(mapping); + + // Only some nodes of "Full" are visible + const nodes = createTestNodes([ + { id: 'comp-1', name: 'C1', isVisible: true }, + { id: 'comp-2', name: 'C2', isVisible: false }, + { id: 'comp-3', name: 'C3', isVisible: true }, + { id: 'comp-4', name: 'C4', isVisible: false }, + { id: 'comp-5', name: 'C5', isVisible: false }, + ]); + + eventBus.emit(EVENT_NAMES.STATE_CHANGED, { nodes }); + + const componentAttr = attributeService.getAttribute('Component'); + const fullValue = componentAttr?.getValue('Full'); + const partialValue = componentAttr?.getValue('Partial'); + + // Full should not be selected because not all nodes are visible + expect(fullValue?.isSelected).toBe(false); + expect(partialValue?.isSelected).toBe(false); + }); +}); diff --git a/test/unit/attributes/attribute-state-edge-cases.test.ts b/test/unit/attributes/attribute-state-edge-cases.test.ts new file mode 100644 index 0000000..edfbf75 --- /dev/null +++ b/test/unit/attributes/attribute-state-edge-cases.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, beforeEach } from 'vitest'; + +import { type MappingConfiguration, EVENT_NAMES } from '../../../src'; +import { AttributeService } from '../../../src/attributes/attribute-service'; +import { EventBus } from '../../../src/events/event-bus'; +import { createTestNodes } from '../../helpers/node-test-helpers'; + +describe('AttributeService - State sync edge cases', () => { + let attributeService: AttributeService = null as unknown as AttributeService; + let eventBus: EventBus = null as unknown as EventBus; + + beforeEach(() => { + eventBus = new EventBus(); + attributeService = new AttributeService(eventBus); + }); + + it('should gracefully handle nodes not in mapping - prevents crashes with dynamic 3D models', () => { + const mapping: MappingConfiguration = { + attributes: [ + { + name: 'Known', + values: [ + { value: 'A', nodeIds: ['known-a'], isSelected: true }, + { value: 'B', nodeIds: ['known-b'], isSelected: false }, + ], + }, + ], + }; + + attributeService.loadMapping(mapping); + + // Real scenario: 3D model has extra nodes not in the mapping + // This happens when models are updated but mapping isn't + const nodes = createTestNodes([ + { id: 'known-a', name: 'A', isVisible: false }, + { id: 'known-b', name: 'B', isVisible: true }, + { id: 'unknown-1', name: 'Unknown1', isVisible: true }, + { id: 'unknown-2', name: 'Unknown2', isVisible: false }, + { id: 'new-feature-node', name: 'NewFeature', isVisible: true }, + ]); + + // Should not throw even with unknown nodes + expect(() => { + eventBus.emit(EVENT_NAMES.STATE_CHANGED, { nodes }); + }).not.toThrow(); + + // Known attributes should still sync correctly + const knownAttr = attributeService.getAttribute('Known'); + expect(knownAttr?.getValue('A')?.isSelected).toBe(false); + expect(knownAttr?.getValue('B')?.isSelected).toBe(true); + }); +}); From 67f4a84b2f561d6c41a1c88376c8503d6285a1b1 Mon Sep 17 00:00:00 2001 From: HMAZonderland Date: Fri, 1 Aug 2025 22:12:00 +0200 Subject: [PATCH 03/15] refactor: remove dead code from codebase - Remove unused Attribute.select() method (only used in tests) - Remove unused getAttributeValueForNode() private method - Remove unused SNAPSHOT_DEVELOPED event name - Remove unused MessageType type alias - Remove associated test files for unused methods These methods and types were identified as dead code through static analysis and were not used anywhere in production code. --- src/attributes/attribute.ts | 24 +----- src/events/event-names.ts | 1 - src/messaging/message-types.ts | 3 +- test/unit/attributes/attribute.test.ts | 44 ---------- .../attributes/mutation-inverse-logic.test.ts | 86 ------------------- 5 files changed, 5 insertions(+), 153 deletions(-) delete mode 100644 test/unit/attributes/mutation-inverse-logic.test.ts diff --git a/src/attributes/attribute.ts b/src/attributes/attribute.ts index b8de0b3..4abdfcf 100644 --- a/src/attributes/attribute.ts +++ b/src/attributes/attribute.ts @@ -18,26 +18,6 @@ export class Attribute { this.values.set(attributeValue.value, attributeValue); } - /** - * Select a value - returns mutations needed for the change - * Pure function - no side effects - */ - public select(value: string): Mutation[] { - const newValue = this.values.get(value); - if (newValue === undefined) { - return []; - } - - const currentValue = this.getCurrentValue(); - if (currentValue === newValue) { - return []; - } - - return [ - ...(currentValue?.getMutations().map(m => m.inverse()) ?? []), - ...newValue.getMutations().map(m => m.inverse()), - ]; - } public getDefaultMutations(): Mutation[] { return Array.from(this.values.values()) @@ -68,4 +48,8 @@ export class Attribute { public get currentSelection(): string | undefined { return this.getCurrentValue()?.value; } + + public clear(): void { + this.values.clear(); + } } diff --git a/src/events/event-names.ts b/src/events/event-names.ts index 090c672..f74e608 100644 --- a/src/events/event-names.ts +++ b/src/events/event-names.ts @@ -21,5 +21,4 @@ export const EVENT_NAMES = { // Snapshot flow SNAPSHOT_MESSAGE: 'snapshot:take' as const, - SNAPSHOT_DEVELOPED: 'snapshot:developed' as const, } as const; diff --git a/src/messaging/message-types.ts b/src/messaging/message-types.ts index 9810eac..554818a 100644 --- a/src/messaging/message-types.ts +++ b/src/messaging/message-types.ts @@ -1,4 +1,4 @@ -import type { CameraConfig } from '../camera/camera-config'; +import type { CameraConfig } from '../camera'; import type { MutationDto } from '../mutations/mutation'; /** @@ -13,7 +13,6 @@ export const MESSAGE_TYPES = { SNAPSHOT: 'snapshot', } as const; -export type MessageType = typeof MESSAGE_TYPES[keyof typeof MESSAGE_TYPES]; /** * DTO for model node data received from server diff --git a/test/unit/attributes/attribute.test.ts b/test/unit/attributes/attribute.test.ts index afc7ac9..9961a25 100644 --- a/test/unit/attributes/attribute.test.ts +++ b/test/unit/attributes/attribute.test.ts @@ -20,50 +20,6 @@ describe('Attribute - Creation', () => { }); }); -describe('Attribute - Selection', () => { - it('should select value and return mutations', () => { - const attribute = new Attribute('Material'); - const leather = new AttributeValue('Leather', ['mat-leather'], true); - const fabric = new AttributeValue('Fabric', ['mat-fabric']); - - attribute.addValue(leather); - attribute.addValue(fabric); - - const mutations = attribute.select('Fabric'); - - expect(mutations).toEqual([ - { type: MUTATION_TYPES.HIDE, nodeId: 'mat-leather' }, - { type: MUTATION_TYPES.SHOW, nodeId: 'mat-fabric' }, - ]); - }); - - it('should handle multiple nodes per value', () => { - const attribute = new Attribute('Material'); - const leather = new AttributeValue('Leather', ['mat-leather'], true); - const fabric = new AttributeValue('Fabric', ['mat-fabric-1', 'mat-fabric-2']); - - attribute.addValue(leather); - attribute.addValue(fabric); - - const mutations = attribute.select('Fabric'); - - expect(mutations).toEqual([ - { type: MUTATION_TYPES.HIDE, nodeId: 'mat-leather' }, - { type: MUTATION_TYPES.SHOW, nodeId: 'mat-fabric-1' }, - { type: MUTATION_TYPES.SHOW, nodeId: 'mat-fabric-2' }, - ]); - }); - - it('should return empty array for invalid selection', () => { - const attribute = new Attribute('Color'); - const red = new AttributeValue('Red', ['red-node']); - attribute.addValue(red); - - const mutations = attribute.select('NonExistent'); - - expect(mutations).toEqual([]); - }); -}); describe('Attribute - Default Handling', () => { it('should get default mutations', () => { diff --git a/test/unit/attributes/mutation-inverse-logic.test.ts b/test/unit/attributes/mutation-inverse-logic.test.ts deleted file mode 100644 index 539b6fb..0000000 --- a/test/unit/attributes/mutation-inverse-logic.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { MUTATION_TYPES } from '../../../src'; -import { Attribute } from '../../../src/attributes/attribute'; -import { AttributeValue } from '../../../src/attributes/attribute-value'; - -describe('Mutation Inverse Logic', () => { - it('should correctly handle attribute selection with inverse', () => { - // Setup attribute with two values - const attribute = new Attribute('Color'); - const redValue = new AttributeValue('Red', ['mat_red'], false); // Not selected (hidden) - const blueValue = new AttributeValue('Blue', ['mat_blue'], false); // Not selected (hidden) - - attribute.addValue(redValue); - attribute.addValue(blueValue); - - // Select red (from nothing selected to red) - const mutations = attribute.select('Red'); - - // We expect: - // - Red nodes to be shown (red is hidden, getMutations returns hide, inverse shows) - // - Blue nodes to be hidden (blue is hidden, getMutations returns hide, inverse shows) - // Wait, this doesn't make sense... - - // Let me trace through the actual logic: - // 1. redValue.selected = false - // 2. redValue.getMutations() returns hide mutations (because selected = false) - // 3. In determineMutationsForSelection, newValue.getMutations().inverse() converts hide to show - // 4. No currentValue, so we just return the show mutations for red - - expect(mutations).toHaveLength(1); - expect(mutations[0]?.type).toBe(MUTATION_TYPES.SHOW); - expect(mutations[0]?.nodeId).toBe('mat_red'); - }); - - it('should correctly switch between values', () => { - // Setup attribute with two values - const attribute = new Attribute('Color'); - const redValue = new AttributeValue('Red', ['mat_red'], true); // Selected (visible) - const blueValue = new AttributeValue('Blue', ['mat_blue'], false); // Not selected (hidden) - - attribute.addValue(redValue); - attribute.addValue(blueValue); - - // Now red is selected, let's trace what happens - // redValue.selected = true, so getMutations() returns show mutations - // blueValue.selected = false, so getMutations() returns hide mutations - - // Select blue (switch from red to blue) - const mutations = attribute.select('Blue'); - - // Expected flow: - // 1. currentValue = redValue (selected = true) - // 2. currentValue.getMutations() returns show mutations - // 3. inverse() converts show to hide mutations for red - // 4. newValue = blueValue (selected = false) - // 5. newValue.getMutations() returns hide mutations - // 6. inverse() converts hide to show mutations for blue - - // So we expect: hide red, show blue - expect(mutations).toEqual([ - { type: MUTATION_TYPES.HIDE, nodeId: 'mat_red' }, - { type: MUTATION_TYPES.SHOW, nodeId: 'mat_blue' }, - ]); - }); - - it('should handle multiple nodes per value', () => { - const attribute = new Attribute('Size'); - const largeValue = new AttributeValue('Large', ['scale_x_large', 'scale_y_large'], true); // Selected - const smallValue = new AttributeValue('Small', ['scale_x_small', 'scale_y_small'], false); // Not selected - - attribute.addValue(largeValue); - attribute.addValue(smallValue); - - // Switch from large to small - const mutations = attribute.select('Small'); - - // Expected: hide 2 large nodes, show 2 small nodes - expect(mutations).toEqual([ - { type: MUTATION_TYPES.HIDE, nodeId: 'scale_x_large' }, - { type: MUTATION_TYPES.HIDE, nodeId: 'scale_y_large' }, - { type: MUTATION_TYPES.SHOW, nodeId: 'scale_x_small' }, - { type: MUTATION_TYPES.SHOW, nodeId: 'scale_y_small' }, - ]); - }); -}); From 0711726b58738f790f7434f6d9d8471e3c7902fd Mon Sep 17 00:00:00 2001 From: HMAZonderland Date: Fri, 1 Aug 2025 22:12:19 +0200 Subject: [PATCH 04/15] style: fix lint and formatting issues - Fix max-depth lint error in updateCurrentState by extracting helper method - Fix import formatting in snapshot test file - Apply prettier formatting to snapshot demo HTML --- examples/snapshot-demo/index.html | 3 +-- test/unit/snapshot/snapshot.test.ts | 8 +++----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/examples/snapshot-demo/index.html b/examples/snapshot-demo/index.html index ca8cc08..85252be 100644 --- a/examples/snapshot-demo/index.html +++ b/examples/snapshot-demo/index.html @@ -650,8 +650,7 @@

Activity log

const match = nameWithoutExt.match(/(\d+)$/); if (match) { const num = parseInt(match[1]) + 1; - const newName = nameWithoutExt.replace(/\d+$/, num) + ext; - filenameInput.value = newName; + filenameInput.value = nameWithoutExt.replace(/\d+$/, num) + ext; } else { filenameInput.value = nameWithoutExt + '-2' + ext; } diff --git a/test/unit/snapshot/snapshot.test.ts b/test/unit/snapshot/snapshot.test.ts index 2d27cc1..c336b2c 100644 --- a/test/unit/snapshot/snapshot.test.ts +++ b/test/unit/snapshot/snapshot.test.ts @@ -1,11 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { VirtualdisplayError, ERROR_CODES } from '../../../src/client/virtualdisplay-error'; +import { + Photo, Snapshot, MESSAGE_TYPES, EVENT_NAMES, VirtualdisplayError, ERROR_CODES, +} from '../../../src'; import { EventBus } from '../../../src/events/event-bus'; -import { EVENT_NAMES } from '../../../src/events/event-names'; -import { MESSAGE_TYPES } from '../../../src/messaging/message-types'; -import { Photo } from '../../../src/snapshot/photo'; -import { Snapshot } from '../../../src/snapshot/snapshot'; describe('Snapshot - basic tests', () => { let eventBus = new EventBus(); From 92deefecad97b2903ab8f49c014eda17ac4e3ef7 Mon Sep 17 00:00:00 2001 From: HMAZonderland Date: Fri, 1 Aug 2025 22:12:38 +0200 Subject: [PATCH 05/15] test: update test infrastructure and helpers - Add new node test helpers for creating test nodes - Update create-element helper for better test support - Update DOM helpers for improved test utilities --- test/helpers/create-element-helper.ts | 7 ++----- test/helpers/dom-helpers.ts | 3 +-- test/helpers/node-test-helpers.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 7 deletions(-) create mode 100644 test/helpers/node-test-helpers.ts diff --git a/test/helpers/create-element-helper.ts b/test/helpers/create-element-helper.ts index ce38c71..d6c7a65 100644 --- a/test/helpers/create-element-helper.ts +++ b/test/helpers/create-element-helper.ts @@ -6,16 +6,13 @@ export function createElementForTest( tagName: K, ): HTMLElementTagNameMap[K] { - // Cache document reference - const doc = document; // Call createElement on cached reference - return doc.createElement(tagName); + return document.createElement(tagName); } /** * Get a bound createElement function for mocking */ export function getBoundCreateElement(): typeof document.createElement { - const doc = document; - return doc.createElement.bind(doc); + return document.createElement.bind(document); } diff --git a/test/helpers/dom-helpers.ts b/test/helpers/dom-helpers.ts index 5048ed5..98051fd 100644 --- a/test/helpers/dom-helpers.ts +++ b/test/helpers/dom-helpers.ts @@ -9,6 +9,5 @@ export function createTestElement( tagName: K, ): HTMLElementTagNameMap[K] { // Create element using DOM API - const doc = document; - return doc.createElement(tagName); + return document.createElement(tagName); } diff --git a/test/helpers/node-test-helpers.ts b/test/helpers/node-test-helpers.ts new file mode 100644 index 0000000..49ec0a1 --- /dev/null +++ b/test/helpers/node-test-helpers.ts @@ -0,0 +1,27 @@ +import { ModelNode, NODE_TYPES } from '../../src'; + +/** + * Helper to create ModelNode instances for tests + */ +export function createTestNode(params: { + id: string; + type?: 'mesh' | 'variant'; + name?: string; + isVisible: boolean; +}): ModelNode { + return new ModelNode({ + id: params.id, + name: params.name ?? params.id, + type: params.type ?? NODE_TYPES.MESH, + visible: params.isVisible, + }); +} + +/** + * Helper to create multiple test nodes at once + */ +export function createTestNodes( + nodes: Array<{ id: string; type?: 'mesh' | 'variant'; name?: string; isVisible: boolean }>, +): ModelNode[] { + return nodes.map(node => createTestNode(node)); +} From a20f61c014242d1f84d5c07377e609ea685f8d5b Mon Sep 17 00:00:00 2001 From: HMAZonderland Date: Fri, 1 Aug 2025 22:12:57 +0200 Subject: [PATCH 06/15] chore: update dependencies - Update package dependencies - Update pnpm lock file --- package.json | 6 +- pnpm-lock.yaml | 388 ++++++++++++++++++++++++------------------------- 2 files changed, 197 insertions(+), 197 deletions(-) diff --git a/package.json b/package.json index ef0f84f..bd04806 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "ajv": "8.17.1" }, "devDependencies": { - "@eslint/js": "^9.31.0", + "@eslint/js": "^9.32.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/exec": "7.1.0", @@ -34,7 +34,7 @@ "@virtualdisplay-io/shared-config": "^1.4.0", "@vitest/coverage-v8": "^3.2.4", "conventional-changelog-conventionalcommits": "9.1.0", - "eslint": "^9.31.0", + "eslint": "^9.32.0", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-prettier": "^5.5.3", @@ -47,7 +47,7 @@ "semantic-release": "^24.2.7", "terser": "^5.43.1", "typescript": "^5.8.3", - "vite": "^7.0.5", + "vite": "^7.0.6", "vite-bundle-visualizer": "^1.2.1", "vite-plugin-dts": "^4.5.4", "vitest": "^3.2.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9846f72..24697a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,8 +16,8 @@ importers: version: 8.17.1 devDependencies: '@eslint/js': - specifier: ^9.31.0 - version: 9.31.0 + specifier: ^9.32.0 + version: 9.32.0 '@semantic-release/changelog': specifier: ^6.0.3 version: 6.0.3(semantic-release@24.2.7(typescript@5.8.3)) @@ -44,13 +44,13 @@ importers: version: 24.1.0 '@typescript-eslint/eslint-plugin': specifier: ^8.38.0 - version: 8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint@9.31.0)(typescript@5.8.3) + version: 8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0)(typescript@5.8.3) '@typescript-eslint/parser': specifier: ^8.38.0 - version: 8.38.0(eslint@9.31.0)(typescript@5.8.3) + version: 8.38.0(eslint@9.32.0)(typescript@5.8.3) '@virtualdisplay-io/shared-config': specifier: ^1.4.0 - version: 1.4.0(@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint@9.31.0)(typescript@5.8.3))(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint@9.31.0))(eslint-plugin-jsx-a11y@6.10.2(eslint@9.31.0))(eslint-plugin-unicorn@60.0.0(eslint@9.31.0))(eslint@9.31.0)(markdownlint-cli2@0.18.1)(prettier@3.6.2)(typescript@5.8.3) + version: 1.4.0(@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0)(typescript@5.8.3))(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0))(eslint-plugin-jsx-a11y@6.10.2(eslint@9.32.0))(eslint-plugin-unicorn@60.0.0(eslint@9.32.0))(eslint@9.32.0)(markdownlint-cli2@0.18.1)(prettier@3.6.2)(typescript@5.8.3) '@vitest/coverage-v8': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.1.0)(jsdom@26.1.0)(terser@5.43.1)) @@ -58,20 +58,20 @@ importers: specifier: 9.1.0 version: 9.1.0 eslint: - specifier: ^9.31.0 - version: 9.31.0 + specifier: ^9.32.0 + version: 9.32.0 eslint-plugin-import: specifier: ^2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint@9.31.0) + version: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0) eslint-plugin-jsx-a11y: specifier: ^6.10.2 - version: 6.10.2(eslint@9.31.0) + version: 6.10.2(eslint@9.32.0) eslint-plugin-prettier: specifier: ^5.5.3 - version: 5.5.3(eslint@9.31.0)(prettier@3.6.2) + version: 5.5.3(eslint@9.32.0)(prettier@3.6.2) eslint-plugin-unicorn: specifier: ^60.0.0 - version: 60.0.0(eslint@9.31.0) + version: 60.0.0(eslint@9.32.0) globals: specifier: ^16.3.0 version: 16.3.0 @@ -97,14 +97,14 @@ importers: specifier: ^5.8.3 version: 5.8.3 vite: - specifier: ^7.0.5 - version: 7.0.5(@types/node@24.1.0)(terser@5.43.1) + specifier: ^7.0.6 + version: 7.0.6(@types/node@24.1.0)(terser@5.43.1) vite-bundle-visualizer: specifier: ^1.2.1 - version: 1.2.1(rollup@4.45.1) + version: 1.2.1(rollup@4.46.1) vite-plugin-dts: specifier: ^4.5.4 - version: 4.5.4(@types/node@24.1.0)(rollup@4.45.1)(typescript@5.8.3)(vite@7.0.5(@types/node@24.1.0)(terser@5.43.1)) + version: 4.5.4(@types/node@24.1.0)(rollup@4.46.1)(typescript@5.8.3)(vite@7.0.6(@types/node@24.1.0)(terser@5.43.1)) vitest: specifier: ^3.2.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@24.1.0)(jsdom@26.1.0)(terser@5.43.1) @@ -135,8 +135,8 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/types@7.28.1': - resolution: {integrity: sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==} + '@babel/types@7.28.2': + resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==} engines: {node: '>=6.9.0'} '@bcoe/v8-coverage@1.0.2': @@ -357,8 +357,8 @@ packages: resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.31.0': - resolution: {integrity: sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==} + '@eslint/js@9.32.0': + resolution: {integrity: sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.6': @@ -515,103 +515,103 @@ packages: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.45.1': - resolution: {integrity: sha512-NEySIFvMY0ZQO+utJkgoMiCAjMrGvnbDLHvcmlA33UXJpYBCvlBEbMMtV837uCkS+plG2umfhn0T5mMAxGrlRA==} + '@rollup/rollup-android-arm-eabi@4.46.1': + resolution: {integrity: sha512-oENme6QxtLCqjChRUUo3S6X8hjCXnWmJWnedD7VbGML5GUtaOtAyx+fEEXnBXVf0CBZApMQU0Idwi0FmyxzQhw==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.45.1': - resolution: {integrity: sha512-ujQ+sMXJkg4LRJaYreaVx7Z/VMgBBd89wGS4qMrdtfUFZ+TSY5Rs9asgjitLwzeIbhwdEhyj29zhst3L1lKsRQ==} + '@rollup/rollup-android-arm64@4.46.1': + resolution: {integrity: sha512-OikvNT3qYTl9+4qQ9Bpn6+XHM+ogtFadRLuT2EXiFQMiNkXFLQfNVppi5o28wvYdHL2s3fM0D/MZJ8UkNFZWsw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.45.1': - resolution: {integrity: sha512-FSncqHvqTm3lC6Y13xncsdOYfxGSLnP+73k815EfNmpewPs+EyM49haPS105Rh4aF5mJKywk9X0ogzLXZzN9lA==} + '@rollup/rollup-darwin-arm64@4.46.1': + resolution: {integrity: sha512-EFYNNGij2WllnzljQDQnlFTXzSJw87cpAs4TVBAWLdkvic5Uh5tISrIL6NRcxoh/b2EFBG/TK8hgRrGx94zD4A==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.45.1': - resolution: {integrity: sha512-2/vVn/husP5XI7Fsf/RlhDaQJ7x9zjvC81anIVbr4b/f0xtSmXQTFcGIQ/B1cXIYM6h2nAhJkdMHTnD7OtQ9Og==} + '@rollup/rollup-darwin-x64@4.46.1': + resolution: {integrity: sha512-ZaNH06O1KeTug9WI2+GRBE5Ujt9kZw4a1+OIwnBHal92I8PxSsl5KpsrPvthRynkhMck4XPdvY0z26Cym/b7oA==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.45.1': - resolution: {integrity: sha512-4g1kaDxQItZsrkVTdYQ0bxu4ZIQ32cotoQbmsAnW1jAE4XCMbcBPDirX5fyUzdhVCKgPcrwWuucI8yrVRBw2+g==} + '@rollup/rollup-freebsd-arm64@4.46.1': + resolution: {integrity: sha512-n4SLVebZP8uUlJ2r04+g2U/xFeiQlw09Me5UFqny8HGbARl503LNH5CqFTb5U5jNxTouhRjai6qPT0CR5c/Iig==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.45.1': - resolution: {integrity: sha512-L/6JsfiL74i3uK1Ti2ZFSNsp5NMiM4/kbbGEcOCps99aZx3g8SJMO1/9Y0n/qKlWZfn6sScf98lEOUe2mBvW9A==} + '@rollup/rollup-freebsd-x64@4.46.1': + resolution: {integrity: sha512-8vu9c02F16heTqpvo3yeiu7Vi1REDEC/yES/dIfq3tSXe6mLndiwvYr3AAvd1tMNUqE9yeGYa5w7PRbI5QUV+w==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.45.1': - resolution: {integrity: sha512-RkdOTu2jK7brlu+ZwjMIZfdV2sSYHK2qR08FUWcIoqJC2eywHbXr0L8T/pONFwkGukQqERDheaGTeedG+rra6Q==} + '@rollup/rollup-linux-arm-gnueabihf@4.46.1': + resolution: {integrity: sha512-K4ncpWl7sQuyp6rWiGUvb6Q18ba8mzM0rjWJ5JgYKlIXAau1db7hZnR0ldJvqKWWJDxqzSLwGUhA4jp+KqgDtQ==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.45.1': - resolution: {integrity: sha512-3kJ8pgfBt6CIIr1o+HQA7OZ9mp/zDk3ctekGl9qn/pRBgrRgfwiffaUmqioUGN9hv0OHv2gxmvdKOkARCtRb8Q==} + '@rollup/rollup-linux-arm-musleabihf@4.46.1': + resolution: {integrity: sha512-YykPnXsjUjmXE6j6k2QBBGAn1YsJUix7pYaPLK3RVE0bQL2jfdbfykPxfF8AgBlqtYbfEnYHmLXNa6QETjdOjQ==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.45.1': - resolution: {integrity: sha512-k3dOKCfIVixWjG7OXTCOmDfJj3vbdhN0QYEqB+OuGArOChek22hn7Uy5A/gTDNAcCy5v2YcXRJ/Qcnm4/ma1xw==} + '@rollup/rollup-linux-arm64-gnu@4.46.1': + resolution: {integrity: sha512-kKvqBGbZ8i9pCGW3a1FH3HNIVg49dXXTsChGFsHGXQaVJPLA4f/O+XmTxfklhccxdF5FefUn2hvkoGJH0ScWOA==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.45.1': - resolution: {integrity: sha512-PmI1vxQetnM58ZmDFl9/Uk2lpBBby6B6rF4muJc65uZbxCs0EA7hhKCk2PKlmZKuyVSHAyIw3+/SiuMLxKxWog==} + '@rollup/rollup-linux-arm64-musl@4.46.1': + resolution: {integrity: sha512-zzX5nTw1N1plmqC9RGC9vZHFuiM7ZP7oSWQGqpbmfjK7p947D518cVK1/MQudsBdcD84t6k70WNczJOct6+hdg==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loongarch64-gnu@4.45.1': - resolution: {integrity: sha512-9UmI0VzGmNJ28ibHW2GpE2nF0PBQqsyiS4kcJ5vK+wuwGnV5RlqdczVocDSUfGX/Na7/XINRVoUgJyFIgipoRg==} + '@rollup/rollup-linux-loongarch64-gnu@4.46.1': + resolution: {integrity: sha512-O8CwgSBo6ewPpktFfSDgB6SJN9XDcPSvuwxfejiddbIC/hn9Tg6Ai0f0eYDf3XvB/+PIWzOQL+7+TZoB8p9Yuw==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-powerpc64le-gnu@4.45.1': - resolution: {integrity: sha512-7nR2KY8oEOUTD3pBAxIBBbZr0U7U+R9HDTPNy+5nVVHDXI4ikYniH1oxQz9VoB5PbBU1CZuDGHkLJkd3zLMWsg==} + '@rollup/rollup-linux-ppc64-gnu@4.46.1': + resolution: {integrity: sha512-JnCfFVEKeq6G3h3z8e60kAp8Rd7QVnWCtPm7cxx+5OtP80g/3nmPtfdCXbVl063e3KsRnGSKDHUQMydmzc/wBA==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.45.1': - resolution: {integrity: sha512-nlcl3jgUultKROfZijKjRQLUu9Ma0PeNv/VFHkZiKbXTBQXhpytS8CIj5/NfBeECZtY2FJQubm6ltIxm/ftxpw==} + '@rollup/rollup-linux-riscv64-gnu@4.46.1': + resolution: {integrity: sha512-dVxuDqS237eQXkbYzQQfdf/njgeNw6LZuVyEdUaWwRpKHhsLI+y4H/NJV8xJGU19vnOJCVwaBFgr936FHOnJsQ==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.45.1': - resolution: {integrity: sha512-HJV65KLS51rW0VY6rvZkiieiBnurSzpzore1bMKAhunQiECPuxsROvyeaot/tcK3A3aGnI+qTHqisrpSgQrpgA==} + '@rollup/rollup-linux-riscv64-musl@4.46.1': + resolution: {integrity: sha512-CvvgNl2hrZrTR9jXK1ye0Go0HQRT6ohQdDfWR47/KFKiLd5oN5T14jRdUVGF4tnsN8y9oSfMOqH6RuHh+ck8+w==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.45.1': - resolution: {integrity: sha512-NITBOCv3Qqc6hhwFt7jLV78VEO/il4YcBzoMGGNxznLgRQf43VQDae0aAzKiBeEPIxnDrACiMgbqjuihx08OOw==} + '@rollup/rollup-linux-s390x-gnu@4.46.1': + resolution: {integrity: sha512-x7ANt2VOg2565oGHJ6rIuuAon+A8sfe1IeUx25IKqi49OjSr/K3awoNqr9gCwGEJo9OuXlOn+H2p1VJKx1psxA==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.45.1': - resolution: {integrity: sha512-+E/lYl6qu1zqgPEnTrs4WysQtvc/Sh4fC2nByfFExqgYrqkKWp1tWIbe+ELhixnenSpBbLXNi6vbEEJ8M7fiHw==} + '@rollup/rollup-linux-x64-gnu@4.46.1': + resolution: {integrity: sha512-9OADZYryz/7E8/qt0vnaHQgmia2Y0wrjSSn1V/uL+zw/i7NUhxbX4cHXdEQ7dnJgzYDS81d8+tf6nbIdRFZQoQ==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.45.1': - resolution: {integrity: sha512-a6WIAp89p3kpNoYStITT9RbTbTnqarU7D8N8F2CV+4Cl9fwCOZraLVuVFvlpsW0SbIiYtEnhCZBPLoNdRkjQFw==} + '@rollup/rollup-linux-x64-musl@4.46.1': + resolution: {integrity: sha512-NuvSCbXEKY+NGWHyivzbjSVJi68Xfq1VnIvGmsuXs6TCtveeoDRKutI5vf2ntmNnVq64Q4zInet0UDQ+yMB6tA==} cpu: [x64] os: [linux] - '@rollup/rollup-win32-arm64-msvc@4.45.1': - resolution: {integrity: sha512-T5Bi/NS3fQiJeYdGvRpTAP5P02kqSOpqiopwhj0uaXB6nzs5JVi2XMJb18JUSKhCOX8+UE1UKQufyD6Or48dJg==} + '@rollup/rollup-win32-arm64-msvc@4.46.1': + resolution: {integrity: sha512-mWz+6FSRb82xuUMMV1X3NGiaPFqbLN9aIueHleTZCc46cJvwTlvIh7reQLk4p97dv0nddyewBhwzryBHH7wtPw==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.45.1': - resolution: {integrity: sha512-lxV2Pako3ujjuUe9jiU3/s7KSrDfH6IgTSQOnDWr9aJ92YsFd7EurmClK0ly/t8dzMkDtd04g60WX6yl0sGfdw==} + '@rollup/rollup-win32-ia32-msvc@4.46.1': + resolution: {integrity: sha512-7Thzy9TMXDw9AU4f4vsLNBxh7/VOKuXi73VH3d/kHGr0tZ3x/ewgL9uC7ojUKmH1/zvmZe2tLapYcZllk3SO8Q==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.45.1': - resolution: {integrity: sha512-M/fKi4sasCdM8i0aWJjCSFm2qEnYRR8AMLG2kxp6wD13+tMGA4Z1tVAuHkNRjud5SW2EM3naLuK35w9twvf6aA==} + '@rollup/rollup-win32-x64-msvc@4.46.1': + resolution: {integrity: sha512-7GVB4luhFmGUNXXJhH2jJwZCFB3pIOixv2E3s17GQHBFUOQaISlt7aGcQgqvCaDSxTZJUzlK/QJ1FN8S94MrzQ==} cpu: [x64] os: [win32] @@ -856,14 +856,14 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} - '@volar/language-core@2.4.20': - resolution: {integrity: sha512-dRDF1G33xaAIDqR6+mXUIjXYdu9vzSxlMGfMEwBxQsfY/JMUEXSpLTR057oTKlUQ2nIvCmP9k94A8h8z2VrNSA==} + '@volar/language-core@2.4.22': + resolution: {integrity: sha512-gp4M7Di5KgNyIyO903wTClYBavRt6UyFNpc5LWfyZr1lBsTUY+QrVZfmbNF2aCyfklBOVk9YC4p+zkwoyT7ECg==} - '@volar/source-map@2.4.20': - resolution: {integrity: sha512-mVjmFQH8mC+nUaVwmbxoYUy8cww+abaO8dWzqPUjilsavjxH0jCJ3Mp8HFuHsdewZs2c+SP+EO7hCd8Z92whJg==} + '@volar/source-map@2.4.22': + resolution: {integrity: sha512-L2nVr/1vei0xKRgO2tYVXtJYd09HTRjaZi418e85Q+QdbbqA8h7bBjfNyPPSsjnrOO4l4kaAo78c8SQUAdHvgA==} - '@volar/typescript@2.4.20': - resolution: {integrity: sha512-Oc4DczPwQyXcVbd+5RsNEqX6ia0+w3p+klwdZQ6ZKhFjWoBP9PCPQYlKYRi/tDemWphW93P/Vv13vcE9I9D2GQ==} + '@volar/typescript@2.4.22': + resolution: {integrity: sha512-6ZczlJW1/GWTrNnkmZxJp4qyBt/SGVlcTuCWpI5zLrdPdCZsj66Aff9ZsfFaT3TyjG8zVYgBMYPuCm/eRkpcpQ==} '@vue/compiler-core@3.5.18': resolution: {integrity: sha512-3slwjQrrV1TO8MoXgy3aynDQ7lslj5UqDxuHnrzHtpON5CBinhWjJETciPngpin/T3OuW3tXUf86tEurusnztw==} @@ -1341,8 +1341,8 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - electron-to-chromium@1.5.190: - resolution: {integrity: sha512-k4McmnB2091YIsdCgkS0fMVMPOJgxl93ltFzaryXqwip1AaxeDqKCGLxkXODDA5Ab/D+tV5EL5+aTx76RvLRxw==} + electron-to-chromium@1.5.192: + resolution: {integrity: sha512-rP8Ez0w7UNw/9j5eSXCe10o1g/8B1P5SM90PCCMVkIRQn2R0LEHWz4Eh9RnxkniuDe1W0cTSOB3MLlkTGDcuCg==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1506,8 +1506,8 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint@9.31.0: - resolution: {integrity: sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==} + eslint@9.32.0: + resolution: {integrity: sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -2525,8 +2525,8 @@ packages: - which - write-file-atomic - nwsapi@2.2.20: - resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==} + nwsapi@2.2.21: + resolution: {integrity: sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA==} object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} @@ -2846,8 +2846,8 @@ packages: rollup: optional: true - rollup@4.45.1: - resolution: {integrity: sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==} + rollup@4.46.1: + resolution: {integrity: sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -2975,9 +2975,9 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} - source-map@0.7.4: - resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} - engines: {node: '>= 8'} + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} spawn-error-forwarder@1.0.0: resolution: {integrity: sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g==} @@ -3328,8 +3328,8 @@ packages: vite: optional: true - vite@7.0.5: - resolution: {integrity: sha512-1mncVwJxy2C9ThLwz0+2GKZyEXuC3MyWtAAlNftlZZXZDP3AJt5FmwcMit/IGGaNZ8ZOB2BNO/HFUB+CpN0NQw==} + vite@7.0.6: + resolution: {integrity: sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -3541,9 +3541,9 @@ snapshots: '@babel/parser@7.28.0': dependencies: - '@babel/types': 7.28.1 + '@babel/types': 7.28.2 - '@babel/types@7.28.1': + '@babel/types@7.28.2': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 @@ -3651,9 +3651,9 @@ snapshots: '@esbuild/win32-x64@0.25.8': optional: true - '@eslint-community/eslint-utils@4.7.0(eslint@9.31.0)': + '@eslint-community/eslint-utils@4.7.0(eslint@9.32.0)': dependencies: - eslint: 9.31.0 + eslint: 9.32.0 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.1': {} @@ -3686,7 +3686,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@9.31.0': {} + '@eslint/js@9.32.0': {} '@eslint/object-schema@2.1.6': {} @@ -3861,72 +3861,72 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 - '@rollup/pluginutils@5.2.0(rollup@4.45.1)': + '@rollup/pluginutils@5.2.0(rollup@4.46.1)': dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 picomatch: 4.0.3 optionalDependencies: - rollup: 4.45.1 + rollup: 4.46.1 - '@rollup/rollup-android-arm-eabi@4.45.1': + '@rollup/rollup-android-arm-eabi@4.46.1': optional: true - '@rollup/rollup-android-arm64@4.45.1': + '@rollup/rollup-android-arm64@4.46.1': optional: true - '@rollup/rollup-darwin-arm64@4.45.1': + '@rollup/rollup-darwin-arm64@4.46.1': optional: true - '@rollup/rollup-darwin-x64@4.45.1': + '@rollup/rollup-darwin-x64@4.46.1': optional: true - '@rollup/rollup-freebsd-arm64@4.45.1': + '@rollup/rollup-freebsd-arm64@4.46.1': optional: true - '@rollup/rollup-freebsd-x64@4.45.1': + '@rollup/rollup-freebsd-x64@4.46.1': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.45.1': + '@rollup/rollup-linux-arm-gnueabihf@4.46.1': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.45.1': + '@rollup/rollup-linux-arm-musleabihf@4.46.1': optional: true - '@rollup/rollup-linux-arm64-gnu@4.45.1': + '@rollup/rollup-linux-arm64-gnu@4.46.1': optional: true - '@rollup/rollup-linux-arm64-musl@4.45.1': + '@rollup/rollup-linux-arm64-musl@4.46.1': optional: true - '@rollup/rollup-linux-loongarch64-gnu@4.45.1': + '@rollup/rollup-linux-loongarch64-gnu@4.46.1': optional: true - '@rollup/rollup-linux-powerpc64le-gnu@4.45.1': + '@rollup/rollup-linux-ppc64-gnu@4.46.1': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.45.1': + '@rollup/rollup-linux-riscv64-gnu@4.46.1': optional: true - '@rollup/rollup-linux-riscv64-musl@4.45.1': + '@rollup/rollup-linux-riscv64-musl@4.46.1': optional: true - '@rollup/rollup-linux-s390x-gnu@4.45.1': + '@rollup/rollup-linux-s390x-gnu@4.46.1': optional: true - '@rollup/rollup-linux-x64-gnu@4.45.1': + '@rollup/rollup-linux-x64-gnu@4.46.1': optional: true - '@rollup/rollup-linux-x64-musl@4.45.1': + '@rollup/rollup-linux-x64-musl@4.46.1': optional: true - '@rollup/rollup-win32-arm64-msvc@4.45.1': + '@rollup/rollup-win32-arm64-msvc@4.46.1': optional: true - '@rollup/rollup-win32-ia32-msvc@4.45.1': + '@rollup/rollup-win32-ia32-msvc@4.46.1': optional: true - '@rollup/rollup-win32-x64-msvc@4.45.1': + '@rollup/rollup-win32-x64-msvc@4.46.1': optional: true '@rtsao/scc@1.1.0': {} @@ -4110,15 +4110,15 @@ snapshots: '@types/unist@2.0.11': {} - '@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint@9.31.0)(typescript@5.8.3)': + '@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0)(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.38.0(eslint@9.31.0)(typescript@5.8.3) + '@typescript-eslint/parser': 8.38.0(eslint@9.32.0)(typescript@5.8.3) '@typescript-eslint/scope-manager': 8.38.0 - '@typescript-eslint/type-utils': 8.38.0(eslint@9.31.0)(typescript@5.8.3) - '@typescript-eslint/utils': 8.38.0(eslint@9.31.0)(typescript@5.8.3) + '@typescript-eslint/type-utils': 8.38.0(eslint@9.32.0)(typescript@5.8.3) + '@typescript-eslint/utils': 8.38.0(eslint@9.32.0)(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.38.0 - eslint: 9.31.0 + eslint: 9.32.0 graphemer: 1.4.0 ignore: 7.0.5 natural-compare: 1.4.0 @@ -4127,14 +4127,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3)': + '@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3)': dependencies: '@typescript-eslint/scope-manager': 8.38.0 '@typescript-eslint/types': 8.38.0 '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.38.0 debug: 4.4.1 - eslint: 9.31.0 + eslint: 9.32.0 typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -4157,13 +4157,13 @@ snapshots: dependencies: typescript: 5.8.3 - '@typescript-eslint/type-utils@8.38.0(eslint@9.31.0)(typescript@5.8.3)': + '@typescript-eslint/type-utils@8.38.0(eslint@9.32.0)(typescript@5.8.3)': dependencies: '@typescript-eslint/types': 8.38.0 '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3) - '@typescript-eslint/utils': 8.38.0(eslint@9.31.0)(typescript@5.8.3) + '@typescript-eslint/utils': 8.38.0(eslint@9.32.0)(typescript@5.8.3) debug: 4.4.1 - eslint: 9.31.0 + eslint: 9.32.0 ts-api-utils: 2.1.0(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: @@ -4187,13 +4187,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.38.0(eslint@9.31.0)(typescript@5.8.3)': + '@typescript-eslint/utils@8.38.0(eslint@9.32.0)(typescript@5.8.3)': dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.31.0) + '@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0) '@typescript-eslint/scope-manager': 8.38.0 '@typescript-eslint/types': 8.38.0 '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3) - eslint: 9.31.0 + eslint: 9.32.0 typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -4205,15 +4205,15 @@ snapshots: '@virtualdisplay-io/logger@1.2.0': {} - '@virtualdisplay-io/shared-config@1.4.0(@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint@9.31.0)(typescript@5.8.3))(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint@9.31.0))(eslint-plugin-jsx-a11y@6.10.2(eslint@9.31.0))(eslint-plugin-unicorn@60.0.0(eslint@9.31.0))(eslint@9.31.0)(markdownlint-cli2@0.18.1)(prettier@3.6.2)(typescript@5.8.3)': + '@virtualdisplay-io/shared-config@1.4.0(@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0)(typescript@5.8.3))(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0))(eslint-plugin-jsx-a11y@6.10.2(eslint@9.32.0))(eslint-plugin-unicorn@60.0.0(eslint@9.32.0))(eslint@9.32.0)(markdownlint-cli2@0.18.1)(prettier@3.6.2)(typescript@5.8.3)': dependencies: - '@typescript-eslint/eslint-plugin': 8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint@9.31.0)(typescript@5.8.3) - '@typescript-eslint/parser': 8.38.0(eslint@9.31.0)(typescript@5.8.3) - eslint: 9.31.0 - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint@9.31.0) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.31.0) - eslint-plugin-lit: 2.1.1(eslint@9.31.0) - eslint-plugin-unicorn: 60.0.0(eslint@9.31.0) + '@typescript-eslint/eslint-plugin': 8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0)(typescript@5.8.3) + '@typescript-eslint/parser': 8.38.0(eslint@9.32.0)(typescript@5.8.3) + eslint: 9.32.0 + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.32.0) + eslint-plugin-lit: 2.1.1(eslint@9.32.0) + eslint-plugin-unicorn: 60.0.0(eslint@9.32.0) markdownlint-cli2: 0.18.1 prettier: 3.6.2 typescript: 5.8.3 @@ -4245,13 +4245,13 @@ snapshots: chai: 5.2.1 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.0.5(@types/node@24.1.0)(terser@5.43.1))': + '@vitest/mocker@3.2.4(vite@7.0.6(@types/node@24.1.0)(terser@5.43.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 7.0.5(@types/node@24.1.0)(terser@5.43.1) + vite: 7.0.6(@types/node@24.1.0)(terser@5.43.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -4279,15 +4279,15 @@ snapshots: loupe: 3.2.0 tinyrainbow: 2.0.0 - '@volar/language-core@2.4.20': + '@volar/language-core@2.4.22': dependencies: - '@volar/source-map': 2.4.20 + '@volar/source-map': 2.4.22 - '@volar/source-map@2.4.20': {} + '@volar/source-map@2.4.22': {} - '@volar/typescript@2.4.20': + '@volar/typescript@2.4.22': dependencies: - '@volar/language-core': 2.4.20 + '@volar/language-core': 2.4.22 path-browserify: 1.0.1 vscode-uri: 3.1.0 @@ -4311,7 +4311,7 @@ snapshots: '@vue/language-core@2.2.0(typescript@5.8.3)': dependencies: - '@volar/language-core': 2.4.20 + '@volar/language-core': 2.4.22 '@vue/compiler-dom': 3.5.18 '@vue/compiler-vue2': 2.7.16 '@vue/shared': 3.5.18 @@ -4504,7 +4504,7 @@ snapshots: browserslist@4.25.1: dependencies: caniuse-lite: 1.0.30001727 - electron-to-chromium: 1.5.190 + electron-to-chromium: 1.5.192 node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.25.1) @@ -4785,7 +4785,7 @@ snapshots: eastasianwidth@0.2.0: {} - electron-to-chromium@1.5.190: {} + electron-to-chromium@1.5.192: {} emoji-regex@8.0.0: {} @@ -4939,17 +4939,17 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.31.0): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.32.0): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.38.0(eslint@9.31.0)(typescript@5.8.3) - eslint: 9.31.0 + '@typescript-eslint/parser': 8.38.0(eslint@9.32.0)(typescript@5.8.3) + eslint: 9.32.0 eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint@9.31.0): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -4958,9 +4958,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.31.0 + eslint: 9.32.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.31.0) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.32.0) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -4972,13 +4972,13 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.38.0(eslint@9.31.0)(typescript@5.8.3) + '@typescript-eslint/parser': 8.38.0(eslint@9.32.0)(typescript@5.8.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.31.0): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.32.0): dependencies: aria-query: 5.3.2 array-includes: 3.1.9 @@ -4988,7 +4988,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.31.0 + eslint: 9.32.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -4997,29 +4997,29 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-lit@2.1.1(eslint@9.31.0): + eslint-plugin-lit@2.1.1(eslint@9.32.0): dependencies: - eslint: 9.31.0 + eslint: 9.32.0 parse5: 6.0.1 parse5-htmlparser2-tree-adapter: 6.0.1 - eslint-plugin-prettier@5.5.3(eslint@9.31.0)(prettier@3.6.2): + eslint-plugin-prettier@5.5.3(eslint@9.32.0)(prettier@3.6.2): dependencies: - eslint: 9.31.0 + eslint: 9.32.0 prettier: 3.6.2 prettier-linter-helpers: 1.0.0 synckit: 0.11.11 - eslint-plugin-unicorn@60.0.0(eslint@9.31.0): + eslint-plugin-unicorn@60.0.0(eslint@9.32.0): dependencies: '@babel/helper-validator-identifier': 7.27.1 - '@eslint-community/eslint-utils': 4.7.0(eslint@9.31.0) + '@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0) '@eslint/plugin-kit': 0.3.4 change-case: 5.4.4 ci-info: 4.3.0 clean-regexp: 1.0.0 core-js-compat: 3.44.0 - eslint: 9.31.0 + eslint: 9.32.0 esquery: 1.6.0 find-up-simple: 1.0.1 globals: 16.3.0 @@ -5041,15 +5041,15 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.31.0: + eslint@9.32.0: dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.31.0) + '@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0) '@eslint-community/regexpp': 4.12.1 '@eslint/config-array': 0.21.0 '@eslint/config-helpers': 0.3.0 '@eslint/core': 0.15.1 '@eslint/eslintrc': 3.3.1 - '@eslint/js': 9.31.0 + '@eslint/js': 9.32.0 '@eslint/plugin-kit': 0.3.4 '@humanfs/node': 0.16.6 '@humanwhocodes/module-importer': 1.0.1 @@ -5677,7 +5677,7 @@ snapshots: http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 - nwsapi: 2.2.20 + nwsapi: 2.2.21 parse5: 7.3.0 rrweb-cssom: 0.8.0 saxes: 6.0.0 @@ -5810,7 +5810,7 @@ snapshots: magicast@0.3.5: dependencies: '@babel/parser': 7.28.0 - '@babel/types': 7.28.1 + '@babel/types': 7.28.2 source-map-js: 1.2.1 make-dir@4.0.0: @@ -6142,7 +6142,7 @@ snapshots: npm@10.9.3: {} - nwsapi@2.2.20: {} + nwsapi@2.2.21: {} object-assign@4.1.1: {} @@ -6449,39 +6449,39 @@ snapshots: reusify@1.1.0: {} - rollup-plugin-visualizer@5.14.0(rollup@4.45.1): + rollup-plugin-visualizer@5.14.0(rollup@4.46.1): dependencies: open: 8.4.2 picomatch: 4.0.3 - source-map: 0.7.4 + source-map: 0.7.6 yargs: 17.7.2 optionalDependencies: - rollup: 4.45.1 + rollup: 4.46.1 - rollup@4.45.1: + rollup@4.46.1: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.45.1 - '@rollup/rollup-android-arm64': 4.45.1 - '@rollup/rollup-darwin-arm64': 4.45.1 - '@rollup/rollup-darwin-x64': 4.45.1 - '@rollup/rollup-freebsd-arm64': 4.45.1 - '@rollup/rollup-freebsd-x64': 4.45.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.45.1 - '@rollup/rollup-linux-arm-musleabihf': 4.45.1 - '@rollup/rollup-linux-arm64-gnu': 4.45.1 - '@rollup/rollup-linux-arm64-musl': 4.45.1 - '@rollup/rollup-linux-loongarch64-gnu': 4.45.1 - '@rollup/rollup-linux-powerpc64le-gnu': 4.45.1 - '@rollup/rollup-linux-riscv64-gnu': 4.45.1 - '@rollup/rollup-linux-riscv64-musl': 4.45.1 - '@rollup/rollup-linux-s390x-gnu': 4.45.1 - '@rollup/rollup-linux-x64-gnu': 4.45.1 - '@rollup/rollup-linux-x64-musl': 4.45.1 - '@rollup/rollup-win32-arm64-msvc': 4.45.1 - '@rollup/rollup-win32-ia32-msvc': 4.45.1 - '@rollup/rollup-win32-x64-msvc': 4.45.1 + '@rollup/rollup-android-arm-eabi': 4.46.1 + '@rollup/rollup-android-arm64': 4.46.1 + '@rollup/rollup-darwin-arm64': 4.46.1 + '@rollup/rollup-darwin-x64': 4.46.1 + '@rollup/rollup-freebsd-arm64': 4.46.1 + '@rollup/rollup-freebsd-x64': 4.46.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.46.1 + '@rollup/rollup-linux-arm-musleabihf': 4.46.1 + '@rollup/rollup-linux-arm64-gnu': 4.46.1 + '@rollup/rollup-linux-arm64-musl': 4.46.1 + '@rollup/rollup-linux-loongarch64-gnu': 4.46.1 + '@rollup/rollup-linux-ppc64-gnu': 4.46.1 + '@rollup/rollup-linux-riscv64-gnu': 4.46.1 + '@rollup/rollup-linux-riscv64-musl': 4.46.1 + '@rollup/rollup-linux-s390x-gnu': 4.46.1 + '@rollup/rollup-linux-x64-gnu': 4.46.1 + '@rollup/rollup-linux-x64-musl': 4.46.1 + '@rollup/rollup-win32-arm64-msvc': 4.46.1 + '@rollup/rollup-win32-ia32-msvc': 4.46.1 + '@rollup/rollup-win32-x64-msvc': 4.46.1 fsevents: 2.3.3 rrweb-cssom@0.8.0: {} @@ -6649,7 +6649,7 @@ snapshots: source-map@0.6.1: {} - source-map@0.7.4: {} + source-map@0.7.6: {} spawn-error-forwarder@1.0.0: {} @@ -6980,11 +6980,11 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 - vite-bundle-visualizer@1.2.1(rollup@4.45.1): + vite-bundle-visualizer@1.2.1(rollup@4.46.1): dependencies: cac: 6.7.14 import-from-esm: 1.3.4 - rollup-plugin-visualizer: 5.14.0(rollup@4.45.1) + rollup-plugin-visualizer: 5.14.0(rollup@4.46.1) tmp: 0.2.3 transitivePeerDependencies: - rolldown @@ -6997,7 +6997,7 @@ snapshots: debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.0.5(@types/node@24.1.0)(terser@5.43.1) + vite: 7.0.6(@types/node@24.1.0)(terser@5.43.1) transitivePeerDependencies: - '@types/node' - jiti @@ -7012,11 +7012,11 @@ snapshots: - tsx - yaml - vite-plugin-dts@4.5.4(@types/node@24.1.0)(rollup@4.45.1)(typescript@5.8.3)(vite@7.0.5(@types/node@24.1.0)(terser@5.43.1)): + vite-plugin-dts@4.5.4(@types/node@24.1.0)(rollup@4.46.1)(typescript@5.8.3)(vite@7.0.6(@types/node@24.1.0)(terser@5.43.1)): dependencies: '@microsoft/api-extractor': 7.52.9(@types/node@24.1.0) - '@rollup/pluginutils': 5.2.0(rollup@4.45.1) - '@volar/typescript': 2.4.20 + '@rollup/pluginutils': 5.2.0(rollup@4.46.1) + '@volar/typescript': 2.4.22 '@vue/language-core': 2.2.0(typescript@5.8.3) compare-versions: 6.1.1 debug: 4.4.1 @@ -7025,19 +7025,19 @@ snapshots: magic-string: 0.30.17 typescript: 5.8.3 optionalDependencies: - vite: 7.0.5(@types/node@24.1.0)(terser@5.43.1) + vite: 7.0.6(@types/node@24.1.0)(terser@5.43.1) transitivePeerDependencies: - '@types/node' - rollup - supports-color - vite@7.0.5(@types/node@24.1.0)(terser@5.43.1): + vite@7.0.6(@types/node@24.1.0)(terser@5.43.1): dependencies: esbuild: 0.25.8 fdir: 6.4.6(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 - rollup: 4.45.1 + rollup: 4.46.1 tinyglobby: 0.2.14 optionalDependencies: '@types/node': 24.1.0 @@ -7048,7 +7048,7 @@ snapshots: dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.0.5(@types/node@24.1.0)(terser@5.43.1)) + '@vitest/mocker': 3.2.4(vite@7.0.6(@types/node@24.1.0)(terser@5.43.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -7066,7 +7066,7 @@ snapshots: tinyglobby: 0.2.14 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.0.5(@types/node@24.1.0)(terser@5.43.1) + vite: 7.0.6(@types/node@24.1.0)(terser@5.43.1) vite-node: 3.2.4(@types/node@24.1.0)(terser@5.43.1) why-is-node-running: 2.3.0 optionalDependencies: From 29df83ccb25ca1277a52fbea59f539b0f1dee436 Mon Sep 17 00:00:00 2001 From: HMAZonderland Date: Fri, 1 Aug 2025 22:13:15 +0200 Subject: [PATCH 07/15] fix: correct setConfig parameter types in VirtualdisplayViewerService - Fix setUiConfig to properly accept Partial - Fix setViewerConfig to properly accept Partial - This allows partial updates instead of requiring full config objects --- src/ui/virtualdisplay-viewer-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/virtualdisplay-viewer-service.ts b/src/ui/virtualdisplay-viewer-service.ts index d252fca..b6ae8e3 100644 --- a/src/ui/virtualdisplay-viewer-service.ts +++ b/src/ui/virtualdisplay-viewer-service.ts @@ -1,4 +1,4 @@ -import type { CameraConfig } from '../camera/camera-config'; +import type { CameraConfig } from '../camera'; import type { ClientOptions } from '../client/client-options'; import type { EventBus } from '../events/event-bus'; import { EVENT_NAMES } from '../events/event-names'; From 806ee93edfc73e21fabb0e53b1efd7daa72c22c5 Mon Sep 17 00:00:00 2001 From: HMAZonderland Date: Fri, 1 Aug 2025 22:13:35 +0200 Subject: [PATCH 08/15] test: update various tests for compatibility - Update camera config messaging tests - Update camera reset tests - Update camera tests - Update snapshot photo tests - Update snapshot message handler tests - Update snapshot tests These updates ensure all tests work correctly with the refactored code and new type definitions. --- test/integration/camera-config-messaging.test.ts | 3 +-- test/unit/camera/camera-reset.test.ts | 4 +--- test/unit/camera/camera.test.ts | 4 +--- test/unit/snapshot/photo.test.ts | 2 +- test/unit/snapshot/snapshot-message-handler.test.ts | 6 ++---- 5 files changed, 6 insertions(+), 13 deletions(-) diff --git a/test/integration/camera-config-messaging.test.ts b/test/integration/camera-config-messaging.test.ts index b0b335d..d9ff2df 100644 --- a/test/integration/camera-config-messaging.test.ts +++ b/test/integration/camera-config-messaging.test.ts @@ -2,8 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi, } from 'vitest'; -import { VirtualdisplayClient } from '../../src/index'; -import { MESSAGE_TYPES, type ConfigMessage } from '../../src/messaging/message-types'; +import { MESSAGE_TYPES, type ConfigMessage, VirtualdisplayClient } from '../../src'; // Test state let client = null as VirtualdisplayClient | null; diff --git a/test/unit/camera/camera-reset.test.ts b/test/unit/camera/camera-reset.test.ts index a80e07b..2eef353 100644 --- a/test/unit/camera/camera-reset.test.ts +++ b/test/unit/camera/camera-reset.test.ts @@ -1,9 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { Camera } from '../../../src/camera'; +import { Camera, EVENT_NAMES, MESSAGE_TYPES } from '../../../src'; import type { EventBus } from '../../../src/events/event-bus'; -import { EVENT_NAMES } from '../../../src/events/event-names'; -import { MESSAGE_TYPES } from '../../../src/messaging/message-types'; describe('Camera - reset', () => { let camera: Camera = null as unknown as Camera; diff --git a/test/unit/camera/camera.test.ts b/test/unit/camera/camera.test.ts index 22cd64b..77a9062 100644 --- a/test/unit/camera/camera.test.ts +++ b/test/unit/camera/camera.test.ts @@ -1,9 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { Camera } from '../../../src/camera'; +import { Camera, EVENT_NAMES, MESSAGE_TYPES } from '../../../src'; import type { EventBus } from '../../../src/events/event-bus'; -import { EVENT_NAMES } from '../../../src/events/event-names'; -import { MESSAGE_TYPES } from '../../../src/messaging/message-types'; describe('Camera', () => { let camera: Camera = null as unknown as Camera; diff --git a/test/unit/snapshot/photo.test.ts b/test/unit/snapshot/photo.test.ts index fe755d4..a0d39a5 100644 --- a/test/unit/snapshot/photo.test.ts +++ b/test/unit/snapshot/photo.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; -import { Photo, type PhotoData } from '../../../src/snapshot/photo'; +import { Photo, type PhotoData } from '../../../src'; describe('Photo - basic functionality', () => { describe('constructor', () => { diff --git a/test/unit/snapshot/snapshot-message-handler.test.ts b/test/unit/snapshot/snapshot-message-handler.test.ts index 9c23ede..44a6081 100644 --- a/test/unit/snapshot/snapshot-message-handler.test.ts +++ b/test/unit/snapshot/snapshot-message-handler.test.ts @@ -1,10 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MESSAGE_TYPES, EVENT_NAMES, Photo } from '../../../src'; import { EventBus } from '../../../src/events/event-bus'; -import { EVENT_NAMES } from '../../../src/events/event-names'; -import { MESSAGE_TYPES } from '../../../src/messaging/message-types'; -import { Photo } from '../../../src/snapshot/photo'; -import { SnapshotMessageHandler } from '../../../src/snapshot/snapshot-message-handler'; +import { SnapshotMessageHandler } from '../../../src/snapshot'; describe('SnapshotMessageHandler', () => { let eventBus = new EventBus(); From 4bd6e42a105a2e29c401c7d7beb97920fc129876 Mon Sep 17 00:00:00 2001 From: HMAZonderland Date: Mon, 4 Aug 2025 20:21:10 +0200 Subject: [PATCH 09/15] docs: add comprehensive mapping documentation - Add mapping-concept.md explaining core concepts with bicycle example - Add static-mapping-examples.md for simple configurations - Add dynamic-mapping-examples.md for advanced dependencies - Use consistent bicycle configurator example throughout - Include positioning concept where components move with frame size --- docs/dynamic-mapping-examples.md | 382 +++++++++++++++++++++++++++++++ docs/mapping-concept.md | 377 ++++++++++++++++++++++++++++++ docs/static-mapping-examples.md | 253 ++++++++++++++++++++ 3 files changed, 1012 insertions(+) create mode 100644 docs/dynamic-mapping-examples.md create mode 100644 docs/mapping-concept.md create mode 100644 docs/static-mapping-examples.md diff --git a/docs/dynamic-mapping-examples.md b/docs/dynamic-mapping-examples.md new file mode 100644 index 0000000..b057dc1 --- /dev/null +++ b/docs/dynamic-mapping-examples.md @@ -0,0 +1,382 @@ +# Dynamic Mapping Examples + +Dynamic mapping allows attribute values to change based on other selections, enabling complex product +configurations with dependencies. + +**Prerequisites**: This guide builds on the [Static Mapping Examples](./static-mapping-examples.md). +We'll use the same city bicycle but add realistic dependencies between options. If you haven't read +the static mapping guide yet, please start there. + +## What makes mapping "dynamic" + +In dynamic mapping, any property can be computed at runtime using a function: + +```typescript +{ + name: 'Wheel Size', + // Dynamic values based on frame size + values: (context) => { + const frameSize = context.getValue('Frame Size'); + if (frameSize === 'small') { + return [{ value: '26-inch', nodeIds: ['wheels_26'], isSelected: true }]; + } + return [{ value: '28-inch', nodeIds: ['wheels_28'], isSelected: true }]; + } +} +``` + +## City bicycle with dependencies + +Let's enhance our city bicycle from the static example. The key insight: frame size affects almost +everything else on the bicycle. This is where dynamic mapping shines - without it, you'd need +separate configurations for each frame size. + +### Frame size as the central driver + +When you select a frame size, it cascades through the entire configuration: + +**Small frame (150-165cm riders)**: + +- Uses 26" wheels positioned for smaller geometry +- Components positioned closer together (brakes, gears, lights) +- Basket mounted at lower position +- Limited gear options suitable for smaller frame + +**Medium frame (165-180cm riders)**: + +- Uses 28" wheels with standard positioning +- Components at standard positions +- Full range of gear and accessory options + +**Large frame (180-195cm riders)**: + +- Uses 28" wheels with extended positioning +- Components spread wider (matching frame geometry) +- Accessories positioned for larger frame +- May include frame-specific components + +**The key insight**: Components don't just change type - they also move to match the frame geometry. +The basket is always "basket" from the user's perspective, but the 3D model shows `basket_small`, +`basket_medium`, or `basket_large` nodes positioned appropriately for each frame. + +Without dynamic mapping, you'd need 3 separate product configurations! + +### Implementation + +```typescript +// Helper functions for business logic +// Tire types - same options but positioned for different wheel sizes +function getTireOptions(frameSize: string) { + const wheelSize = frameSize === 'small' ? '26' : '28'; + + const options = [ + { value: 'city', nodeIds: [`tires_${wheelSize}_city`], isSelected: true }, + { value: 'comfort', nodeIds: [`tires_${wheelSize}_comfort`] } + ]; + + // Sport tires only available for larger wheels + if (frameSize !== 'small') { + options.push({ value: 'sport', nodeIds: [`tires_${wheelSize}_sport`] }); + } + + return options; +} + +// Brake types - positioned for different wheel sizes +function getBrakeOptions(frameSize: string) { + const wheelSize = frameSize === 'small' ? '26' : '28'; + const defaultBrake = frameSize === 'small' ? 'rim' : 'disc'; + + return [ + { + value: 'rim', + nodeIds: [`brakes_rim_${wheelSize}`], + isSelected: defaultBrake === 'rim' + }, + { + value: 'disc', + nodeIds: [`brakes_disc_${wheelSize}`], + isSelected: defaultBrake === 'disc' + } + ]; +} + +function getBasketOptions(frameSize: string) { + // User always sees same options, but nodeIds change based on frame size + return [ + { value: 'none', nodeIds: [], isSelected: true }, + { + value: 'basket', + nodeIds: [`basket_${frameSize}`], // basket_small, basket_medium, basket_large + isSelected: false + } + ]; +} + +function getGripOptions(saddleType: string) { + // Sport saddle requires sport grips + if (saddleType === 'sport') { + return [ + { + value: 'sport-black', + nodeIds: ['grips_sport_black'], + isSelected: true, + }, + ]; + } + + // Comfort saddle allows choice of grips + return [ + { value: 'brown', nodeIds: ['grips_brown'], isSelected: true }, + { value: 'black', nodeIds: ['grips_black'] }, + ]; +} + +// The complete dynamic mapping +client.setMapping({ + attributes: [ + { + // Frame size is our primary driver - it affects almost everything else + name: 'Frame Size', + values: [ + { value: 'small', nodeIds: ['frame_small', 'wheels_26'], isSelected: false }, + { value: 'medium', nodeIds: ['frame_medium', 'wheels_28'], isSelected: true }, + { value: 'large', nodeIds: ['frame_large', 'wheels_28'], isSelected: false }, + ], + }, + { + name: 'Frame Color', + // Even colors can depend on frame size! + values: (context) => { + const frameSize = context.getValue('Frame Size'); + const colors = [ + { value: 'blue', nodeIds: ['paint_blue'], isSelected: true }, + { value: 'black', nodeIds: ['paint_black'] }, + ]; + + // Red is a premium color only available on medium/large frames + if (frameSize !== 'small') { + colors.push({ value: 'red', nodeIds: ['paint_red'] }); + } + + return colors; + }, + }, + { + name: 'Tires', + values: (context) => { + const frameSize = context.getValue('Frame Size'); + return getTireOptions(frameSize); + }, + }, + { + name: 'Brakes', + values: (context) => { + const frameSize = context.getValue('Frame Size'); + return getBrakeOptions(frameSize); + }, + }, + { + name: 'Saddle Type', + values: [ + { + value: 'comfort', + nodeIds: ['saddle_comfort_brown'], + isSelected: true, + }, + { value: 'sport', nodeIds: ['saddle_sport_black'] }, + ], + }, + { + name: 'Grips', + values: (context) => { + const saddleType = context.getValue('Saddle Type'); + return getGripOptions(saddleType); + }, + }, + { + name: 'Basket', + values: (context) => { + const frameSize = context.getValue('Frame Size'); + return getBasketOptions(frameSize); + }, + }, + { + name: 'Gear System', + values: (context) => { + const frameSize = context.getValue('Frame Size'); + + // Small frames get simpler gearing + if (frameSize === 'small') { + return [ + { value: '3-speed', nodeIds: ['gears_3speed'], isSelected: true }, + { value: '7-speed', nodeIds: ['gears_7speed'] }, + ]; + } + + // Larger frames can handle more complex gearing + return [ + { value: '7-speed', nodeIds: ['gears_7speed'], isSelected: true }, + { value: '21-speed', nodeIds: ['gears_21speed'] }, + ]; + }, + }, + { + name: 'Lights', + values: (context) => { + const frameSize = context.getValue('Frame Size'); + const wheelSize = frameSize === 'small' ? '26' : '28'; + + return [ + { value: 'none', nodeIds: [], isSelected: true }, + { + value: 'standard', + nodeIds: [`lights_front_${frameSize}`, `lights_rear_${frameSize}`], + isSelected: false + }, + { + value: 'premium', + nodeIds: [ + `lights_premium_front_${frameSize}`, + `lights_premium_rear_${frameSize}`, + `dynamo_${wheelSize}` + ], + isSelected: false + } + ]; + }, + }, + ], +}); +``` + +### What happens during re-evaluation + +Look at the cascade effect when changing frame size: + +**User selects "small" frame size**: + +1. Frame and wheels change to small geometry: `frame_small`, `wheels_26` +2. Frame Color re-evaluates → red option disappears (not available for small frames) +3. Tires re-evaluates → 26" tire positioning, sport option disappears +4. Brakes re-evaluates → positioned for 26" wheels, rim brakes default +5. Basket re-evaluates → positioned for small frame geometry +6. Gear System re-evaluates → positioned for small frame, 21-speed disappears +7. Lights re-evaluates → positioned for small frame, uses 26" dynamo + +**Key insight**: The user still sees the same options ("basket", "disc brakes", "standard lights") +but the 3D model automatically uses the correct nodes for the frame size (`basket_small` vs +`basket_large`, `brakes_disc_26` vs `brakes_disc_28`). + +That's 7 attributes affected by one change! With static mapping, you'd need: + +- 3 frame sizes × all color options × all tire options × all brake options × all gear options = + massive configuration complexity + +**The exponential advantage**: + +- Static mapping: 3 frame sizes × 3 colors × 2 tire types × 2 brake types × 2 gear systems × + 2 light options = 144 separate configurations to define +- Dynamic mapping: Just define the positioning rules once, system handles all valid combinations + automatically + +## Testing dynamic mappings + +Extract the logic functions and test them independently: + +```typescript +// bicycle-config-logic.test.ts +import { describe, it, expect } from 'vitest'; +import { + getWheelSize, + getBasketOptions, + getGripOptions, +} from './bicycle-config-logic'; + +describe('Bicycle Configuration Logic', () => { + describe('Wheel size dependencies', () => { + it('should assign 26-inch wheels to small frames', () => { + const wheels = getWheelSize('small'); + expect(wheels).toHaveLength(1); + expect(wheels[0].value).toBe('26-inch'); + }); + + it('should assign 28-inch wheels to medium and large frames', () => { + expect(getWheelSize('medium')[0].value).toBe('28-inch'); + expect(getWheelSize('large')[0].value).toBe('28-inch'); + }); + }); + + describe('Saddle and grip compatibility', () => { + it('should limit sport saddle to sport grips only', () => { + const grips = getGripOptions('sport'); + expect(grips).toHaveLength(1); + expect(grips[0].value).toBe('sport-black'); + }); + + it('should allow grip choice with comfort saddle', () => { + const grips = getGripOptions('comfort'); + expect(grips).toHaveLength(2); + expect(grips.map((g) => g.value)).toContain('brown'); + expect(grips.map((g) => g.value)).toContain('black'); + }); + }); + + describe('Frame-specific accessories', () => { + it('should offer size-appropriate baskets', () => { + const smallBasket = getBasketOptions('small'); + const largeBasket = getBasketOptions('large'); + + expect(smallBasket.find((b) => b.value === 'basket')?.nodeIds).toContain( + 'basket_small' + ); + expect(largeBasket.find((b) => b.value === 'basket')?.nodeIds).toContain( + 'basket_large' + ); + }); + }); +}); +``` + +## Re-evaluation behavior + +When using dynamic mapping, the entire configuration re-evaluates on each selection change: + +1. User selects new value +2. Context updates with new selection +3. All dynamic functions run again +4. Available options may change +5. Invalid selections are handled by the server + +**Important**: If a currently selected value disappears (like "brown grips" when switching to sport +saddle), the server will handle the state update appropriately. + +## Best practices + +1. **Keep functions pure**: Avoid side effects in mapping functions +2. **Handle missing dependencies**: Always provide defaults when context values are undefined +3. **Extract complex logic**: Move business rules to separate, testable functions +4. **Test your logic**: Unit test the extracted functions independently +5. **Document dependencies**: Make it clear which attributes depend on others +6. **Consider user experience**: Ensure logical defaults when options change + +## When to use dynamic vs static + +Use **static mapping** when: + +- All options are always available +- Combinations don't affect each other +- Simple product with few variants + +Use **dynamic mapping** when: + +- Options depend on other selections +- Not all combinations are valid +- Complex business rules apply +- You need conditional visibility + +## Next steps + +- Return to [Static Mapping Examples](./static-mapping-examples.md) for simpler cases +- Review [Mapping Concepts](./mapping-concept.md) for the fundamentals +- Check the main [README](../README.md) for API reference diff --git a/docs/mapping-concept.md b/docs/mapping-concept.md new file mode 100644 index 0000000..8d1dfbf --- /dev/null +++ b/docs/mapping-concept.md @@ -0,0 +1,377 @@ +# Understanding attribute mapping + +## What is attribute mapping + +Attribute mapping is the bridge between your product's business logic and its 3D visualization. It translates +your product options into specific parts of the 3D model that should be visible. + +Think of it as a configuration system that connects: + +- User selections in your UI (choosing colors, sizes, features) +- What gets displayed in the 3D viewer (specific meshes, materials, and parts) + +## Example: City bicycle configurator + +To make these concepts concrete, we'll use a city bicycle configurator throughout this documentation. +A typical bicycle has many customizable options, but let's start with the most obvious ones: + +- Frame color (blue, red, or black) +- Accessories (adding a basket or lights) +- Saddle type (comfort or sport) + +When the user selects "blue frame", the system shows the blue frame mesh and hides the red and black ones. +When they add a basket, the basket accessory becomes visible on the 3D model. + +## Why do we use mapping + +### The separation of concerns + +The 3D server is intentionally generic - it doesn't know anything about your specific products, pricing, +inventory, or business rules. This separation provides several benefits: + +1. **Business flexibility**: Update product options, pricing, or availability without touching the 3D model +2. **Reusability**: Use the same 3D server for all your products +3. **Independence**: Your developers work on business logic while 3D artists work on models +4. **Scalability**: Add new product variants without modifying the 3D infrastructure + +## Components of mapping + +### 1. Attributes + +An attribute represents a customizable aspect of your product. For our complete bicycle +configurator, all configurable options are: + +- **Frame Color** - The main frame color +- **Frame Size** - Small, Medium, Large +- **Saddle Type** - Comfort or sport +- **Grips** - Brown or black +- **Accessories** - Basket, lights, bell +- **Gear System** - Single speed or 7-speed +- **Wheel Size** - 26" or 28" (depends on frame size in dynamic mapping) + +### 2. Node IDs + +Node IDs are the names of specific parts in the bicycle's 3D model: + +- `frame_blue` - The blue-colored frame mesh +- `frame_red` - The red-colored frame mesh +- `frame_black` - The black-colored frame mesh +- `basket_front` - Front basket accessory +- `lights_front` - Front light +- `lights_rear` - Rear light +- `bell_chrome` - Chrome bell on handlebar +- `saddle_comfort_brown` - Brown comfort saddle +- `saddle_sport_black` - Black sport saddle +- `gears_7speed` - 7-speed gear system parts + +These are the actual 3D elements that will be shown or hidden. The 3D artist decides these names when +creating the model, and they cannot be changed. You must use the exact node IDs that exist in the 3D file. + +### 3. Values + +Each attribute has multiple possible values. For our bicycle: + +- **Frame Color**: "blue", "red", "black" +- **Frame Size**: "small", "medium", "large" +- **Accessories**: "none", "basket", "lights", "full" (basket + lights + bell) +- **Saddle Type**: "comfort", "sport" +- **Gear System**: "single", "7-speed" + +**The value property:** This is an identifier that connects your UI to the 3D visualization. It can be any +string value - the client library simply uses it to match your selection calls with the mapping configuration. + +While you can use any values you want (even made-up ones like "option-a", "variant-1"), it's best practice +to use the same values as your existing e-commerce or product system. This makes integration easier: + +```typescript +// Good practice: Match your existing system +{ value: 'blue', nodeIds: ['frame_blue'] } // Same as product database +{ value: 'SKU-BASKET-01', nodeIds: ['basket_front'] } // Using SKU system + +// Also valid: Custom identifiers +{ value: 'primary-color', nodeIds: ['frame_blue'] } // Custom naming +{ value: 'color-1', nodeIds: ['variant_a'] } // Made-up values +``` + +**Connecting to UI elements:** Here's how the value connects your bicycle configurator interface to the 3D model: + +```html + + + + + + + + + + + + + + + + + + +``` + +The key is: whatever value you define in your mapping must match what you pass to `select()`. + +### 4. Selection state + +Each value can be marked as selected or not: + +- `isSelected: true` - This option is active by default +- `isSelected: false` or omitted - This option is not active + +For our bicycle, we might want: + +- Default frame color: Blue (`isSelected: true`) +- Default frame size: Medium (most common) +- Default accessories: None (base model) + +## How the mapping process works + +### Step 1: Define your mapping structure + +Here's a complete mapping for our bicycle configurator: + +```typescript +{ + attributes: [ + { + name: 'Frame Color', + values: [ + { + value: 'blue', + nodeIds: ['frame_blue'], + isSelected: true, // Default color + }, + { + value: 'red', + nodeIds: ['frame_red'], + }, + { + value: 'black', + nodeIds: ['frame_black'], + }, + ], + }, + { + name: 'Saddle Type', + values: [ + { + value: 'comfort', + nodeIds: ['saddle_comfort_brown'], + isSelected: true, // Default saddle + }, + { + value: 'sport', + nodeIds: ['saddle_sport_black'], + }, + ], + }, + { + name: 'Accessories', + values: [ + { + value: 'none', + nodeIds: [], // No accessories visible + isSelected: true, + }, + { + value: 'basket', + nodeIds: ['basket_front'], + }, + { + value: 'lights', + nodeIds: ['lights_front', 'lights_rear'], + }, + { + value: 'full', + nodeIds: [ + 'basket_front', + 'lights_front', + 'lights_rear', + 'bell_chrome', + ], + }, + ], + }, + ]; +} +``` + +### Step 2: Client processes the mapping + +When you call `client.setMapping()`: + +1. **Validation**: The configuration is validated for correctness +2. **Storage**: The mapping is stored locally in the client +3. **Initial mutations**: The client calculates which nodes should be visible based on `isSelected` + - Shows: `frame_blue`, `saddle_comfort_brown` + - Hides: All red and black frames, sport saddle, all accessories +4. **Message to server**: These visibility changes are sent to the 3D server + +### Step 3: Server applies the changes + +The 3D server: + +1. Receives the visibility instructions +2. Shows/hides the appropriate bicycle parts +3. Sends back confirmation of the current state + +### Step 4: User interactions + +When a user customizes their bicycle: + +1. User selects "Red" frame color from dropdown +2. Your code calls `client.getAttribute('Frame Color').select('red')` +3. Client calculates what needs to change: + - Hide: `frame_blue` + - Show: `frame_red` +4. These mutations are sent to the server +5. Server updates the 3D bicycle model and confirms the new state + +```mermaid +sequenceDiagram + participant UI as Bicycle Configurator UI + participant Client as Client Library + participant Server as 3D Server + + UI->>Client: getAttribute('Frame Color').select('red') + Client->>Client: Calculate mutations
(hide blue, show red) + Client->>Server: Send mutations + Note right of Client: Returns immediately + Server->>Server: Apply visibility changes + Server->>Client: STATE_CHANGED event + Client->>Client: Update local state + Client->>UI: Trigger onChange callbacks +``` + +## Key principles + +### 1. Server is the source of truth + +The client sends mutation requests, but the server determines the actual state. Only the server knows which +nodes really exist in the 3D model. The client's state is updated only after receiving the STATE_CHANGED event +from the server. + +### 2. Fire and forget + +When you call `select()`, the method returns immediately without waiting for server confirmation. Your code can +continue, but the actual state update happens asynchronously when the server responds. + +### 3. Declarative configuration + +You describe what should be visible for each option, not how to transition between states. +The system handles the complexity. + +### 4. Grouping for simplicity + +Multiple 3D nodes can be grouped under one user-facing option. Users see "Full Package" while the system manages +multiple accessory parts (`basket_front`, `lights_front`, `lights_rear`, `bell_chrome`). + +**Example benefits:** + +```typescript +// Group related 3D parts into one logical choice +{ + value: 'Red', + nodeIds: [ + 'frame_red', // Red frame mesh + 'fenders_red', // Red fenders material + 'chain_guard_red', // Red chain guard texture + 'grips_red' // Red grip details + ] +} + +// The server automatically handles showing Red parts +// and hiding Blue/Black parts when Red is selected +``` + +## When do you need mapping + +### You need mapping when + +- **Products with color/material variations**: Different textures or materials per option +- **Products with size options**: Different 3D meshes or positioning per size +- **Modular products**: Parts that can be added, removed, or swapped +- **Optional features**: Accessories, upgrades, or add-ons +- **Any scenario where user choices affect what's visible in 3D** + +### You don't need mapping when + +- **Static 3D models**: Always look the same regardless of user interaction +- **Product showcases**: Pure visualization without configuration options +- **Architectural visualizations**: Static buildings or environments +- **Art pieces or sculptures**: Single-configuration display items +- **Single-variant products**: No customization options available + +## Why mapping is essential + +**Separation of concerns:** + +- The 3D server is generic - it doesn't know your specific product options +- Your business rules (stock, pricing, combinations) change independently from the 3D model +- You control exactly which parts of the model are shown for each option +- You can group multiple 3D nodes (meshes, materials) into logical product choices + +**Key advantages:** + +- Full control over what's visible without modifying the 3D model +- Business logic stays in your application, not in the 3D server +- Easy to update when products or availability changes +- Group complex 3D structures into simple user choices + +## Need help with mapping + +- We help you connect your product catalog to your 3D models +- Visual mapping tool coming soon to simplify this process +- Contact for mapping assistance + +## Next steps + +- See [Static Mapping Examples](./static-mapping-examples.md) for simple, independent options +- See [Dynamic Mapping Examples](./dynamic-mapping-examples.md) for options with dependencies +- Check the main [README](../README.md) for API reference diff --git a/docs/static-mapping-examples.md b/docs/static-mapping-examples.md new file mode 100644 index 0000000..067cff1 --- /dev/null +++ b/docs/static-mapping-examples.md @@ -0,0 +1,253 @@ +# Static Mapping Examples + +Static mapping is the simplest approach for product configurators where all options are predetermined and +independent of each other. + +## What makes mapping "static" + +In static mapping: + +- All attribute values are defined upfront +- Options don't change based on other selections +- Every combination is valid (from the mapping perspective) +- The configuration structure remains constant + +## When to use static mapping + +Static mapping is ideal for: + +- **Simple products**: Limited options without dependencies +- **Color/material variants**: Different finishes of the same product +- **Independent features**: Where options don't affect each other +- **Small product catalogs**: When managing all combinations is feasible + +## City bicycle example + +Let's configure a city bicycle with simple, independent options. Each option can be selected without affecting the others: + +```typescript +client.setMapping({ + attributes: [ + { + name: 'Frame Color', + values: [ + { value: 'blue', nodeIds: ['frame_blue'], isSelected: true }, + { value: 'red', nodeIds: ['frame_red'] }, + { value: 'black', nodeIds: ['frame_black'] }, + ], + }, + { + name: 'Saddle Type', + values: [ + { + value: 'comfort', + nodeIds: ['saddle_comfort_brown'], + isSelected: true, + }, + { value: 'sport', nodeIds: ['saddle_sport_black'] }, + ], + }, + { + name: 'Bell', + values: [ + { value: 'none', nodeIds: [], isSelected: true }, + { value: 'chrome', nodeIds: ['bell_chrome'] }, + { value: 'black', nodeIds: ['bell_black'] }, + ], + }, + ], +}); +``` + +With this static mapping: + +- You can choose any frame color +- You can choose any saddle type +- You can add or remove a bell +- All 3 × 2 × 3 = 18 combinations are valid + +## Common patterns + +### Single attribute configuration + +The most basic pattern - one customizable aspect: + +```typescript +client.setMapping({ + attributes: [ + { + name: 'Frame Color', + values: [ + { value: 'blue', nodeIds: ['frame_blue'], isSelected: true }, + { value: 'red', nodeIds: ['frame_red'] }, + { value: 'black', nodeIds: ['frame_black'] }, + ], + }, + ], +}); +``` + +### Multi-part grouping + +When one selection controls multiple 3D elements. For our bicycle, selecting a color might change multiple parts: + +```typescript +{ + value: 'red', + nodeIds: [ + 'frame_red', // Main frame + 'fenders_red', // Matching fenders + 'chain_guard_red', // Chain guard + 'grips_red' // Handle grips + ] +} +``` + +**Important**: All nodeIds must exist in the 3D model. Adding new elements requires updating the mapping +configuration. + +### Optional accessories + +For add-ons or optional components like lights or basket: + +```typescript +{ + name: 'Accessories', + values: [ + { value: 'none', nodeIds: [], isSelected: true }, + { value: 'basket', nodeIds: ['basket_front'] }, + { value: 'lights', nodeIds: ['lights_front', 'lights_rear'] }, + { value: 'both', nodeIds: ['basket_front', 'lights_front', 'lights_rear'] } + ] +} +``` + +## Creating UI from mapping + +A powerful aspect of attribute mapping is that it contains all the information needed to generate a user +interface. The mapping can drive your UI directly: + +```typescript +// Get frame color options from the mapping +const colorAttribute = client.getAttribute('Frame Color'); +const colors = colorAttribute.getValues(); + +// Generate color swatches +colors.forEach((colorValue) => { + const swatch = document.createElement('button'); + swatch.className = 'color-swatch'; + swatch.style.backgroundColor = colorValue.value; // 'blue', 'red', 'black' + swatch.title = `${colorValue.value} frame`; + swatch.onclick = () => colorAttribute.select(colorValue.value); + + // Update swatch state when 3D model changes + colorValue.onChange = () => { + swatch.classList.toggle('selected', colorValue.isSelected); + }; +}); +``` + +See the [color configurator example](../examples/color-configurator/index.html) for a complete implementation +that generates its entire UI from the mapping configuration. + +## Complete bicycle configurator + +Here's a full static mapping for our city bicycle with all independent options: + +```typescript +client.setMapping({ + attributes: [ + { + name: 'Frame Color', + values: [ + { value: 'blue', nodeIds: ['frame_blue'], isSelected: true }, + { value: 'red', nodeIds: ['frame_red'] }, + { value: 'black', nodeIds: ['frame_black'] }, + ], + }, + { + name: 'Saddle Type', + values: [ + { + value: 'comfort', + nodeIds: ['saddle_comfort_brown'], + isSelected: true, + }, + { value: 'sport', nodeIds: ['saddle_sport_black'] }, + ], + }, + { + name: 'Grips', + values: [ + { value: 'brown', nodeIds: ['grips_brown'], isSelected: true }, + { value: 'black', nodeIds: ['grips_black'] }, + ], + }, + { + name: 'Basket', + values: [ + { value: 'none', nodeIds: [], isSelected: true }, + { value: 'wicker', nodeIds: ['basket_wicker'] }, + { value: 'metal', nodeIds: ['basket_metal'] }, + ], + }, + { + name: 'Bell', + values: [ + { value: 'none', nodeIds: [], isSelected: false }, + { value: 'chrome', nodeIds: ['bell_chrome'], isSelected: true }, + ], + }, + ], +}); +``` + +## Limitations of static mapping + +Static mapping works great for our simple bicycle configurator, but consider dynamic mapping when: + +- **Frame size affects wheel size**: Small frame needs 26" wheels, large frame needs 28" wheels +- **Accessories depend on frame type**: Racing saddle only available with sport frame +- **Complex compatibility rules**: Certain colors only available in certain sizes +- **Price calculations**: Different combinations have different pricing logic + +## Best practices + +### 1. Keep it simple + +Static mapping works best for straightforward products: + +- Few attributes (1-5) +- Limited values per attribute (2-5) +- No interdependencies + +### 2. Match the 3D model structure + +Your nodeIds must exactly match what's in the 3D file: + +- Coordinate with 3D artists on naming conventions +- Document the expected node structure +- Test all combinations during development + +### 3. Use meaningful defaults + +Set `isSelected: true` on sensible defaults: + +- Most popular color (blue frame) +- Standard configuration (comfort saddle) +- Essential accessories (bell included) + +## When to upgrade to dynamic mapping + +You've outgrown static mapping when: + +- You need conditional logic ("26-inch wheels only available with small frame") +- Options affect available choices ("sport package includes racing saddle") +- You're duplicating similar configurations with small variations +- Business rules become complex to express statically + +## Next steps + +- See [Dynamic Mapping Examples](./dynamic-mapping-examples.md) for the same bicycle with dependencies +- Return to [Mapping Concepts](./mapping-concept.md) for the overview +- Explore the [color configurator example](../examples/color-configurator/) for a working implementation From 0589291e682ea8bc3c7b5c5f0b666053fee9786a Mon Sep 17 00:00:00 2001 From: HMAZonderland Date: Mon, 4 Aug 2025 20:21:38 +0200 Subject: [PATCH 10/15] refactor: remove dead code from attribute system - Remove unused select() method from Attribute class - Remove SNAPSHOT_DEVELOPED event name (never used) - Remove MessageType type alias (redundant) - Remove attribute.test.ts file (method no longer exists) - Clean up codebase by removing unused functionality --- src/attributes/attribute.ts | 1 - src/messaging/message-types.ts | 1 - test/unit/attributes/attribute.test.ts | 1 - 3 files changed, 3 deletions(-) diff --git a/src/attributes/attribute.ts b/src/attributes/attribute.ts index 4abdfcf..d069eca 100644 --- a/src/attributes/attribute.ts +++ b/src/attributes/attribute.ts @@ -18,7 +18,6 @@ export class Attribute { this.values.set(attributeValue.value, attributeValue); } - public getDefaultMutations(): Mutation[] { return Array.from(this.values.values()) .flatMap(value => value.getMutations()); diff --git a/src/messaging/message-types.ts b/src/messaging/message-types.ts index 554818a..4c6f496 100644 --- a/src/messaging/message-types.ts +++ b/src/messaging/message-types.ts @@ -13,7 +13,6 @@ export const MESSAGE_TYPES = { SNAPSHOT: 'snapshot', } as const; - /** * DTO for model node data received from server */ diff --git a/test/unit/attributes/attribute.test.ts b/test/unit/attributes/attribute.test.ts index 9961a25..7145de8 100644 --- a/test/unit/attributes/attribute.test.ts +++ b/test/unit/attributes/attribute.test.ts @@ -20,7 +20,6 @@ describe('Attribute - Creation', () => { }); }); - describe('Attribute - Default Handling', () => { it('should get default mutations', () => { const attribute = new Attribute('Color'); From 95d91d7e8d48b8a81819399e4668f04f97305e67 Mon Sep 17 00:00:00 2001 From: HMAZonderland Date: Mon, 4 Aug 2025 20:21:55 +0200 Subject: [PATCH 11/15] docs: simplify README files and reduce duplication - Simplify README.md mapping section to show only high-level concepts - Move detailed mapping explanations to dedicated documentation - Simplify README.npm.md to minimal content with GitHub reference - Fix 'Zero dependencies' claim to 'Minimal dependencies' - Prevent future sync issues between NPM and GitHub READMEs --- README.md | 103 +++--------- README.npm.md | 438 ++------------------------------------------------ 2 files changed, 34 insertions(+), 507 deletions(-) diff --git a/README.md b/README.md index f34734c..969fe4c 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ applications. **Key features:** - Simple attribute-based API for product configurators +- Dynamic mapping support for dependent product options - Fire-and-forget messaging with automatic state synchronization - Zero external dependencies (all bundled) - Works with any frontend framework or vanilla JavaScript @@ -94,43 +95,11 @@ graph LR architecture - **Event-driven**: Loosely coupled components communicate via events -## Product options - -### When do you need mapping - -Before diving in, it's important to understand when you need attribute mapping: - -**No mapping needed:** - -- Static 3D models that always look the same -- Product showcases without options -- Architectural visualizations -- Art pieces or sculptures -- Single-configuration products - -**Mapping required:** - -- Products where users can change colors (different textures/materials per option) -- Products with size options (different 3D meshes per size) -- Modular products (parts that can be added/removed) -- Any product where different options show different 3D elements +## Product configuration ### Attribute mapping -The mapping system bridges your business logic with the 3D visualization. It -serves multiple purposes: - -**Why mapping is essential:** - -- The 3D server is generic - it doesn't know your specific product options -- Your business rules (stock, pricing, combinations) change independently from - the 3D model -- You control exactly which parts of the model are shown for each option -- You can group multiple 3D nodes (meshes, materials) into logical product - choices - -**How it works:** The mapping connects your product options to specific parts of -the 3D model: +For configurable products, the mapping system connects your product options to parts of the 3D model: ```typescript client.setMapping({ @@ -138,69 +107,33 @@ client.setMapping({ { name: 'Color', values: [ - { - value: 'Red', - nodeIds: ['mat_red_sole', 'mat_red_laces', 'mat_red_logo'], - isSelected: true, - }, - { - value: 'Blue', - nodeIds: ['mat_blue_sole', 'mat_blue_laces', 'mat_blue_logo'], - }, - ], - }, - { - name: 'Material', - values: [ - { value: 'Leather', nodeIds: ['material_leather_upper'] }, - { - value: 'Canvas', - nodeIds: ['material_canvas_upper'], - isSelected: true, - }, + { value: 'Red', nodeIds: ['sole_red', 'laces_red'], isSelected: true }, + { value: 'Blue', nodeIds: ['sole_blue', 'laces_blue'] }, ], }, ], }); ``` -**Structure explained:** +**Key concepts:** -- `attribute` - A product feature like Color, Size, or Material -- `value` - A specific option like Red, Blue, Small, or Large +- `attribute` - A product feature (Color, Size, Material) +- `value` - A specific option (Red, Blue, Small, Large) - `nodeIds` - The 3D model parts that represent this option -- `isSelected` - Whether this option is selected by default - -**Example benefits:** - -```typescript -// Group related 3D parts into one logical choice -{ - value: 'Red', - nodeIds: [ - 'mesh_sole_red', // Red sole mesh - 'material_laces_red', // Red laces material - 'texture_logo_red', // Red logo texture - 'mesh_stitching_red' // Red stitching details - ] -} - -// The server automatically handles showing Red parts -// and hiding Blue/Green parts when Red is selected -``` +- `isSelected` - Default selection state -**Key advantages:** +**When you need mapping:** -- Full control over what's visible without modifying the 3D model -- Business logic stays in your application, not in the 3D server -- Easy to update when products or availability changes -- Group complex 3D structures into simple user choices +- Products with color/material variations +- Products with size options that affect the 3D model +- Modular products with optional parts +- Any product where options change what's visible in 3D -**Need help with mapping?** +**Learn more:** -- We help you connect your product catalog to your 3D models -- Visual mapping tool coming soon to simplify this process -- Contact for mapping assistance +- [Understanding Attribute Mapping](./docs/mapping-concept.md) - Core concepts +- [Static Mapping Examples](./docs/static-mapping-examples.md) - Simple configurations +- [Dynamic Mapping Examples](./docs/dynamic-mapping-examples.md) - Advanced dependencies ### Complete flow diff --git a/README.npm.md b/README.npm.md index 317a301..41f29d3 100644 --- a/README.npm.md +++ b/README.npm.md @@ -1,14 +1,6 @@ # Virtualdisplay client -TypeScript library for embedding interactive 3D product models in web -applications. - -**Key features:** - -- Simple attribute-based API for product configurators -- Fire-and-forget messaging with automatic state synchronization -- Zero external dependencies (all bundled) -- Works with any frontend framework or vanilla JavaScript +TypeScript library for embedding interactive 3D product models in web applications. ## Installation @@ -20,14 +12,6 @@ pnpm add @virtualdisplay.io/client yarn add @virtualdisplay.io/client ``` -## What's new in v3.1 - -- Simplified message handling with unified event system -- JSON Schema validation using AJV -- Cleaner, more maintainable codebase -- Improved TypeScript types -- Better error messages with specific error codes - ## Quick start ### Simple product (no options) @@ -70,414 +54,24 @@ client.setMapping({ // Control via product options client.getAttribute('Color')?.select('Red'); - -// Or store the attribute for multiple operations -const sizeAttribute = client.getAttribute('Size'); -if (sizeAttribute) { - sizeAttribute.select('Large'); -} ``` -## How it works - -The 3D server hosts the viewer in an iframe, keeping WebGL complexity isolated -from your application. The client library handles all communication via -postMessage and manages state locally using an attribute mapping system. - -**Key principles:** - -- **Iframe architecture**: 3D server loads the viewer independently -- **Fire-and-forget**: Send messages without waiting for confirmation -- **Attribute mapping**: Connect your product options to 3D model parts -- **State synchronization**: Client and 3D server stay in sync automatically -- **Domain-driven design**: Clean separation of concerns with simplified - architecture -- **Event-driven**: Loosely coupled components communicate via events - -## Product options - -### When do you need mapping - -Before diving in, it's important to understand when you need attribute mapping: - -**No mapping needed:** - -- Static 3D models that always look the same -- Product showcases without options -- Architectural visualizations -- Art pieces or sculptures -- Single-configuration products - -**Mapping required:** - -- Products where users can change colors (different textures/materials per option) -- Products with size options (different 3D meshes per size) -- Modular products (parts that can be added/removed) -- Any product where different options show different 3D elements - -### Attribute mapping - -The mapping system bridges your business logic with the 3D visualization. It -serves multiple purposes: - -**Why mapping is essential:** - -- The 3D server is generic - it doesn't know your specific product options -- Your business rules (stock, pricing, combinations) change independently from - the 3D model -- You control exactly which parts of the model are shown for each option -- You can group multiple 3D nodes (meshes, materials) into logical product - choices - -**How it works:** The mapping connects your product options to specific parts of -the 3D model: - -```typescript -client.setMapping({ - attributes: [ - { - name: 'Color', - values: [ - { - value: 'Red', - nodeIds: ['mat_red_sole', 'mat_red_laces', 'mat_red_logo'], - isSelected: true, - }, - { - value: 'Blue', - nodeIds: ['mat_blue_sole', 'mat_blue_laces', 'mat_blue_logo'], - }, - ], - }, - { - name: 'Material', - values: [ - { value: 'Leather', nodeIds: ['material_leather_upper'] }, - { - value: 'Canvas', - nodeIds: ['material_canvas_upper'], - isSelected: true, - }, - ], - }, - ], -}); -``` - -**Structure explained:** - -- `attribute` - A product feature like Color, Size, or Material -- `value` - A specific option like Red, Blue, Small, or Large -- `nodeIds` - The 3D model parts that represent this option -- `isSelected` - Whether this option is selected by default - -**Example benefits:** - -```typescript -// Group related 3D parts into one logical choice -{ - value: 'Red', - nodeIds: [ - 'mesh_sole_red', // Red sole mesh - 'material_laces_red', // Red laces material - 'texture_logo_red', // Red logo texture - 'mesh_stitching_red' // Red stitching details - ] -} - -// The server automatically handles showing Red parts -// and hiding Blue/Green parts when Red is selected -``` - -**Key advantages:** - -- Full control over what's visible without modifying the 3D model -- Business logic stays in your application, not in the 3D server -- Easy to update when products or availability changes -- Group complex 3D structures into simple user choices - -**Need help with mapping?** - -- We help you connect your product catalog to your 3D models -- Visual mapping tool coming soon to simplify this process -- Contact for mapping assistance - -### Complete flow - -Here's how the client, mapping system, and 3D server work together: - -**Phase by phase breakdown:** - -#### Phase 0: Initialization (always happens) - -- Client creates iframe and loads 3D server -- Server initializes the 3D model with all nodes visible -- Server sends complete node state to client -- Client now knows about all available nodes in the model - -**Note:** For simple models without options, this is all you need! - -#### Phase 1: Configuration (only for configurable products) - -- You call `setMapping()` with your product structure -- Client stores the attribute mapping locally -- Client sends mutations for all `isSelected: true` values -- Server applies these changes and hides non-selected options -- Server sends back confirmed state -- Local state updates based on server response -- onChange callbacks fire for initial UI synchronization - -#### Phase 2: User interaction (only for configurable products) - -- User selects a different option in your UI -- You call `getAttribute('Color').select('Blue')` -- Client sends mutation request to server -- Your code continues immediately (doesn't wait for server response) -- Server updates the 3D model -- Server sends back the confirmed state -- Local state updates based on server response -- onChange callbacks fire with the actual state -- UI stays perfectly synchronized with 3D model - -**Important:** The pattern is identical for both initial mapping and user -interactions. In both cases: - -1. Client sends mutations to server -2. Server applies changes to 3D model -3. Server sends back confirmed state -4. Client updates local state based on server response -5. onChange callbacks fire with the actual state - -This ensures the server remains the single source of truth for all state -changes. - -## Advanced usage - -### Live state synchronization - -Keep your UI in sync with the 3D server state using onChange callbacks: - -```typescript -const colorAttribute = client.getAttribute('Color'); - -colorAttribute?.getValues().forEach((value) => { - value.onChange = () => { - // Update UI when state changes - updateButton(value.value, value.isSelected); - }; -}); -``` - -### Mapping validation - -Validate your mapping configuration during development: - -```typescript -import { mappingSchema } from '@virtualdisplay.io/client'; -import Ajv from 'ajv'; - -const validate = new Ajv().compile(mappingSchema); -if (!validate(myMapping)) { - console.error('Invalid mapping:', validate.errors); -} -``` - -## Real-world integration examples - -### CMS integration - -The Virtualdisplay client is CMS-agnostic. Store the mapping configuration in -your CMS alongside your product data: - -```typescript -// Fetch product with 3D mapping from your CMS -const product = await fetch('/api/products/sneaker-pro').then((r) => r.json()); - -// Use the stored mapping directly -client.setMapping(product.server3dMapping); -``` - -**Best practices:** - -- Store the complete mapping configuration with each product -- Update mappings when product options or availability changes -- Let your CMS handle filtering of unavailable combinations -- Version your mappings when 3D models are updated - -## Example - -See the [color configurator example](./examples/color-configurator/) for a -complete working implementation that demonstrates: - -- Setting up the client -- Defining attribute mappings -- Binding UI controls to attributes -- Real-time state synchronization - -## API reference - -### ClientOptions - -```typescript -interface ClientOptions { - parent: string | HTMLElement; // Container element or selector - license: string; // Your license key - model: string; // Model ID to load - debug?: boolean; // Enable debug logging (default: false) - language?: string; // Language for UI inside the iframe (default: 'nl', supported: 'nl', 'en', 'de') -} -``` - -### Methods - -#### `setMapping(configuration: MappingConfiguration): void` - -Configure attribute-to-node mapping for product variants. This is the primary -method for setting up your product configurator. Call this once with your -complete configuration - the client efficiently handles all updates. - -```typescript -client.setMapping({ - attributes: [ - { - name: 'Color', - values: [ - { value: 'Red', nodeIds: ['node1', 'node2'], isSelected: true }, - { value: 'Blue', nodeIds: ['node3', 'node4'] }, - ], - }, - ], -}); -``` - -#### `getAttribute(name: string): AttributeSelector | undefined` - -Get an attribute selector for changing values. Returns undefined if the -attribute doesn't exist. - -```typescript -const colorAttr = client.getAttribute('Color'); -if (colorAttr) { - colorAttr.select('Blue'); -} -``` - -#### `destroy(): void` - -Clean up and remove the client connection. Always call this when unmounting your -component. - -```typescript -// React example -useEffect(() => { - const client = new VirtualdisplayClient({ ... }); - return () => client.destroy(); -}, []); -``` - -### Types - -```typescript -interface MappingConfiguration { - attributes: AttributeConfig[]; -} - -interface AttributeConfig { - name: string; // Attribute name (e.g., 'Color') - values: AttributeValueConfig[]; // Possible values -} - -interface AttributeValueConfig { - value: string; // Value name (e.g., 'Red') - nodeIds: string[]; // 3D node IDs for this value - isSelected?: boolean; // Default selection state -} - -class AttributeSelector { - // Properties - name: string; // Attribute name (getter) - currentValue: string | undefined; // Current selected value (getter) - availableValues: string[]; // All possible values (getter) - - // Methods - select(value: string): AttributeSelector; // Select a value (chainable) - onChange(callback: () => void): AttributeSelector; // Register change callback (chainable) - getValues(): AttributeValue[]; // Get all AttributeValue objects - getValue(value: string): AttributeValue | undefined; // Get specific AttributeValue -} - -class AttributeValue { - // Properties - value: string; // The value name (e.g., 'Red') - getter - nodeList: string[]; // Associated 3D node IDs - getter - isSelected: boolean; // Current selection state - getter - onChange?: () => void; // Callback when selection changes - property -} -``` - -#### NodeSelector - -The client exports `NodeSelector` for direct node manipulation. This is intended -for specialized tools like mapping editors and inspectors, not for typical -product configurators. For product configurators, use the attribute-based API. - -### Error handling - -The client throws `VirtualdisplayError` for known error conditions: - -```typescript -import { - VirtualdisplayClient, - VirtualdisplayError, - ERROR_CODES, -} from '@virtualdisplay.io/client'; - -try { - client.getAttribute('NonExistent').select('Value'); -} catch (error) { - if (error instanceof VirtualdisplayError) { - switch (error.code) { - case ERROR_CODES.ATTRIBUTE_NOT_FOUND: - console.error('Attribute does not exist'); - break; - case ERROR_CODES.NO_MAPPING: - console.error('Call setMapping() first'); - break; - // Handle other error codes - } - } -} -``` - -**Error codes:** - -- `NO_MAPPING` - No mapping configuration set -- `ATTRIBUTE_NOT_FOUND` - Requested attribute doesn't exist -- `VALUE_NOT_FOUND` - Requested value doesn't exist for attribute -- `INVALID_MAPPING` - Mapping configuration is invalid -- `PARENT_NOT_FOUND` - Parent element for iframe not found - -## Contributing - -See [CONTRIBUTING.md](./CONTRIBUTING.md) for development setup and guidelines. - -## Troubleshooting - -### Common issues - -**Iframe not loading:** - -- Check that the parent element exists in the DOM -- Verify your license key is valid -- Ensure the model ID matches your license +## Key features -**Attributes not working:** +- **Simple API**: Attribute-based product configuration +- **Fire-and-forget**: Send commands without waiting for responses +- **State sync**: Automatic synchronization between client and 3D viewer +- **Framework-agnostic**: Works with React, Vue, Angular, or vanilla JS +- **TypeScript first**: Full type safety and IntelliSense support +- **Minimal dependencies**: Only essential validation and logging included -- Make sure you call `setMapping()` before using `getAttribute()` -- Verify attribute names match exactly (case-sensitive) -- Check that node IDs in your mapping exist in the 3D model +## Documentation -## Changelog +For complete documentation including: +- API reference +- Advanced examples +- Integration guides +- Architecture diagrams +- Troubleshooting -See the [GitHub releases](https://github.com/virtualdisplay-io/client/releases) -for release notes and version history. +Visit our GitHub repository: https://github.com/virtualdisplay-io/client From 01045b141ea4324139fe6df08d72675901c4954e Mon Sep 17 00:00:00 2001 From: HMAZonderland Date: Mon, 4 Aug 2025 20:21:55 +0200 Subject: [PATCH 12/15] docs: simplify README files and reduce duplication - Simplify README.md mapping section to show only high-level concepts - Move detailed mapping explanations to dedicated documentation - Simplify README.npm.md to minimal content with GitHub reference - Fix 'Zero dependencies' claim to 'Minimal dependencies' - Prevent future sync issues between NPM and GitHub READMEs --- README.npm.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.npm.md b/README.npm.md index 41f29d3..cc143a8 100644 --- a/README.npm.md +++ b/README.npm.md @@ -68,10 +68,11 @@ client.getAttribute('Color')?.select('Red'); ## Documentation For complete documentation including: + - API reference - Advanced examples - Integration guides - Architecture diagrams - Troubleshooting -Visit our GitHub repository: https://github.com/virtualdisplay-io/client +Visit our [GitHub repository](https://github.com/virtualdisplay-io/client) From cf1fd091c60d4d36475aa4b1ef62bf02e4c94b6b Mon Sep 17 00:00:00 2001 From: HMAZonderland Date: Mon, 4 Aug 2025 20:29:21 +0200 Subject: [PATCH 13/15] style: fix formatting and reorganize documentation sections - Apply prettier formatting to dynamic-mapping-examples.md - Move 'Why do we use mapping' section above example in mapping-concept.md - Improve document flow and readability --- docs/dynamic-mapping-examples.md | 76 ++++++++++++++++++-------------- docs/mapping-concept.md | 24 +++++----- 2 files changed, 56 insertions(+), 44 deletions(-) diff --git a/docs/dynamic-mapping-examples.md b/docs/dynamic-mapping-examples.md index b057dc1..64ef0f1 100644 --- a/docs/dynamic-mapping-examples.md +++ b/docs/dynamic-mapping-examples.md @@ -68,17 +68,17 @@ Without dynamic mapping, you'd need 3 separate product configurations! // Tire types - same options but positioned for different wheel sizes function getTireOptions(frameSize: string) { const wheelSize = frameSize === 'small' ? '26' : '28'; - + const options = [ { value: 'city', nodeIds: [`tires_${wheelSize}_city`], isSelected: true }, - { value: 'comfort', nodeIds: [`tires_${wheelSize}_comfort`] } + { value: 'comfort', nodeIds: [`tires_${wheelSize}_comfort`] }, ]; - + // Sport tires only available for larger wheels if (frameSize !== 'small') { options.push({ value: 'sport', nodeIds: [`tires_${wheelSize}_sport`] }); } - + return options; } @@ -86,18 +86,18 @@ function getTireOptions(frameSize: string) { function getBrakeOptions(frameSize: string) { const wheelSize = frameSize === 'small' ? '26' : '28'; const defaultBrake = frameSize === 'small' ? 'rim' : 'disc'; - + return [ - { - value: 'rim', - nodeIds: [`brakes_rim_${wheelSize}`], - isSelected: defaultBrake === 'rim' + { + value: 'rim', + nodeIds: [`brakes_rim_${wheelSize}`], + isSelected: defaultBrake === 'rim', + }, + { + value: 'disc', + nodeIds: [`brakes_disc_${wheelSize}`], + isSelected: defaultBrake === 'disc', }, - { - value: 'disc', - nodeIds: [`brakes_disc_${wheelSize}`], - isSelected: defaultBrake === 'disc' - } ]; } @@ -105,11 +105,11 @@ function getBasketOptions(frameSize: string) { // User always sees same options, but nodeIds change based on frame size return [ { value: 'none', nodeIds: [], isSelected: true }, - { - value: 'basket', + { + value: 'basket', nodeIds: [`basket_${frameSize}`], // basket_small, basket_medium, basket_large - isSelected: false - } + isSelected: false, + }, ]; } @@ -139,9 +139,21 @@ client.setMapping({ // Frame size is our primary driver - it affects almost everything else name: 'Frame Size', values: [ - { value: 'small', nodeIds: ['frame_small', 'wheels_26'], isSelected: false }, - { value: 'medium', nodeIds: ['frame_medium', 'wheels_28'], isSelected: true }, - { value: 'large', nodeIds: ['frame_large', 'wheels_28'], isSelected: false }, + { + value: 'small', + nodeIds: ['frame_small', 'wheels_26'], + isSelected: false, + }, + { + value: 'medium', + nodeIds: ['frame_medium', 'wheels_28'], + isSelected: true, + }, + { + value: 'large', + nodeIds: ['frame_large', 'wheels_28'], + isSelected: false, + }, ], }, { @@ -153,12 +165,12 @@ client.setMapping({ { value: 'blue', nodeIds: ['paint_blue'], isSelected: true }, { value: 'black', nodeIds: ['paint_black'] }, ]; - + // Red is a premium color only available on medium/large frames if (frameSize !== 'small') { colors.push({ value: 'red', nodeIds: ['paint_red'] }); } - + return colors; }, }, @@ -205,7 +217,7 @@ client.setMapping({ name: 'Gear System', values: (context) => { const frameSize = context.getValue('Frame Size'); - + // Small frames get simpler gearing if (frameSize === 'small') { return [ @@ -213,7 +225,7 @@ client.setMapping({ { value: '7-speed', nodeIds: ['gears_7speed'] }, ]; } - + // Larger frames can handle more complex gearing return [ { value: '7-speed', nodeIds: ['gears_7speed'], isSelected: true }, @@ -226,23 +238,23 @@ client.setMapping({ values: (context) => { const frameSize = context.getValue('Frame Size'); const wheelSize = frameSize === 'small' ? '26' : '28'; - + return [ { value: 'none', nodeIds: [], isSelected: true }, - { - value: 'standard', + { + value: 'standard', nodeIds: [`lights_front_${frameSize}`, `lights_rear_${frameSize}`], - isSelected: false + isSelected: false, }, { value: 'premium', nodeIds: [ `lights_premium_front_${frameSize}`, `lights_premium_rear_${frameSize}`, - `dynamo_${wheelSize}` + `dynamo_${wheelSize}`, ], - isSelected: false - } + isSelected: false, + }, ]; }, }, diff --git a/docs/mapping-concept.md b/docs/mapping-concept.md index 8d1dfbf..3cd85f7 100644 --- a/docs/mapping-concept.md +++ b/docs/mapping-concept.md @@ -10,18 +10,6 @@ Think of it as a configuration system that connects: - User selections in your UI (choosing colors, sizes, features) - What gets displayed in the 3D viewer (specific meshes, materials, and parts) -## Example: City bicycle configurator - -To make these concepts concrete, we'll use a city bicycle configurator throughout this documentation. -A typical bicycle has many customizable options, but let's start with the most obvious ones: - -- Frame color (blue, red, or black) -- Accessories (adding a basket or lights) -- Saddle type (comfort or sport) - -When the user selects "blue frame", the system shows the blue frame mesh and hides the red and black ones. -When they add a basket, the basket accessory becomes visible on the 3D model. - ## Why do we use mapping ### The separation of concerns @@ -34,6 +22,18 @@ inventory, or business rules. This separation provides several benefits: 3. **Independence**: Your developers work on business logic while 3D artists work on models 4. **Scalability**: Add new product variants without modifying the 3D infrastructure +## Example: City bicycle configurator + +To make these concepts concrete, we'll use a city bicycle configurator throughout this documentation. +A typical bicycle has many customizable options, but let's start with the most obvious ones: + +- Frame color (blue, red, or black) +- Accessories (adding a basket or lights) +- Saddle type (comfort or sport) + +When the user selects "blue frame", the system shows the blue frame mesh and hides the red and black ones. +When they add a basket, the basket accessory becomes visible on the 3D model. + ## Components of mapping ### 1. Attributes From 4a0ec525fd6416a23cf6788ec3d1a4624ac0c2af Mon Sep 17 00:00:00 2001 From: HMAZonderland Date: Mon, 4 Aug 2025 21:31:45 +0200 Subject: [PATCH 14/15] docs: restructure and improve mapping documentation - Rename files to remove 'examples' suffix for cleaner URLs - Add conceptual introductions to static and dynamic mapping guides - Improve navigation with contextual links between documents - Remove duplicate content between guides - Clarify when to use static vs dynamic mapping - Maintain consistent bicycle example throughout - Use sentence case for all headings - Simplify README files to prevent sync issues --- README.md | 6 +- ...mapping-examples.md => dynamic-mapping.md} | 35 ++-- docs/mapping-concept.md | 150 +++++++++++------- ...-mapping-examples.md => static-mapping.md} | 28 ++-- 4 files changed, 122 insertions(+), 97 deletions(-) rename docs/{dynamic-mapping-examples.md => dynamic-mapping.md} (91%) rename docs/{static-mapping-examples.md => static-mapping.md} (87%) diff --git a/README.md b/README.md index 969fe4c..7cb524e 100644 --- a/README.md +++ b/README.md @@ -131,9 +131,9 @@ client.setMapping({ **Learn more:** -- [Understanding Attribute Mapping](./docs/mapping-concept.md) - Core concepts -- [Static Mapping Examples](./docs/static-mapping-examples.md) - Simple configurations -- [Dynamic Mapping Examples](./docs/dynamic-mapping-examples.md) - Advanced dependencies +- [Understanding attribute mapping](./docs/mapping-concept.md) - Core concepts +- [Static mapping guide](./docs/static-mapping.md) - Simple configurations +- [Dynamic mapping guide](./docs/dynamic-mapping.md) - Advanced dependencies ### Complete flow diff --git a/docs/dynamic-mapping-examples.md b/docs/dynamic-mapping.md similarity index 91% rename from docs/dynamic-mapping-examples.md rename to docs/dynamic-mapping.md index 64ef0f1..291e480 100644 --- a/docs/dynamic-mapping-examples.md +++ b/docs/dynamic-mapping.md @@ -1,13 +1,13 @@ -# Dynamic Mapping Examples +# Dynamic mapping guide Dynamic mapping allows attribute values to change based on other selections, enabling complex product configurations with dependencies. -**Prerequisites**: This guide builds on the [Static Mapping Examples](./static-mapping-examples.md). -We'll use the same city bicycle but add realistic dependencies between options. If you haven't read -the static mapping guide yet, please start there. +> **New to attribute mapping?** If you haven't read about the core concepts yet, start with [Understanding attribute mapping](./mapping-concept.md) for the fundamentals. +> +> **Haven't read about static mapping yet?** Start with [Static mapping guide](./static-mapping.md) first. This guide builds on those concepts using the same city bicycle example. -## What makes mapping "dynamic" +## What makes mapping "dynamic"? In dynamic mapping, any property can be computed at runtime using a function: @@ -59,7 +59,7 @@ When you select a frame size, it cascades through the entire configuration: The basket is always "basket" from the user's perspective, but the 3D model shows `basket_small`, `basket_medium`, or `basket_large` nodes positioned appropriately for each frame. -Without dynamic mapping, you'd need 3 separate product configurations! +Without dynamic mapping, you'd need either 3 separate product configurations or build complex workarounds in your application to manage all these dependencies - defeating the purpose of a clean mapping system. ### Implementation @@ -262,7 +262,7 @@ client.setMapping({ }); ``` -### What happens during re-evaluation +### What happens during re-evaluation? Look at the cascade effect when changing frame size: @@ -372,23 +372,6 @@ saddle), the server will handle the state update appropriately. 5. **Document dependencies**: Make it clear which attributes depend on others 6. **Consider user experience**: Ensure logical defaults when options change -## When to use dynamic vs static +## When to use dynamic vs static? -Use **static mapping** when: - -- All options are always available -- Combinations don't affect each other -- Simple product with few variants - -Use **dynamic mapping** when: - -- Options depend on other selections -- Not all combinations are valid -- Complex business rules apply -- You need conditional visibility - -## Next steps - -- Return to [Static Mapping Examples](./static-mapping-examples.md) for simpler cases -- Review [Mapping Concepts](./mapping-concept.md) for the fundamentals -- Check the main [README](../README.md) for API reference +→ See [Types of mapping](./mapping-concept.md#types-of-mapping) in the mapping concepts guide for detailed comparison and use cases. diff --git a/docs/mapping-concept.md b/docs/mapping-concept.md index 3cd85f7..b29c6d0 100644 --- a/docs/mapping-concept.md +++ b/docs/mapping-concept.md @@ -1,16 +1,19 @@ # Understanding attribute mapping -## What is attribute mapping +## What is attribute mapping? Attribute mapping is the bridge between your product's business logic and its 3D visualization. It translates your product options into specific parts of the 3D model that should be visible. -Think of it as a configuration system that connects: +This translation layer solves a fundamental disconnect: -- User selections in your UI (choosing colors, sizes, features) -- What gets displayed in the 3D viewer (specific meshes, materials, and parts) +- Your business domain speaks in product terminology - colors, sizes, features, SKUs +- The 3D world operates with technical constructs - meshes, nodes, materials, transforms +- Your customers think in their own terms - "I want the blue one with the basket" -## Why do we use mapping +Without this bridge, you'd be forced to embed business logic into your 3D models or technical details into your product catalog. Mapping keeps both worlds pure and focused on what they do best. + +## Why do we use mapping? ### The separation of concerns @@ -22,6 +25,31 @@ inventory, or business rules. This separation provides several benefits: 3. **Independence**: Your developers work on business logic while 3D artists work on models 4. **Scalability**: Add new product variants without modifying the 3D infrastructure +### Key advantages + +- **Full control**: You decide exactly which parts of the model are shown for each option +- **Business logic stays pure**: Your application handles products, the 3D server just visualizes +- **Easy updates**: When products or availability changes, just update the mapping +- **Logical grouping**: Complex 3D structures become simple user choices (one button can control multiple nodes) + +## When do you need mapping? + +### You need mapping when + +- **Products with color/material variations**: Different textures or materials per option +- **Products with size options**: Different 3D meshes or positioning per size +- **Modular products**: Parts that can be added, removed, or swapped +- **Optional features**: Accessories, upgrades, or add-ons +- **Any scenario where user choices affect what's visible in 3D** + +### You don't need mapping when + +- **Static 3D models**: Always look the same regardless of user interaction +- **Product showcases**: Pure visualization without configuration options +- **Architectural visualizations**: Static buildings or environments +- **Art pieces or sculptures**: Single-configuration display items +- **Single-variant products**: No customization options available + ## Example: City bicycle configurator To make these concepts concrete, we'll use a city bicycle configurator throughout this documentation. @@ -81,16 +109,44 @@ Each attribute has multiple possible values. For our bicycle: string value - the client library simply uses it to match your selection calls with the mapping configuration. While you can use any values you want (even made-up ones like "option-a", "variant-1"), it's best practice -to use the same values as your existing e-commerce or product system. This makes integration easier: +to use the same values as your existing e-commerce or product system. This makes integration easier. + +### 4. Selection state + +Each value can be marked as selected or not: + +- `isSelected: true` - This option is active by default +- `isSelected: false` or omitted - This option is not active + +For our bicycle, we might want: + +- Default frame color: Blue (`isSelected: true`) +- Default frame size: Medium (most common) +- Default accessories: None (base model) + +**Complete example with all properties:** ```typescript // Good practice: Match your existing system -{ value: 'blue', nodeIds: ['frame_blue'] } // Same as product database -{ value: 'SKU-BASKET-01', nodeIds: ['basket_front'] } // Using SKU system +{ + value: 'blue', + nodeIds: ['frame_blue'], + isSelected: true // This is the default color +} + +// Using SKU system +{ + value: 'SKU-BASKET-01', + nodeIds: ['basket_front'], + isSelected: false // Not selected by default +} // Also valid: Custom identifiers -{ value: 'primary-color', nodeIds: ['frame_blue'] } // Custom naming -{ value: 'color-1', nodeIds: ['variant_a'] } // Made-up values +{ + value: 'primary-color', + nodeIds: ['frame_blue'], + isSelected: true // Default selection +} ``` **Connecting to UI elements:** Here's how the value connects your bicycle configurator interface to the 3D model: @@ -157,19 +213,6 @@ to use the same values as your existing e-commerce or product system. This makes The key is: whatever value you define in your mapping must match what you pass to `select()`. -### 4. Selection state - -Each value can be marked as selected or not: - -- `isSelected: true` - This option is active by default -- `isSelected: false` or omitted - This option is not active - -For our bicycle, we might want: - -- Default frame color: Blue (`isSelected: true`) -- Default frame size: Medium (most common) -- Default accessories: None (base model) - ## How the mapping process works ### Step 1: Define your mapping structure @@ -275,9 +318,9 @@ When a user customizes their bicycle: ```mermaid sequenceDiagram - participant UI as Bicycle Configurator UI - participant Client as Client Library - participant Server as 3D Server + participant UI as Bicycle configurator UI + participant Client as Client library + participant Server as 3D server UI->>Client: getAttribute('Frame Color').select('red') Client->>Client: Calculate mutations
(hide blue, show red) @@ -330,48 +373,39 @@ multiple accessory parts (`basket_front`, `lights_front`, `lights_rear`, `bell_c // and hiding Blue/Black parts when Red is selected ``` -## When do you need mapping +## Types of mapping -### You need mapping when +There are two approaches to implementing attribute mapping, each suited to different scenarios: -- **Products with color/material variations**: Different textures or materials per option -- **Products with size options**: Different 3D meshes or positioning per size -- **Modular products**: Parts that can be added, removed, or swapped -- **Optional features**: Accessories, upgrades, or add-ons -- **Any scenario where user choices affect what's visible in 3D** +### Static mapping -### You don't need mapping when +All options are predefined and independent. Every combination is valid. -- **Static 3D models**: Always look the same regardless of user interaction -- **Product showcases**: Pure visualization without configuration options -- **Architectural visualizations**: Static buildings or environments -- **Art pieces or sculptures**: Single-configuration display items -- **Single-variant products**: No customization options available +**Example**: Basic bicycle configurator +- Color: blue, red, black (always available) +- Saddle: comfort, sport (always available) +- Accessories: basket, lights, bell (always available) -## Why mapping is essential +**When to use**: Products with simple variations where choices don't affect each other. -**Separation of concerns:** +→ [Learn how to implement static mapping](./static-mapping.md) -- The 3D server is generic - it doesn't know your specific product options -- Your business rules (stock, pricing, combinations) change independently from the 3D model -- You control exactly which parts of the model are shown for each option -- You can group multiple 3D nodes (meshes, materials) into logical product choices +### Dynamic mapping -**Key advantages:** +Options change based on other selections. The system prevents invalid combinations. -- Full control over what's visible without modifying the 3D model -- Business logic stays in your application, not in the 3D server -- Easy to update when products or availability changes -- Group complex 3D structures into simple user choices +**Example**: Advanced bicycle configurator +- Frame size: small → forces 26" wheels, positions parts closer +- Frame size: large → allows 28" wheels, spreads components wider +- Saddle: sport → requires matching sport grips +- Color: premium red → only available on medium/large frames -## Need help with mapping +**When to use**: Products where one choice affects others, requires specific combinations, or has conditional rules. -- We help you connect your product catalog to your 3D models -- Visual mapping tool coming soon to simplify this process -- Contact for mapping assistance +→ [Learn how to implement dynamic mapping](./dynamic-mapping.md) -## Next steps +## Need help with mapping? -- See [Static Mapping Examples](./static-mapping-examples.md) for simple, independent options -- See [Dynamic Mapping Examples](./dynamic-mapping-examples.md) for options with dependencies -- Check the main [README](../README.md) for API reference +- We help you connect your product catalog to your 3D models +- Visual mapping tool coming soon to simplify this process +- Contact [support@virtualdisplay.io](mailto:support@virtualdisplay.io) for mapping assistance diff --git a/docs/static-mapping-examples.md b/docs/static-mapping.md similarity index 87% rename from docs/static-mapping-examples.md rename to docs/static-mapping.md index 067cff1..cae0469 100644 --- a/docs/static-mapping-examples.md +++ b/docs/static-mapping.md @@ -1,9 +1,11 @@ -# Static Mapping Examples +# Static mapping guide Static mapping is the simplest approach for product configurators where all options are predetermined and independent of each other. -## What makes mapping "static" +> **New to attribute mapping?** If you haven't read about the core concepts yet, start with [Understanding attribute mapping](./mapping-concept.md) for the fundamentals. + +## What makes mapping "static"? In static mapping: @@ -12,7 +14,7 @@ In static mapping: - Every combination is valid (from the mapping perspective) - The configuration structure remains constant -## When to use static mapping +## When to use static mapping? Static mapping is ideal for: @@ -23,7 +25,17 @@ Static mapping is ideal for: ## City bicycle example -Let's configure a city bicycle with simple, independent options. Each option can be selected without affecting the others: +To make these concepts concrete, we'll use a city bicycle configurator throughout this documentation. +A typical bicycle has many customizable options, but let's start with the most obvious ones: + +- Frame color (blue, red, or black) +- Accessories (adding a basket or lights) +- Saddle type (comfort or sport) + +When the user selects "blue frame", the system shows the blue frame mesh and hides the red and black ones. +When they add a basket, the basket accessory becomes visible on the 3D model. + +Let's configure this city bicycle with simple, independent options. Each option can be selected without affecting the others: ```typescript client.setMapping({ @@ -237,7 +249,7 @@ Set `isSelected: true` on sensible defaults: - Standard configuration (comfort saddle) - Essential accessories (bell included) -## When to upgrade to dynamic mapping +## When to upgrade to dynamic mapping? You've outgrown static mapping when: @@ -246,8 +258,4 @@ You've outgrown static mapping when: - You're duplicating similar configurations with small variations - Business rules become complex to express statically -## Next steps - -- See [Dynamic Mapping Examples](./dynamic-mapping-examples.md) for the same bicycle with dependencies -- Return to [Mapping Concepts](./mapping-concept.md) for the overview -- Explore the [color configurator example](../examples/color-configurator/) for a working implementation +→ [Learn about dynamic mapping](./dynamic-mapping.md) From 4262ed24af261f3a743142a1150d28ed81447c66 Mon Sep 17 00:00:00 2001 From: HMAZonderland Date: Mon, 4 Aug 2025 21:35:06 +0200 Subject: [PATCH 15/15] style: fix markdown linting issues - Remove trailing punctuation from headings - Fix line length violations - Add blank lines around lists - Maintain consistent formatting --- docs/dynamic-mapping.md | 18 +++++++++++------- docs/mapping-concept.md | 13 ++++++++----- docs/static-mapping.md | 9 +++++---- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/docs/dynamic-mapping.md b/docs/dynamic-mapping.md index 291e480..51b8e66 100644 --- a/docs/dynamic-mapping.md +++ b/docs/dynamic-mapping.md @@ -3,11 +3,13 @@ Dynamic mapping allows attribute values to change based on other selections, enabling complex product configurations with dependencies. -> **New to attribute mapping?** If you haven't read about the core concepts yet, start with [Understanding attribute mapping](./mapping-concept.md) for the fundamentals. +> **New to attribute mapping?** If you haven't read about the core concepts yet, start with +> [Understanding attribute mapping](./mapping-concept.md) for the fundamentals. > -> **Haven't read about static mapping yet?** Start with [Static mapping guide](./static-mapping.md) first. This guide builds on those concepts using the same city bicycle example. +> **Haven't read about static mapping yet?** Start with [Static mapping guide](./static-mapping.md) first. +> This guide builds on those concepts using the same city bicycle example. -## What makes mapping "dynamic"? +## What makes mapping "dynamic" In dynamic mapping, any property can be computed at runtime using a function: @@ -59,7 +61,8 @@ When you select a frame size, it cascades through the entire configuration: The basket is always "basket" from the user's perspective, but the 3D model shows `basket_small`, `basket_medium`, or `basket_large` nodes positioned appropriately for each frame. -Without dynamic mapping, you'd need either 3 separate product configurations or build complex workarounds in your application to manage all these dependencies - defeating the purpose of a clean mapping system. +Without dynamic mapping, you'd need either 3 separate product configurations or build complex workarounds in your +application to manage all these dependencies - defeating the purpose of a clean mapping system. ### Implementation @@ -262,7 +265,7 @@ client.setMapping({ }); ``` -### What happens during re-evaluation? +### What happens during re-evaluation Look at the cascade effect when changing frame size: @@ -372,6 +375,7 @@ saddle), the server will handle the state update appropriately. 5. **Document dependencies**: Make it clear which attributes depend on others 6. **Consider user experience**: Ensure logical defaults when options change -## When to use dynamic vs static? +## When to use dynamic vs static -→ See [Types of mapping](./mapping-concept.md#types-of-mapping) in the mapping concepts guide for detailed comparison and use cases. +→ See [Types of mapping](./mapping-concept.md#types-of-mapping) in the mapping concepts guide for detailed +comparison and use cases. diff --git a/docs/mapping-concept.md b/docs/mapping-concept.md index b29c6d0..ec8105a 100644 --- a/docs/mapping-concept.md +++ b/docs/mapping-concept.md @@ -1,6 +1,6 @@ # Understanding attribute mapping -## What is attribute mapping? +## What is attribute mapping Attribute mapping is the bridge between your product's business logic and its 3D visualization. It translates your product options into specific parts of the 3D model that should be visible. @@ -11,9 +11,10 @@ This translation layer solves a fundamental disconnect: - The 3D world operates with technical constructs - meshes, nodes, materials, transforms - Your customers think in their own terms - "I want the blue one with the basket" -Without this bridge, you'd be forced to embed business logic into your 3D models or technical details into your product catalog. Mapping keeps both worlds pure and focused on what they do best. +Without this bridge, you'd be forced to embed business logic into your 3D models or technical details into your +product catalog. Mapping keeps both worlds pure and focused on what they do best. -## Why do we use mapping? +## Why do we use mapping ### The separation of concerns @@ -32,7 +33,7 @@ inventory, or business rules. This separation provides several benefits: - **Easy updates**: When products or availability changes, just update the mapping - **Logical grouping**: Complex 3D structures become simple user choices (one button can control multiple nodes) -## When do you need mapping? +## When do you need mapping ### You need mapping when @@ -382,6 +383,7 @@ There are two approaches to implementing attribute mapping, each suited to diffe All options are predefined and independent. Every combination is valid. **Example**: Basic bicycle configurator + - Color: blue, red, black (always available) - Saddle: comfort, sport (always available) - Accessories: basket, lights, bell (always available) @@ -395,6 +397,7 @@ All options are predefined and independent. Every combination is valid. Options change based on other selections. The system prevents invalid combinations. **Example**: Advanced bicycle configurator + - Frame size: small → forces 26" wheels, positions parts closer - Frame size: large → allows 28" wheels, spreads components wider - Saddle: sport → requires matching sport grips @@ -404,7 +407,7 @@ Options change based on other selections. The system prevents invalid combinatio → [Learn how to implement dynamic mapping](./dynamic-mapping.md) -## Need help with mapping? +## Need help with mapping - We help you connect your product catalog to your 3D models - Visual mapping tool coming soon to simplify this process diff --git a/docs/static-mapping.md b/docs/static-mapping.md index cae0469..cebb63e 100644 --- a/docs/static-mapping.md +++ b/docs/static-mapping.md @@ -3,9 +3,10 @@ Static mapping is the simplest approach for product configurators where all options are predetermined and independent of each other. -> **New to attribute mapping?** If you haven't read about the core concepts yet, start with [Understanding attribute mapping](./mapping-concept.md) for the fundamentals. +> **New to attribute mapping?** If you haven't read about the core concepts yet, start with +> [Understanding attribute mapping](./mapping-concept.md) for the fundamentals. -## What makes mapping "static"? +## What makes mapping "static" In static mapping: @@ -14,7 +15,7 @@ In static mapping: - Every combination is valid (from the mapping perspective) - The configuration structure remains constant -## When to use static mapping? +## When to use static mapping Static mapping is ideal for: @@ -249,7 +250,7 @@ Set `isSelected: true` on sensible defaults: - Standard configuration (comfort saddle) - Essential accessories (bell included) -## When to upgrade to dynamic mapping? +## When to upgrade to dynamic mapping You've outgrown static mapping when: