import partsData from '../data/index.js'; import { evaluateCondition, shouldIncludeComponent, getPriceDisplayFromLinks } from './bomUtils'; import { formatPrice } from './priceFormat'; /** * Categorize hardware by type */ export const getHardwareType = (hardware) => { const id = hardware.id?.toLowerCase() || ''; const name = hardware.name?.toLowerCase() || ''; // Fasteners if (id.includes('fastener') || id.includes('screw') || id.includes('nut') || id.includes('washer') || id.includes('bolt') || id.includes('handle') || name.includes('fastener') || name.includes('screw') || name.includes('nut') || name.includes('washer') || name.includes('bolt') || name.includes('handle')) { return 'Fasteners'; } // Motion components if (id.includes('bearing') || id.includes('pulley') || id.includes('belt') || id.includes('gear') || id.includes('motor') || id.includes('rail') || id.includes('linear') || name.includes('bearing') || name.includes('pulley') || name.includes('belt') || name.includes('gear') || name.includes('motor') || name.includes('rail') || name.includes('linear')) { return 'Motion Components'; } // Aluminum extrusion / 3030 if (id.includes('3030') || id.includes('extrusion') || id.includes('aluminum') || id.includes('support') || name.includes('3030') || name.includes('extrusion') || name.includes('aluminum') || name.includes('90 degree support')) { return 'Aluminum Extrusion'; } // Electronics if (id.includes('pcb') || id.includes('board') || id.includes('circuit') || id.includes('sensor') || id.includes('switch') || id.includes('led') || name.includes('pcb') || name.includes('board') || name.includes('circuit') || name.includes('sensor') || name.includes('switch') || name.includes('led')) { return 'Electronics'; } // Other / General Hardware return 'Other Hardware'; }; /** * Get required printed parts based on config */ export const getRequiredPrintedParts = (config) => { const parts = []; // Always include components that are marked as required and meet their conditions Object.entries(partsData.components || {}).forEach(([componentKey, component]) => { // Skip components that don't have selected options (except actuator) if (!shouldIncludeComponent(componentKey, config)) { return; } const category = component.category || componentKey; // Handle standard printedParts array if (component.printedParts) { component.printedParts.forEach((part) => { if (part.required && evaluateCondition(part.Condition, config)) { parts.push({ ...part, category }); } }); } // Handle systems (for hinges, remotes, etc.) if (component.systems) { // If it's a selected system, include its printed parts const selectedSystemId = config[componentKey] || config.standHinge; // Fallback for naming mismatches const system = component.systems[selectedSystemId?.id || selectedSystemId]; if (system) { const systemParts = system.printedParts || system.bodyParts || []; systemParts.forEach((part) => { if (part.required && evaluateCondition(part.Condition, config)) { parts.push({ ...part, category }); } }); // Remote knobs are handled by looking up the knob in the system if (componentKey === 'remotes' && config.remoteKnob) { const knobPart = system.knobs?.find(k => k.id === config.remoteKnob.id); if (knobPart) { parts.push({ ...knobPart, category: 'Remote Knobs' }); } } } } }); // Handle options that are not explicitly in the "components" top-level structure but represent printed parts // Mount variations (if not already handled by required parts) if (config.mount && partsData.components?.mounts?.printedParts) { const mountPart = partsData.components.mounts.printedParts.find(p => p.id === config.mount.id); if (mountPart) parts.push({ ...mountPart, category: 'Mount' }); } // Custom Cover const coverId = config.cover?.id; const isStandardCover = coverId === 'standard-cover'; const isBlankCover = coverId === 'blank-cover'; const isCustomCover = config.cover !== null && !isStandardCover && !isBlankCover; if (isCustomCover) { const coverOption = config.cover; parts.push({ id: coverOption.id, name: coverOption.name, description: coverOption.description || coverOption.name, filamentEstimate: coverOption.filamentEstimate ? parseFloat(coverOption.filamentEstimate.replace('~', '').replace('g', '')) : 0, filePath: `${coverOption.id}.3mf`, category: 'Cover', required: true, colour: 'primary', }); } // Stand components (feet, supports) if (config.standFeet && partsData.components?.feet?.printedParts) { const feetPart = partsData.components.feet.printedParts.find(p => p.id === config.standFeet.id); if (feetPart) parts.push({ ...feetPart, category: 'Stand Feet' }); } if (config.standCrossbarSupports && partsData.components?.crossbarSupports?.printedParts) { const selectedSupportIds = new Set(config.standCrossbarSupports.map(opt => opt.id)); partsData.components.crossbarSupports.printedParts.forEach((part) => { if (selectedSupportIds.has(part.id) && !part.isHardwareOnly) { parts.push({ ...part, category: 'Stand Crossbar Supports' }); } }); } // Toy Mounts if (config.toyMountOptions && config.toyMountOptions.length > 0 && partsData.components?.toyMounts?.printedParts) { const selectedToyMountIds = new Set(config.toyMountOptions.map(opt => opt.id)); partsData.components.toyMounts.printedParts.forEach((part) => { if (selectedToyMountIds.has(part.id)) { parts.push({ ...part, category: 'Toy Mounts' }); } }); } // Handle 'replaces' logic const replacedIds = new Set(); parts.forEach(part => { if (part.replaces) { part.replaces.forEach(id => replacedIds.add(id)); } }); return parts.filter(part => !replacedIds.has(part.id)); }; /** * Get required hardware parts based on config */ export const getRequiredHardwareParts = (config) => { const printedParts = getRequiredPrintedParts(config); const printedPartIds = new Set(printedParts.map(p => p.id)); const hardwareParts = []; const hardwareMap = new Map(); // To aggregate quantities for same hardware part // Collect all selected option IDs (including hardware-only options) const selectedOptionIds = new Set(); // Add selected crossbar supports (may include hardware-only options) if (config.standCrossbarSupports && config.standCrossbarSupports.length > 0) { config.standCrossbarSupports.forEach(opt => selectedOptionIds.add(opt.id)); } // Add other selected options that might be hardware-only if (config.standHinge) selectedOptionIds.add(config.standHinge.id); if (config.standFeet) selectedOptionIds.add(config.standFeet.id); if (config.mount) selectedOptionIds.add(config.mount.id); if (config.cover) selectedOptionIds.add(config.cover.id); if (config.pcbMount) selectedOptionIds.add(config.pcbMount.id); if (config.remoteKnob) selectedOptionIds.add(config.remoteKnob.id); if (config.toyMountOptions && config.toyMountOptions.length > 0) { config.toyMountOptions.forEach(opt => selectedOptionIds.add(opt.id)); } // Handle hinges systems (new structure) if (config.standHinge && partsData.components?.hinges?.systems) { const hingeSystem = partsData.components.hinges.systems[config.standHinge.id]; if (hingeSystem?.hardwareParts) { hingeSystem.hardwareParts.forEach((hardware) => { if (!hardware.required) return; // Evaluate condition for hardware if (!evaluateCondition(hardware.Condition, config)) return; const key = hardware.id; if (hardwareMap.has(key)) { const existing = hardwareMap.get(key); existing.quantity = (existing.quantity || 1) + (hardware.quantity || 1); } else { hardwareMap.set(key, { ...hardware, quantity: hardware.quantity || 1, category: partsData.components.hinges.category || 'Hardware', hardwareType: getHardwareType(hardware) }); } }); } } // Handle remote systems (new structure) if (config.remoteKnob && partsData.components?.remotes?.systems) { // Find which system contains this knob let remoteSystem = null; Object.values(partsData.components.remotes.systems).forEach((system) => { if (system.knobs && system.knobs.find(k => k.id === config.remoteKnob.id)) { remoteSystem = system; } }); if (remoteSystem?.hardwareParts) { remoteSystem.hardwareParts.forEach((hardware) => { if (!hardware.required) return; // Evaluate condition for hardware if (!evaluateCondition(hardware.Condition, config)) return; const key = hardware.id; if (hardwareMap.has(key)) { const existing = hardwareMap.get(key); existing.quantity = (existing.quantity || 1) + (hardware.quantity || 1); } else { hardwareMap.set(key, { ...hardware, quantity: hardware.quantity || 1, category: partsData.components.remotes.category || 'Hardware' }); } }); } } // Build a map of component keys to their printed part IDs for quick lookup const componentPrintedPartIds = new Map(); Object.entries(partsData.components || {}).forEach(([componentKey, component]) => { // Skip hinges and remotes as they're handled separately above if (componentKey === 'hinges' || componentKey === 'remotes') return; // Skip components that don't have selected options (except actuator) if (!shouldIncludeComponent(componentKey, config)) { return; } if (component.printedParts) { const partIds = component.printedParts.map(p => p.id); componentPrintedPartIds.set(componentKey, new Set(partIds)); } }); // Iterate through all components to find hardware parts (excluding hinges and remotes) Object.entries(partsData.components || {}).forEach(([componentKey, component]) => { // Skip hinges and remotes as they're handled separately above if (componentKey === 'hinges' || componentKey === 'remotes') return; // Skip components that don't have selected options (except actuator) if (!shouldIncludeComponent(componentKey, config)) { return; } // Handle components with systems (like mounts) if (component.systems) { // Find the selected system based on config let selectedSystemId = null; if (componentKey === 'mounting' && config.mount) { selectedSystemId = config.mount.id; } else if (componentKey === 'toyMounts' && config.toyMountOptions && config.toyMountOptions.length > 0) { // For toy mounts, process all selected options config.toyMountOptions.forEach((toyMount) => { const system = component.systems[toyMount.id]; if (system?.hardwareParts) { system.hardwareParts.forEach((hardware) => { if (!hardware.required) return; if (!evaluateCondition(hardware.Condition, config)) return; const key = hardware.id; if (hardwareMap.has(key)) { const existing = hardwareMap.get(key); existing.quantity = (existing.quantity || 1) + (hardware.quantity || 1); } else { hardwareMap.set(key, { ...hardware, quantity: hardware.quantity || 1, category: component.category || 'Hardware', hardwareType: getHardwareType(hardware) }); } }); } }); return; // Skip the rest for toy mounts } if (selectedSystemId) { const system = component.systems[selectedSystemId]; if (system?.hardwareParts) { system.hardwareParts.forEach((hardware) => { if (!hardware.required) return; if (!evaluateCondition(hardware.Condition, config)) return; const key = hardware.id; if (hardwareMap.has(key)) { const existing = hardwareMap.get(key); existing.quantity = (existing.quantity || 1) + (hardware.quantity || 1); } else { hardwareMap.set(key, { ...hardware, quantity: hardware.quantity || 1, category: component.category || 'Hardware', hardwareType: getHardwareType(hardware) }); } }); } } return; // Skip direct hardwareParts check for components with systems } // Handle components with direct hardwareParts (old structure) if (!component.hardwareParts) return; // Check if this component has any selected printed parts OR selected options const componentPartIds = componentPrintedPartIds.get(componentKey); const hasSelectedParts = componentPartIds && Array.from(componentPartIds).some(id => printedPartIds.has(id) || selectedOptionIds.has(id)); component.hardwareParts.forEach((hardware) => { if (!hardware.required) return; // Evaluate condition for hardware if (!evaluateCondition(hardware.Condition, config)) return; // If component has selected parts, check if hardware should be included let shouldInclude = false; if (hasSelectedParts) { const relatedParts = hardware.relatedParts || []; // If no relatedParts specified, include if component has selected parts // If relatedParts specified, include if any related part is selected (printed or option) if (relatedParts.length === 0) { shouldInclude = true; } else { shouldInclude = relatedParts.some(partId => printedPartIds.has(partId) || selectedOptionIds.has(partId)); } } if (shouldInclude) { const key = hardware.id; if (hardwareMap.has(key)) { // Aggregate quantities if same hardware appears multiple times const existing = hardwareMap.get(key); existing.quantity = (existing.quantity || 1) + (hardware.quantity || 1); } else { hardwareMap.set(key, { ...hardware, quantity: hardware.quantity || 1, category: component.category || 'Hardware', hardwareType: getHardwareType(hardware) }); } } }); }); // Add Motor, Power Supply, and PCB to hardware list // Store original item reference for price conversion if (config.motor) { hardwareParts.push({ id: config.motor.id || 'motor', name: config.motor.name || 'Motor', description: config.motor.description || '', quantity: 1, category: 'Electronics', hardwareType: 'Electronics', price: config.motor, // Store original item for price conversion _isItemReference: true // Flag to indicate this is an item reference }); } if (config.powerSupply) { hardwareParts.push({ id: config.powerSupply.id || 'power-supply', name: config.powerSupply.name || 'Power Supply', description: config.powerSupply.description || '', quantity: 1, category: 'Electronics', hardwareType: 'Electronics', price: config.powerSupply, // Store original item for price conversion _isItemReference: true }); } // Add PCB (always required, separate from PCB mount) if (partsData.pcbs && partsData.pcbs.length > 0) { const pcb = partsData.pcbs[0]; // Get the first/only PCB hardwareParts.push({ id: pcb.id || 'pcb', name: pcb.name || 'PCB', description: pcb.description || '', quantity: 1, category: 'Electronics', hardwareType: 'Electronics', price: pcb, // Store original item for price conversion _isItemReference: true }); } // Convert map to array hardwareMap.forEach((hardware) => { hardwareParts.push(hardware); }); return hardwareParts; }; /** * Parse time string to minutes */ export const parseTimeToMinutes = (timeStr) => { if (!timeStr || typeof timeStr !== 'string') return 0; let totalMinutes = 0; const hourMatch = timeStr.match(/(\d+)h/); const minuteMatch = timeStr.match(/(\d+)m/); const secondMatch = timeStr.match(/(\d+)s/); if (hourMatch) totalMinutes += parseInt(hourMatch[1], 10) * 60; if (minuteMatch) totalMinutes += parseInt(minuteMatch[1], 10); if (secondMatch) totalMinutes += parseFloat(secondMatch[1]) / 60; return totalMinutes; }; /** * Format minutes to readable string (e.g., "2h 14m") */ export const formatTimeFromMinutes = (minutes) => { if (minutes === 0) return '0m'; const hours = Math.floor(minutes / 60); const mins = Math.round(minutes % 60); if (hours > 0 && mins > 0) { return `${hours}h ${mins}m`; } else if (hours > 0) { return `${hours}h`; } else { return `${mins}m`; } }; /** * Calculate total filament estimate */ export const getTotalFilamentEstimate = (printedParts) => { const totals = { primary: 0, secondary: 0, total: 0 }; printedParts.forEach((part) => { let estimate = 0; // Handle both numeric and string values (e.g., "~147g" or 147.19) if (typeof part.filamentEstimate === 'number') { estimate = part.filamentEstimate; } else if (typeof part.filamentEstimate === 'string') { // Parse string format like "~147g" or "147g" const cleaned = part.filamentEstimate.replace(/[~g]/g, '').trim(); estimate = parseFloat(cleaned) || 0; } // Multiply by quantity if specified (default to 1) const quantity = part.quantity || 1; estimate = estimate * quantity; const colour = part.colour || 'primary'; if (colour === 'primary') { totals.primary += estimate; } else if (colour === 'secondary') { totals.secondary += estimate; } totals.total += estimate; }); return totals; }; /** * Calculate total time estimate */ export const getTotalTimeEstimate = (printedParts) => { let totalMinutes = 0; printedParts.forEach((part) => { if (part.timeEstimate) { const timeMinutes = parseTimeToMinutes(part.timeEstimate); // Multiply by quantity if specified (default to 1) const quantity = part.quantity || 1; totalMinutes += timeMinutes * quantity; } }); return formatTimeFromMinutes(totalMinutes); }; /** * Get color name from color ID */ export const getColorName = (colorId, type = 'primary') => { const colors = type === 'primary' ? partsData.colors.primary : partsData.colors.accent; const color = colors.find((c) => c.id === colorId); return color ? color.name : colorId; }; /** * Get color hex from color ID */ export const getColorHex = (colorId, type = 'primary') => { const colors = type === 'primary' ? partsData.colors.primary : partsData.colors.accent; const color = colors.find((c) => c.id === colorId); return color ? color.hex : '#000000'; }; /** * Get expanded hardware parts grouped by component */ export const getExpandedHardwareParts = (config) => { const printedParts = getRequiredPrintedParts(config); const printedPartIds = new Set(printedParts.map(p => p.id)); const expandedHardware = []; const selectedOptionIds = new Set(); // Add Electronics section with Motor, Power Supply, and PCB const electronicsHardware = []; if (config.motor) { electronicsHardware.push({ id: config.motor.id || 'motor', name: config.motor.name || 'Motor', description: config.motor.description || '', quantity: 1, hardwareType: 'Electronics', price: config.motor, // Store original item for price conversion _isItemReference: true }); } if (config.powerSupply) { electronicsHardware.push({ id: config.powerSupply.id || 'power-supply', name: config.powerSupply.name || 'Power Supply', description: config.powerSupply.description || '', quantity: 1, hardwareType: 'Electronics', price: config.powerSupply, // Store original item for price conversion _isItemReference: true }); } // Add PCB (always required, separate from PCB mount) if (partsData.pcbs && partsData.pcbs.length > 0) { const pcb = partsData.pcbs[0]; // Get the first/only PCB electronicsHardware.push({ id: pcb.id || 'pcb', name: pcb.name || 'PCB', description: pcb.description || '', quantity: 1, hardwareType: 'Electronics', price: pcb, // Store original item for price conversion _isItemReference: true }); } if (electronicsHardware.length > 0) { expandedHardware.push({ component: 'Electronics', parts: electronicsHardware }); } if (config.standCrossbarSupports && config.standCrossbarSupports.length > 0) { config.standCrossbarSupports.forEach(opt => selectedOptionIds.add(opt.id)); } if (config.standHinge) selectedOptionIds.add(config.standHinge.id); if (config.standFeet) selectedOptionIds.add(config.standFeet.id); if (config.mount) selectedOptionIds.add(config.mount.id); if (config.cover) selectedOptionIds.add(config.cover.id); if (config.pcbMount) selectedOptionIds.add(config.pcbMount.id); if (config.remoteKnob) selectedOptionIds.add(config.remoteKnob.id); if (config.toyMountOptions && config.toyMountOptions.length > 0) { config.toyMountOptions.forEach(opt => selectedOptionIds.add(opt.id)); } // Handle hinges systems if (config.standHinge && partsData.components?.hinges?.systems) { const hingeSystem = partsData.components.hinges.systems[config.standHinge.id]; if (hingeSystem?.hardwareParts) { const componentHardware = []; hingeSystem.hardwareParts.forEach((hardware) => { if (hardware.required) { componentHardware.push({ ...hardware, quantity: hardware.quantity || 1, hardwareType: getHardwareType(hardware) }); } }); if (componentHardware.length > 0) { expandedHardware.push({ component: partsData.components.hinges.category || 'Hinges', parts: componentHardware, }); } } } // Handle remote systems if (config.remoteKnob && partsData.components?.remotes?.systems) { let remoteSystem = null; Object.values(partsData.components.remotes.systems).forEach((system) => { if (system.knobs && system.knobs.find(k => k.id === config.remoteKnob.id)) { remoteSystem = system; } }); if (remoteSystem?.hardwareParts) { const componentHardware = []; remoteSystem.hardwareParts.forEach((hardware) => { if (hardware.required) { componentHardware.push({ ...hardware, quantity: hardware.quantity || 1, hardwareType: getHardwareType(hardware) }); } }); if (componentHardware.length > 0) { expandedHardware.push({ component: partsData.components.remotes.category || 'Remote', parts: componentHardware, }); } } } // Handle mount systems if (config.mount && partsData.components?.mounting?.systems) { const mountSystem = partsData.components.mounting.systems[config.mount.id]; if (mountSystem?.hardwareParts) { const componentHardware = []; mountSystem.hardwareParts.forEach((hardware) => { if (hardware.required && evaluateCondition(hardware.Condition, config)) { componentHardware.push({ ...hardware, quantity: hardware.quantity || 1, hardwareType: getHardwareType(hardware) }); } }); if (componentHardware.length > 0) { expandedHardware.push({ component: partsData.components.mounting.category || 'Mounting', parts: componentHardware, }); } } } // Handle toy mount systems if (config.toyMountOptions && config.toyMountOptions.length > 0 && partsData.components?.toyMounts?.systems) { config.toyMountOptions.forEach((toyMount) => { const toyMountSystem = partsData.components.toyMounts.systems[toyMount.id]; if (toyMountSystem?.hardwareParts) { const componentHardware = []; toyMountSystem.hardwareParts.forEach((hardware) => { if (hardware.required && evaluateCondition(hardware.Condition, config)) { componentHardware.push({ ...hardware, quantity: hardware.quantity || 1, hardwareType: getHardwareType(hardware) }); } }); if (componentHardware.length > 0) { expandedHardware.push({ component: partsData.components.toyMounts.category || 'Toy Mounts', parts: componentHardware, }); } } }); } // Handle other components const componentPrintedPartIds = new Map(); Object.entries(partsData.components || {}).forEach(([componentKey, component]) => { if (componentKey === 'hinges' || componentKey === 'remotes' || componentKey === 'mounting' || componentKey === 'toyMounts') return; // Skip components that don't have selected options (except actuator) if (!shouldIncludeComponent(componentKey, config)) { return; } if (component.printedParts) { const partIds = component.printedParts.map(p => p.id); componentPrintedPartIds.set(componentKey, new Set(partIds)); } }); Object.entries(partsData.components || {}).forEach(([componentKey, component]) => { if (componentKey === 'hinges' || componentKey === 'remotes' || componentKey === 'mounting' || componentKey === 'toyMounts') return; // Skip components that don't have selected options (except actuator) if (!shouldIncludeComponent(componentKey, config)) { return; } if (!component.hardwareParts) return; const componentPartIds = componentPrintedPartIds.get(componentKey); const hasSelectedParts = componentPartIds && Array.from(componentPartIds).some(id => printedPartIds.has(id) || selectedOptionIds.has(id)); const componentHardware = []; component.hardwareParts.forEach((hardware) => { if (!hardware.required) return; let shouldInclude = false; if (hasSelectedParts) { const relatedParts = hardware.relatedParts || []; if (relatedParts.length === 0) { shouldInclude = true; } else { shouldInclude = relatedParts.some(partId => printedPartIds.has(partId) || selectedOptionIds.has(partId)); } } if (shouldInclude) { componentHardware.push({ ...hardware, quantity: hardware.quantity || 1, hardwareType: getHardwareType(hardware) }); } }); if (componentHardware.length > 0) { expandedHardware.push({ component: component.category || componentKey, parts: componentHardware, }); } }); return expandedHardware; };