diff --git a/src/assets/js/diy-calculator/components-data.js b/src/assets/js/diy-calculator/components-data.js
new file mode 100644
index 000000000..5430cdd98
--- /dev/null
+++ b/src/assets/js/diy-calculator/components-data.js
@@ -0,0 +1,436 @@
+const primaryCountMap = { 5: 5, 6: 6, 8: 6, 10: 8 };
+
+export function primaryTrackerCount(set) {
+ return primaryCountMap[set] !== undefined ? primaryCountMap[set] : set;
+}
+
+export function extensionCount(set) {
+ return set - primaryTrackerCount(set);
+}
+
+export function physicalTrackerCount(set) {
+ return set;
+}
+
+export const componentCategories = [
+ {
+ name: 'Microcontroller',
+ choices: [
+ {
+ name: 'Wemos D1 Mini (AliExpress)',
+ description: 'ESP8266 dev board. Standard choice for DIY SlimeVR trackers.',
+ amount: (set) => primaryTrackerCount(set),
+ cost: () => 1.85,
+ costAll: (set) => primaryTrackerCount(set) * 1.85 + 2.53,
+ links: '\
+ AliExpress Wemos D1 Mini \
+ $1.85 per board, plus $2.53 shipping. ',
+ },
+ ],
+ },
+ {
+ name: 'IMU',
+ choices: [
+ {
+ name: 'π’ ICM-45686 (SlimeVR shop)',
+ description: 'Best performance option.',
+ amount: (set) => set,
+ cost: () => 6.70,
+ costAll: (set) => set * 6.70 + 6.70,
+ links: '\
+ SlimeVR Mumo Breakout Module V1 (ICM-45686 + QMC6309) \
+ $6.70 per IMU. Cost includes one extra spare (+$6.70). ',
+ },
+ {
+ name: 'π’ LSM6DSV (Moffshop)',
+ description: 'Great performance from Moffshop.',
+ amount: (set) => set,
+ cost: () => 8.93,
+ costAll: (set) => set * 8.93 + 5.58,
+ links: '\
+ Moffshop LSM6DSV \
+ $8.93 per IMU. Cost includes one extra spare (+$5.58). ',
+ },
+ {
+ name: 'π’ LSM6DSR (AliExpress)',
+ description: 'Budget option.',
+ amount: (set) => set,
+ cost: () => 3.35,
+ costAll: (set) => set * 3.35 + 6.70,
+ links: '\
+ AliExpress LSM6DSR \
+ $3.35 per IMU. Cost includes one extra spare (+$6.70). ',
+ },
+ ],
+ },
+ {
+ name: 'Batteries',
+ choices: [
+ {
+ name: '1800 mAh 804040 Li-Po (AliExpress)',
+ description: 'Large capacity. One per primary tracker.',
+ amount: (set) => primaryTrackerCount(set),
+ cost: () => 1.67,
+ costAll: (set) => primaryTrackerCount(set) * 1.67,
+ links: '\
+ 804040 1800mAh 10pk 2 wires (AliExpress) \
+ $16.74 per 10-pack ($1.67 per battery). ',
+ },
+ {
+ name: '1200 mAh 903052 Li-Po - 5 pcs (Amazon)',
+ description: 'Buy 1 pack for up to 5 primary trackers, 2 for 6+.',
+ amount: (set) => Math.ceil(primaryTrackerCount(set) / 5),
+ cost: () => 22.99,
+ costAll: (set) => Math.ceil(primaryTrackerCount(set) / 5) * 22.99,
+ links: '\
+ Amazon Li-Po Batteries \
+ $22.99 per 5-pack. ',
+ },
+ {
+ name: 'Generic 18650 (AliExpress)',
+ description: 'Per primary tracker. Requires holder. Buyer beware: rated capacities may be inaccurate.',
+ amount: (set) => primaryTrackerCount(set),
+ cost: () => 3 + 0.27,
+ costAll: (set) => primaryTrackerCount(set) * (3 + 0.27) + 1.89,
+ links: '\
+ AliExpress 18650 cell and \
+ AliExpress (X4 Slot) \
+ $3 per cell + $0.27 holder, plus $1.89 shipping. ',
+ },
+ {
+ name: 'Sourced elsewhere',
+ amount: () => 0,
+ cost: () => 0,
+ costAll: () => 0,
+ links: '',
+ },
+ ],
+ },
+ {
+ name: 'Charging Board',
+ choices: [
+ {
+ name: 'TP4056 USB charging board (AliExpress)',
+ description: 'One per primary tracker.',
+ amount: (set) => primaryTrackerCount(set),
+ cost: () => 0.36,
+ costAll: (set) => primaryTrackerCount(set) * 0.36 + 2.07,
+ links: '\
+ AliExpress (18650 mini / type-c / micro) \
+ $0.36 per board, plus $2.07 shipping. ',
+ },
+ {
+ name: 'TP4056 USB charging board - 10 pcs (Amazon)',
+ description: 'Enough for 10 primary trackers.',
+ amount: () => 1,
+ cost: () => 8.19,
+ costAll: () => 8.19,
+ links: '\
+ Amazon TP4056 \
+ $8.19 for 10pcs. ',
+ },
+ {
+ name: 'Sourced elsewhere',
+ amount: () => 0,
+ cost: () => 0,
+ costAll: () => 0,
+ links: '',
+ },
+ ],
+ },
+ {
+ name: 'Power Switches',
+ choices: [
+ {
+ name: 'SS22F32 switches - 10 pcs (AliExpress)',
+ description: '',
+ amount: () => 1,
+ cost: () => 1.97,
+ costAll: () => 1.97,
+ links: '\
+ AliExpress \
+ $1.97 for 10pcs. ',
+ },
+ {
+ name: 'SS22F32 switches - 10 pcs (Alt) (Amazon)',
+ description: '',
+ amount: () => 1,
+ cost: () => 7.99,
+ costAll: () => 7.99,
+ links: '\
+ Amazon SS22F32 switches \
+ $7.99 for 10pcs. ',
+ },
+ {
+ name: 'Sourced elsewhere',
+ amount: () => 0,
+ cost: () => 0,
+ costAll: () => 0,
+ links: '',
+ },
+ ],
+ },
+ {
+ name: 'Diodes',
+ choices: [
+ {
+ name: '1N5817 diodes - 50 pcs (AliExpress)',
+ description: 'Optional for battery protection.',
+ amount: () => 1,
+ cost: () => 0.62,
+ costAll: () => 0.62 + 2.21,
+ links: '\
+ AliExpress (1N5817) \
+ $0.62 for 50pcs, plus $2.21 shipping. ',
+ },
+ {
+ name: '1N5817 diodes - 100 pcs (Amazon)',
+ description: 'Optional for battery protection.',
+ amount: () => 1,
+ cost: () => 5.99,
+ costAll: () => 5.99,
+ links: '\
+ Amazon 1N5817 diodes \
+ $5.99 for 100pcs. ',
+ },
+ {
+ name: 'Sourced elsewhere',
+ amount: () => 0,
+ cost: () => 0,
+ costAll: () => 0,
+ links: '',
+ },
+ ],
+ },
+ {
+ name: 'Resistors',
+ choices: [
+ {
+ name: '180K ohm resistors - 100 pcs (AliExpress)',
+ description: 'Optional for battery percentage reporting.',
+ amount: () => 1,
+ cost: () => 1.96,
+ costAll: () => 1.96 + 1.29,
+ links: '\
+ AliExpress (100pcs 180K ohms) \
+ $1.96 for 100pcs, plus $1.29 shipping. ',
+ },
+ {
+ name: '180K ohm resistors - 100 pcs (Alt) (Amazon)',
+ description: 'Optional for battery percentage reporting.',
+ amount: () => 1,
+ cost: () => 4.99,
+ costAll: () => 4.99,
+ links: '\
+ Amazon 180K ohm resistors \
+ $4.99 for 100pcs. ',
+ },
+ {
+ name: 'Sourced elsewhere',
+ defaultSelected: true,
+ amount: () => 0,
+ cost: () => 0,
+ costAll: () => 0,
+ links: '',
+ },
+ ],
+ },
+ {
+ name: 'Carrier Board Options',
+ choices: [
+ {
+ name: 'PCB chosen from cases page',
+ description: 'Order 10 PCBs from a PCB fab.',
+ amount: () => 10,
+ cost: () => 0.50,
+ costAll: () => 5,
+ links: '\
+ Cases page here \
+ $0.50 per PCB, $5 for 10pcs. ',
+ },
+ {
+ name: '24-26 AWG Wire, No PCB (Not Recommended) (AliExpress)',
+ description: 'Point-to-point wiring instead of PCB.',
+ amount: () => 1,
+ cost: () => 1.85,
+ costAll: () => 1.85 + 1.68,
+ links: '\
+ AliExpress (22 AWG, 2m) \
+ $1.85 for wire, plus $1.68 shipping. ',
+ },
+ {
+ name: '26AWG Wire, No PCB (Not Recommended) (Amazon)',
+ description: '10m spools, 6-pack.',
+ amount: () => 1,
+ cost: () => 14.99,
+ costAll: () => 14.99,
+ links: '\
+ Amazon 26 AWG, 10m spools \
+ $14.99 for 6 spools. ',
+ },
+ {
+ name: 'Sourced elsewhere',
+ amount: () => 0,
+ cost: () => 0,
+ costAll: () => 0,
+ links: '',
+ },
+ ],
+ },
+ {
+ name: 'Wiring for Extensions',
+ hideFor5Set: true,
+ choices: [
+ {
+ name: 'JST connectors - 5 pin 5 pcs (AliExpress)',
+ description: 'Optional for removable extensions. 2 connectors per extension.',
+ amount: (set) => Math.ceil(extensionCount(set) * 2 / 5),
+ cost: () => 3,
+ costAll: (set) => Math.ceil(extensionCount(set) * 2 / 5) * 3 + 3.96,
+ links: '\
+ AliExpress JST connectors \
+ $3 for 5pcs, plus $3.96 shipping. ',
+ },
+ {
+ name: 'JST connectors - 5 pin 20 pcs (Amazon)',
+ description: 'Optional for removable extensions. 2 connectors per extension.',
+ amount: (set) => Math.ceil(extensionCount(set) * 2 / 20),
+ cost: () => 12.98,
+ costAll: (set) => Math.ceil(extensionCount(set) * 2 / 20) * 12.98,
+ links: '\
+ Amazon JST connectors \
+ $12.98 for 20pcs. ',
+ },
+ {
+ name: 'SlimeVR Extension Cables Deluxe Set (SlimeVR shop)',
+ description: 'Pre-made V3 cables from official store. 6 foot + 2 hip + 2 arm. 10 cables, one per extension.',
+ amount: (set) => Math.ceil(extensionCount(set) / 10),
+ cost: () => 4.50,
+ costAll: (set) => Math.ceil(extensionCount(set) / 10) * 4.50,
+ links: '\
+ SlimeVR Shop (Deluxe Set) \
+ ~$4.50 for 10 cables. ',
+ },
+ {
+ name: 'SlimeVR Extension Cables Full-Body Set (SlimeVR shop)',
+ description: 'Pre-made V3 cables from official store. 4 foot + 2 hip. 6 cables, one per extension.',
+ amount: (set) => Math.ceil(extensionCount(set) / 6),
+ cost: () => 3.00,
+ costAll: (set) => Math.ceil(extensionCount(set) / 6) * 3.00,
+ links: '\
+ SlimeVR Shop (Full-Body Set) \
+ ~$3.00 for 6 cables. ',
+ },
+ {
+ name: 'Sourced elsewhere',
+ amount: () => 0,
+ cost: () => 0,
+ costAll: () => 0,
+ links: '',
+ },
+ ],
+ },
+ {
+ name: 'Cases',
+ choices: [
+ {
+ name: '3D printed yourself, approximate $',
+ description: '',
+ amount: (set) => set,
+ cost: () => 2,
+ costAll: (set) => set * 2,
+ links: '\
+ You make your own cases! $2 per case (3D print material estimate). ',
+ },
+ {
+ name: 'Amazon cases - 6 pcs (Amazon)',
+ description: '1 pack for up to 6 trackers, 2 for 7+.',
+ amount: (set) => Math.ceil(set / 6),
+ cost: () => 7.49,
+ costAll: (set) => Math.ceil(set / 6) * 7.49,
+ links: '\
+ Amazon cases \
+ $7.49 per 6-pack. ',
+ },
+ {
+ name: 'Sourced elsewhere',
+ amount: () => 0,
+ cost: () => 0,
+ costAll: () => 0,
+ links: '',
+ },
+ ],
+ },
+ {
+ name: 'Straps',
+ choices: [
+ {
+ name: 'π’ VYRO VR Comfort Strap Bundle',
+ amount: () => 1,
+ cost: (set) => {
+ if (set <= 6) return 109;
+ if (set <= 8) return 114;
+ return 139;
+ },
+ costAll: (set) => {
+ if (set <= 6) return 109;
+ if (set <= 8) return 114;
+ if (set <= 10) return 139;
+ return 278;
+ },
+ links: '\
+ VYRO VR Comfort Strap Bundles \
+ \
+ Variants:\
+
\
+ Core: 1 Chest + 1 Hip + 2 Thigh + 2 Ankles + 3 Extension Brackets: $109 \
+ Advanced: Core + 2 extra basic straps for feet trackers: $114 \
+ Full Body: Core + 2 extra basic straps + 2 extra Comfort straps for arms: $139 \
+ ',
+ },
+ {
+ name: 'π‘ VYRO VR Silicone Straps',
+ amount: (set) => Math.ceil(set / 10),
+ cost: () => 27.46,
+ costAll: (set) => Math.ceil(set / 10) * 27.46,
+ links: '\
+ VYRO VR Silicone Straps \
+ 10-pack: 2x30cm + 4x35cm + 2x50cm + 2x110cm\
+ \
+ 30cm (~12"): ankles, arms \
+ 50cm (~20"): thighs, hip \
+ 110cm (~43"): chest, waist \
+ \
+ Add GoPro style chest strap to improve set. ',
+ },
+ {
+ name: 'π‘ Generic AliExpress straps - 6 pcs (AliExpress)',
+ description: '1 pack for fewer than 6 trackers, 2 for 6+.',
+ amount: (set) => set < 6 ? 1 : 2,
+ cost: () => 5,
+ costAll: (set) => (set < 6 ? 1 : 2) * 5 + 2.77,
+ links: '\
+ AliExpress straps \
+ $5 per 6-pack, plus $2.77 shipping. ',
+ },
+ {
+ name: 'π‘ Generic Amazon straps - 5 pcs (Amazon)',
+ description: '1 pack for under 5 trackers, 2 for 5+.',
+ amount: (set) => set < 5 ? 1 : 2,
+ cost: () => 9.00,
+ costAll: (set) => (set < 5 ? 1 : 2) * 9.00,
+ links: '\
+ Amazon straps \
+ $9 per 5-pack. ',
+ },
+ {
+ name: 'Sourced elsewhere',
+ defaultSelected: true,
+ amount: () => 0,
+ cost: () => 0,
+ costAll: () => 0,
+ links: '',
+ },
+ ],
+ },
+];
diff --git a/src/assets/js/diy-calculator/components-data.test.js b/src/assets/js/diy-calculator/components-data.test.js
new file mode 100644
index 000000000..df6cf86b4
--- /dev/null
+++ b/src/assets/js/diy-calculator/components-data.test.js
@@ -0,0 +1,375 @@
+import { describe, it, expect } from 'vitest';
+import { componentCategories, physicalTrackerCount, primaryTrackerCount, extensionCount } from './components-data.js';
+
+const SET = { LOWER: 5, CORE: 6, ENHANCED: 8, FULL: 10 };
+
+function cat(name) {
+ return componentCategories.find(c => c.name === name);
+}
+function choice(category, index) {
+ return cat(category).choices[index];
+}
+
+describe('primaryTrackerCount', () => {
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${5}
+ ${SET.CORE} | ${6}
+ ${SET.ENHANCED}| ${6}
+ ${SET.FULL} | ${8}
+ `('returns $expected for set=$set', ({ set, expected }) => {
+ expect(primaryTrackerCount(set)).toBe(expected);
+ });
+});
+
+describe('extensionCount', () => {
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${0}
+ ${SET.CORE} | ${0}
+ ${SET.ENHANCED}| ${2}
+ ${SET.FULL} | ${2}
+ `('returns $expected for set=$set', ({ set, expected }) => {
+ expect(extensionCount(set)).toBe(expected);
+ });
+});
+
+describe('physicalTrackerCount', () => {
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${SET.LOWER}
+ ${SET.CORE} | ${SET.CORE}
+ ${SET.ENHANCED}| ${SET.ENHANCED}
+ ${SET.FULL} | ${SET.FULL}
+ `('returns $expected for set=$set', ({ set, expected }) => {
+ expect(physicalTrackerCount(set)).toBe(expected);
+ });
+});
+
+describe('Microcontroller', () => {
+ const c = choice('Microcontroller', 0);
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${5}
+ ${SET.CORE} | ${6}
+ ${SET.ENHANCED}| ${6}
+ ${SET.FULL} | ${8}
+ `('$expected boards for $set IMUs', ({ set, expected }) => {
+ expect(c.amount(set)).toBe(expected);
+ });
+});
+
+describe('IMU', () => {
+ for (let i = 0; i < 3; i++) {
+ const c = choice('IMU', i);
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${SET.LOWER}
+ ${SET.CORE} | ${SET.CORE}
+ ${SET.ENHANCED}| ${SET.ENHANCED}
+ ${SET.FULL} | ${SET.FULL}
+ `(`${c.name}: $expected IMUs for $set trackers`, ({ set, expected }) => {
+ expect(c.amount(set)).toBe(expected);
+ });
+ }
+});
+
+describe('Batteries', () => {
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${5}
+ ${SET.CORE} | ${6}
+ ${SET.ENHANCED}| ${6}
+ ${SET.FULL} | ${8}
+ `('1800 mAh 804040: $expected batteries for $set trackers', ({ set, expected }) => {
+ expect(choice('Batteries', 0).amount(set)).toBe(expected);
+ });
+
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${1}
+ ${SET.CORE} | ${2}
+ ${SET.ENHANCED}| ${2}
+ ${SET.FULL} | ${2}
+ `('1200 mAh 5-pack: $expected packs for $set trackers', ({ set, expected }) => {
+ expect(choice('Batteries', 1).amount(set)).toBe(expected);
+ });
+
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${5}
+ ${SET.CORE} | ${6}
+ ${SET.ENHANCED}| ${6}
+ ${SET.FULL} | ${8}
+ `('Generic 18650: $expected for $set trackers', ({ set, expected }) => {
+ expect(choice('Batteries', 2).amount(set)).toBe(expected);
+ });
+
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${0}
+ ${SET.CORE} | ${0}
+ ${SET.ENHANCED}| ${0}
+ ${SET.FULL} | ${0}
+ `('Sourced elsewhere: $expected for $set trackers', ({ set, expected }) => {
+ expect(choice('Batteries', 3).amount(set)).toBe(expected);
+ });
+});
+
+describe('Charging Board', () => {
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${5}
+ ${SET.CORE} | ${6}
+ ${SET.ENHANCED}| ${6}
+ ${SET.FULL} | ${8}
+ `('TP4056 single: $expected boards for $set trackers', ({ set, expected }) => {
+ expect(choice('Charging Board', 0).amount(set)).toBe(expected);
+ });
+
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${1}
+ ${SET.CORE} | ${1}
+ ${SET.ENHANCED}| ${1}
+ ${SET.FULL} | ${1}
+ `('TP4056 10-pack: $expected pack for $set trackers', ({ set, expected }) => {
+ expect(choice('Charging Board', 1).amount(set)).toBe(expected);
+ });
+
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${0}
+ ${SET.CORE} | ${0}
+ ${SET.ENHANCED}| ${0}
+ ${SET.FULL} | ${0}
+ `('Sourced elsewhere: $expected for $set trackers', ({ set, expected }) => {
+ expect(choice('Charging Board', 2).amount(set)).toBe(expected);
+ });
+});
+
+describe('Power Switches', () => {
+ for (let i = 0; i < 2; i++) {
+ const c = choice('Power Switches', i);
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${1}
+ ${SET.CORE} | ${1}
+ ${SET.ENHANCED}| ${1}
+ ${SET.FULL} | ${1}
+ `(`${c.name}: $expected pack for $set trackers`, ({ set, expected }) => {
+ expect(c.amount(set)).toBe(expected);
+ });
+ }
+
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${0}
+ ${SET.CORE} | ${0}
+ ${SET.ENHANCED}| ${0}
+ ${SET.FULL} | ${0}
+ `('Sourced elsewhere: $expected for $set trackers', ({ set, expected }) => {
+ expect(choice('Power Switches', 2).amount(set)).toBe(expected);
+ });
+});
+
+describe('Diodes', () => {
+ for (let i = 0; i < 2; i++) {
+ const c = choice('Diodes', i);
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${1}
+ ${SET.CORE} | ${1}
+ ${SET.ENHANCED}| ${1}
+ ${SET.FULL} | ${1}
+ `(`${c.name}: $expected pack for $set trackers`, ({ set, expected }) => {
+ expect(c.amount(set)).toBe(expected);
+ });
+ }
+
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${0}
+ ${SET.CORE} | ${0}
+ ${SET.ENHANCED}| ${0}
+ ${SET.FULL} | ${0}
+ `('Sourced elsewhere: $expected for $set trackers', ({ set, expected }) => {
+ expect(choice('Diodes', 2).amount(set)).toBe(expected);
+ });
+});
+
+describe('Resistors', () => {
+ for (let i = 0; i < 2; i++) {
+ const c = choice('Resistors', i);
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${1}
+ ${SET.CORE} | ${1}
+ ${SET.ENHANCED}| ${1}
+ ${SET.FULL} | ${1}
+ `(`${c.name}: $expected pack for $set trackers`, ({ set, expected }) => {
+ expect(c.amount(set)).toBe(expected);
+ });
+ }
+
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${0}
+ ${SET.CORE} | ${0}
+ ${SET.ENHANCED}| ${0}
+ ${SET.FULL} | ${0}
+ `('Sourced elsewhere: $expected for $set trackers', ({ set, expected }) => {
+ expect(choice('Resistors', 2).amount(set)).toBe(expected);
+ });
+});
+
+describe('Carrier Board Options', () => {
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${10}
+ ${SET.CORE} | ${10}
+ ${SET.ENHANCED}| ${10}
+ ${SET.FULL} | ${10}
+ `('PCB from cases page: $expected PCBs for $set trackers', ({ set, expected }) => {
+ expect(choice('Carrier Board Options', 0).amount(set)).toBe(expected);
+ });
+
+ for (let i = 1; i <= 2; i++) {
+ const c = choice('Carrier Board Options', i);
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${1}
+ ${SET.CORE} | ${1}
+ ${SET.ENHANCED}| ${1}
+ ${SET.FULL} | ${1}
+ `(`${c.name}: $expected for $set trackers`, ({ set, expected }) => {
+ expect(c.amount(set)).toBe(expected);
+ });
+ }
+
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${0}
+ ${SET.CORE} | ${0}
+ ${SET.ENHANCED}| ${0}
+ ${SET.FULL} | ${0}
+ `('Sourced elsewhere: $expected for $set trackers', ({ set, expected }) => {
+ expect(choice('Carrier Board Options', 3).amount(set)).toBe(expected);
+ });
+});
+
+describe('Wiring for Extensions', () => {
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${0}
+ ${SET.CORE} | ${0}
+ ${SET.ENHANCED}| ${1}
+ ${SET.FULL} | ${1}
+ `('JST 5-pin 5 pcs: $expected packs for $set trackers', ({ set, expected }) => {
+ expect(choice('Wiring for Extensions', 0).amount(set)).toBe(expected);
+ });
+
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${0}
+ ${SET.CORE} | ${0}
+ ${SET.ENHANCED}| ${1}
+ ${SET.FULL} | ${1}
+ `('JST 5-pin 20 pcs: $expected packs for $set trackers', ({ set, expected }) => {
+ expect(choice('Wiring for Extensions', 1).amount(set)).toBe(expected);
+ });
+
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${0}
+ ${SET.CORE} | ${0}
+ ${SET.ENHANCED}| ${1}
+ ${SET.FULL} | ${1}
+ `('Deluxe Set: $expected packs for $set trackers', ({ set, expected }) => {
+ expect(choice('Wiring for Extensions', 2).amount(set)).toBe(expected);
+ });
+
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${0}
+ ${SET.CORE} | ${0}
+ ${SET.ENHANCED}| ${1}
+ ${SET.FULL} | ${1}
+ `('Full-Body Set: $expected packs for $set trackers', ({ set, expected }) => {
+ expect(choice('Wiring for Extensions', 3).amount(set)).toBe(expected);
+ });
+
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${0}
+ ${SET.CORE} | ${0}
+ ${SET.ENHANCED}| ${0}
+ ${SET.FULL} | ${0}
+ `('Sourced elsewhere: $expected for $set trackers', ({ set, expected }) => {
+ expect(choice('Wiring for Extensions', 4).amount(set)).toBe(expected);
+ });
+});
+
+describe('Cases', () => {
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${SET.LOWER}
+ ${SET.CORE} | ${SET.CORE}
+ ${SET.ENHANCED}| ${SET.ENHANCED}
+ ${SET.FULL} | ${SET.FULL}
+ `('3D printed: $expected cases for $set trackers', ({ set, expected }) => {
+ expect(choice('Cases', 0).amount(set)).toBe(expected);
+ });
+
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${1}
+ ${SET.CORE} | ${1}
+ ${SET.ENHANCED}| ${2}
+ ${SET.FULL} | ${2}
+ `('Amazon cases 6-pack: $expected packs for $set trackers', ({ set, expected }) => {
+ expect(choice('Cases', 1).amount(set)).toBe(expected);
+ });
+
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${0}
+ ${SET.CORE} | ${0}
+ ${SET.ENHANCED}| ${0}
+ ${SET.FULL} | ${0}
+ `('Sourced elsewhere: $expected for $set trackers', ({ set, expected }) => {
+ expect(choice('Cases', 2).amount(set)).toBe(expected);
+ });
+});
+
+describe('Straps', () => {
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${1}
+ ${SET.CORE} | ${2}
+ ${SET.ENHANCED}| ${2}
+ ${SET.FULL} | ${2}
+ `('AliExpress straps 6-pack: $expected packs for $set trackers', ({ set, expected }) => {
+ expect(choice('Straps', 0).amount(set)).toBe(expected);
+ });
+
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${2}
+ ${SET.CORE} | ${2}
+ ${SET.ENHANCED}| ${2}
+ ${SET.FULL} | ${2}
+ `('Amazon straps 5-pack: $expected packs for $set trackers', ({ set, expected }) => {
+ expect(choice('Straps', 1).amount(set)).toBe(expected);
+ });
+
+ it.each`
+ set | expected
+ ${SET.LOWER}| ${0}
+ ${SET.CORE} | ${0}
+ ${SET.ENHANCED}| ${0}
+ ${SET.FULL} | ${0}
+ `('Sourced elsewhere: $expected for $set trackers', ({ set, expected }) => {
+ expect(choice('Straps', 2).amount(set)).toBe(expected);
+ });
+});
diff --git a/src/assets/js/diy-calculator/index.js b/src/assets/js/diy-calculator/index.js
new file mode 100644
index 000000000..b32b5705d
--- /dev/null
+++ b/src/assets/js/diy-calculator/index.js
@@ -0,0 +1,7 @@
+import { initializeCalculator } from './ui.js';
+
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initializeCalculator);
+} else {
+ initializeCalculator();
+}
diff --git a/src/assets/js/diy-calculator/ui.js b/src/assets/js/diy-calculator/ui.js
new file mode 100644
index 000000000..b56fd48a5
--- /dev/null
+++ b/src/assets/js/diy-calculator/ui.js
@@ -0,0 +1,133 @@
+import { makeElement, formatCost, getSelectedSet, getSelectedChoiceIndex } from "./utils.js";
+import { componentCategories, physicalTrackerCount } from "./components-data.js";
+
+export let trackerAmount = 6;
+
+function sanitizeComponentName(name) {
+ const stripped = name.replace(/<[^>]*>/g, '');
+ return stripped.replace(/[!"#$%&'()*+,.\/:;?@[\\\]^`{|}~\s]/g, '_');
+}
+
+export function updateComponentRow(component, set) {
+ let choice;
+ if (component.choices.length == 1) {
+ choice = component.choices[0];
+ } else {
+ const selectedIndex = getSelectedChoiceIndex(component.radioGroup);
+ choice = component.choices[selectedIndex];
+ }
+
+ component.amount.innerHTML = choice.amount(set) != 0 ? choice.amount(set) : "";
+ component.cost.innerHTML = choice.cost(set) != 0 ? formatCost(choice.cost(set)) : "";
+ component.costAll.innerHTML = choice.costAll(set) != 0 ? formatCost(choice.costAll(set)) : "";
+
+ component.links.innerHTML = choice.links;
+ return choice.costAll(set);
+}
+
+export const updatePrices = () => {
+ trackerAmount = getSelectedSet();
+ const set = trackerAmount;
+
+ let total = 0;
+ componentCategories.forEach((component) => {
+ if (component.hideFor5Set) {
+ component.tr.style.visibility = (set == 5 ? "hidden" : "visible");
+ if (set == 5) return;
+ }
+ total += updateComponentRow(component, set);
+ });
+
+ document.getElementById("diy-total-value").innerHTML = formatCost(total);
+};
+
+function createRadioCard(component, choiceObj, index) {
+ const card = document.createElement("div");
+ card.className = "radio-card";
+ card.tabIndex = 0;
+ card.style.cursor = "pointer";
+
+ const radio = document.createElement("input");
+ radio.type = "radio";
+ radio.name = component.radioName;
+ radio.value = index;
+ if (choiceObj.defaultSelected || index === 0) radio.checked = true;
+ radio.addEventListener("change", updatePrices);
+ component.radioGroup.push(radio);
+
+ card.addEventListener("click", () => {
+ radio.checked = true;
+ updatePrices();
+ });
+
+ card.addEventListener("keydown", (e) => {
+ if (e.key === " " || e.key === "Enter") {
+ radio.checked = true;
+ updatePrices();
+ e.preventDefault();
+ }
+ });
+
+ const info = document.createElement("div");
+ info.className = "radio-card-info";
+
+ const nameEl = document.createElement("div");
+ nameEl.className = "radio-card-name";
+ nameEl.innerHTML = choiceObj.name;
+ info.appendChild(nameEl);
+
+ if (choiceObj.description) {
+ const descEl = document.createElement("div");
+ descEl.className = "radio-card-desc";
+ descEl.innerHTML = choiceObj.description;
+ info.appendChild(descEl);
+ }
+
+ const costEl = document.createElement("div");
+ costEl.className = "radio-card-cost";
+ const cost = choiceObj.costAll(trackerAmount);
+ costEl.innerHTML = cost != 0 ? `~${formatCost(cost)}` : "";
+
+ card.appendChild(radio);
+ card.appendChild(info);
+ card.appendChild(costEl);
+
+ return card;
+}
+
+function createRadioGroup(component, choiceCell) {
+ component.radioGroup = [];
+ component.radioName = "name-" + sanitizeComponentName(component.name);
+ component.choices.forEach((choiceObj, index) => {
+ choiceCell.appendChild(createRadioCard(component, choiceObj, index));
+ });
+}
+
+function createComponentRow(component, tbody) {
+ const tr = makeElement(tbody, "tr");
+ component.tr = tr;
+ makeElement(tr, "th", component.name);
+
+ const choice = makeElement(tr, "td");
+ if (component.choices.length == 1) {
+ choice.innerHTML = component.choices[0].name;
+ } else {
+ const group = document.createElement("div");
+ group.className = "radio-card-group";
+ choice.appendChild(group);
+ createRadioGroup(component, group);
+ }
+
+ component.amount = makeElement(tr, "td");
+ component.cost = makeElement(tr, "td");
+ component.costAll = makeElement(tr, "td");
+ component.links = makeElement(tr, "td");
+}
+
+export function initializeCalculator() {
+ const tbody = document.getElementById("diy-components");
+ componentCategories.forEach((component) => createComponentRow(component, tbody));
+
+ updatePrices();
+ document.querySelectorAll('input[name="diy-set"]').forEach((set) => set.addEventListener("change", updatePrices));
+}
diff --git a/src/assets/js/diy-calculator/utils.js b/src/assets/js/diy-calculator/utils.js
new file mode 100644
index 000000000..2008c5264
--- /dev/null
+++ b/src/assets/js/diy-calculator/utils.js
@@ -0,0 +1,19 @@
+export const makeElement = (parent, type, contents = "") => {
+ const el = document.createElement(type);
+ el.innerHTML = contents;
+ parent.appendChild(el);
+ return el;
+};
+
+export function formatCost(value) {
+ return "$" + value.toFixed(2).replace(/\.00$/, "");
+}
+
+export function getSelectedSet() {
+ return +document.querySelector("input[name=diy-set]:checked").value;
+}
+
+export function getSelectedChoiceIndex(radioGroup) {
+ const checkedRadio = radioGroup.find((radio) => radio.checked);
+ return checkedRadio ? checkedRadio.value : 0;
+}
diff --git a/src/assets/js/diy.js b/src/assets/js/diy.js
index 55e18ddaf..9826e97d6 100644
--- a/src/assets/js/diy.js
+++ b/src/assets/js/diy.js
@@ -19,77 +19,77 @@
'choices': [
//Prices are based on the seller "Simple Robot Store" for AliExpress links.
{
- 'name': 'ICM-45686',
+ 'name': 'π’ ICM-45686',
'amount': (set) => set,
'cost': 6.70,
'costAll': (set) => set * 6.70 + 6.70,
'links': 'SlimeVR Mumo Breakout Module V1 (ICM-45686 + QMC6309) .'
},
{
- 'name': 'LSM6DSV',
+ 'name': 'π’ LSM6DSV',
'amount': (set) => set,
'cost': 8.47,
'costAll': (set) => set * 8.47 + 5.58,
'links': 'Moffshop LSM6DSV .'
},
{
- 'name': 'LSM6DSR',
+ 'name': 'π’ LSM6DSR',
'amount': (set) => set,
'cost': 3.15,
'costAll': (set) => set * 3.35 + 6.70,
- 'links': 'Moffshop LSM6DSR + QMC6309 , experimental .'
+ 'links': 'AliExpress LSM6DSR , experimental .'
},
{
- 'name': 'BNO085',
+ 'name': 'π΄ BNO085',
'amount': (set) => set,
'cost': 11,
'costAll': (set) => set * 11 + 6,
'links': 'Adafruit BNO085 , Not recommended for new designs. '
},
{
- 'name': 'BMI160',
+ 'name': 'π« BMI160',
'amount': (set) => set,
'cost': 1.42,
'costAll': (set) => set * 1.42 + 2.67,
'links': 'AliExpress BMI160 , Not recommended for new designs. '
},
{
- 'name': 'ICM20948',
+ 'name': 'π΄ ICM20948',
'amount': (set) => set,
'cost': 17.40,
'costAll': (set) => set * 17.40 + 8.89,
'links': 'Mouser ICM20948 . The most commonly chosen options are either the Pimoroni or Adafruit ICM20948. Please note that any orders you place will be backordered , potentially meaning a wait of upwards of 3 months before shipping. Not recommended for new designs. '
},
{
- 'name': 'MPU9250',
+ 'name': 'π« MPU9250',
'amount': (set) => set,
'cost': 4.75,
'costAll': (set) => set * 4.75 + 2.73,
'links': 'AliExpress MPU9250 . Buyer beware: large amount of fakes. Not recommended for new designs. '
},
{
- 'name': 'MPU+QMC5883L',
+ 'name': 'π« MPU+QMC5883L',
'amount': (set) => set,
'cost': 1.04+1.23,
'costAll': (set) => (set * (1.04+1.23)) + 2.67,
'links': 'AliExpress MPU6050 and AliExpress QMC5883L . Performance should apprxomiately match an MPU9250, but please note that this is Experimental . Not recommended for new designs. '
},
{
- 'name': 'BNO055',
+ 'name': 'π΄ BNO055',
'amount': (set) => set,
'cost': 17,
'costAll': (set) => set * 17 + 2.73,
'links': 'AliExpress BNO055 . Not recommended for new designs. '
},
{
- 'name': 'MPU6500',
+ 'name': 'π« MPU6500',
'amount': (set) => set,
'cost': 0.95,
'costAll': (set) => set * 0.95 + 2.67,
'links': 'AliExpress MPU6500 . Not recommended for new designs. '
},
{
- 'name': 'MPU6050',
+ 'name': 'π« MPU6050',
'amount': (set) => set,
'cost': 1.04,
'costAll': (set) => set * 1.04 + 2.67,
@@ -105,7 +105,7 @@
'amount': () => tracker,
'cost': 3.66,
'costAll': () => tracker*3.19 + 5.33,
- 'links': 'AliExpress 804040, 5 pcs or AliExpress 804040, 10 pcs . Pricing is approximate.'
+ 'links': 'AliExpress π« unavailable or AliExpress π« unavailable .'
},
{
'name': '1200 mAh 903052 Li-Po - 5 pcs',
@@ -119,7 +119,7 @@
'amount': () => tracker,
'cost': 3 + 0.27,
'costAll': () => tracker * (3 + 0.27) + (1.89),
- 'links': 'AliExpress 18650 cell and AliExpress 18650 holder . Buyer beware: rated capacities are likely inaccurate.'
+ 'links': 'AliExpress 18650 cell and AliExpress (X4 Slot) . Buyer beware: rated capacities are likely inaccurate.'
},
{
'name': 'Sourced elsewhere',
@@ -138,7 +138,7 @@
'amount': () => tracker,
'cost': 0.36,
'costAll': () => tracker*0.36 + 2.07,
- 'links': 'AliExpress TP4056 '
+ 'links': 'AliExpress (18650 mini / type-c / micro) '
},
{
'name': 'TP4056 USB charging board - 10 pcs',
@@ -162,9 +162,9 @@
{
'name': 'SS22F32 switches - 10 pcs',
'amount': () => 1,
- 'cost': 2.36,
- 'costAll': () => 2.36,
- 'links': 'AliExpress SS22F32 switches '
+ 'cost': 1.97,
+ 'costAll': () => 1.97,
+ 'links': 'AliExpress '
},
{
'name': 'SS22F32 switches - 10 pcs (Alt)',
@@ -197,7 +197,7 @@
'amount': () => 1,
'cost': 0.62,
'costAll': () => 0.62 + 2.21,
- 'links': 'AliExpress 1N5817 diodes. Optional component for battery protection.'
+ 'links': 'AliExpress (1N5817) . Optional component for battery protection.'
},
{
'name': '1N5817 diodes - 100 pcs',
@@ -223,7 +223,7 @@
'amount': () => 1,
'cost': 1.96,
'costAll': () => 1.96 + 1.29,
- 'links': 'AliExpress 180K ohm resistors. Optional component for checking battery percentage.'
+ 'links': 'AliExpress (100pcs 180K ohms) . Optional component for checking battery percentage.'
},
{
'name': '180K ohm resistors - 100 pcs (Alt)',
@@ -256,7 +256,7 @@
'amount': () => 1,
'cost': 1.85,
'costAll': () => 1.85+1.68,
- 'links': 'AliExpress 24-26 AWG 5m '
+ 'links': 'AliExpress (22 AWG, 2m) '
},
{
'name': '26AWG Wire, No PCB (Not Recommended)',
@@ -316,7 +316,7 @@
'amount': () => tracker,
'cost': 0.65,
'costAll': () => (tracker * 0.65) + 3.01,
- 'links': 'AliExpress cases . Not guaranteed to fit, check your parts before ordering.'
+ 'links': 'AliExpress π« unavailable .'
},
{
'name': 'Amazon cases - 6 pcs',
@@ -338,14 +338,14 @@
'links': ''
},
{
- 'name': 'Generic AliExpress straps - 6 pcs',
+ 'name': 'π‘ Generic AliExpress straps - 6 pcs',
'amount': (set) => ((set < 6) ? 1 : 2),
'cost': 5,
'costAll': (set) => ((set < 6) ? 1 : 2) * 5 + 2.77,
'links': 'AliExpress straps , get some in different sizes?'
},
{
- 'name': 'Generic Amazon straps - 5 pcs',
+ 'name': 'π‘ Generic Amazon straps - 5 pcs',
'amount': (set) => ((set < 5) ? 1 : 2),
'cost': 9.00,
'costAll': (set) => ((set < 5) ? 1 : 2) * 9.00,
diff --git a/src/diy/components-guide.md b/src/diy/components-guide.md
index 8b843dd2d..7080bb7b8 100644
--- a/src/diy/components-guide.md
+++ b/src/diy/components-guide.md
@@ -1,6 +1,16 @@
# Components Guide
-The costs shown should be taken as a **rough approximation** due to prices changing over time. Prices are in USD.
+
+```admonish info
+- The costs shown should be taken as a **rough approximation** due to prices changing over time. Prices are in USD.
+- Prices last updated: 2026-06-05.
+- Links sourced by community.
+```
+
+## Table Of Contents
+
+- TOC
+ {:toc}
## Don't order yet!
This guide is meant to show rough price estimates for the components needed to build a set of SlimeVR trackers.
@@ -15,23 +25,64 @@ This limitation is due to the processing load required for handling multiple IMU
Official SlimeVR Tracker v1.2 hardware uses an SPI interface to overcome this limitation.
```
-**Number of trackers**
+### Select Number of Trackers
+
+Before you start, decide on [how many trackers you may need](../../../slimevr101.md#how-many-trackers-do-you-need).
+
+
+
+
+
+ π‘ Lower-Body Set
+ 5 Trackers — Casual VR users
+
+ Provides positional tracking for legs and spine. Limited tracking for foot orientation and lower spine bending.
+
+
+
+
+
+
+ π’ Core Set
+ 6 Trackers — Users who want better stability
+
+ Adds an extra spine tracker for improved stability, especially when sitting, lying down, or bending over.
+
+
+
+
+
+
+ π’ Enhanced Core Set
+ 6 Trackers, 2 Extensions — Users who sit or lie down often
+
+ Adds foot movement tracking for more expressive, emotive poses when seated or lying down.
+
+
+
+
+
+
+ π’ Full-Body Set
+ 8 Trackers, 2 Extensions — Dancers, role-players, immersive users
+
+ Enables independent elbow movement, providing more realistic upper-body motion and increased immersion in VR.
+
+
+
+
- Lower-Body Set - 5 Trackers |
- Core Set - 6 Trackers |
- Enhanced Core Set - 6 Trackers, 2 Extensions |
- Full-Body Set - 8 Trackers, 2 Extensions
+### Select Parts
----
Component
- Choice
+ Choice
Amount
- Cost per
- Cost with Shipping
+ Cost per one
+ Cost Total
Quick Link
@@ -40,7 +91,10 @@ Official SlimeVR Tracker v1.2 hardware uses an SPI interface to overcome this li
-**TOTAL COST**: ~$
+
+ TOTAL COST:
+ ~
+
**Please note**: JST connectors are an optional convenience if you want to be able to disconnect your extensions. If you plan on never disconnecting your extensions, you do not need JST connectors. Instead, hardwiring the extensions is recommended for durability.
@@ -54,10 +108,6 @@ The second most impactful choice will be where you choose to purchase your compo
While purchasing components, especially from AliExpress, it is also highly recommended to purchase one or two extra of each part in case they come dead on arrival or due to soldering mistakes. Keep in mind that AliExpress shipping times are quite long, which means replacements for faulty components may have a very long wait timeβso plan accordingly. Generally speaking, IMUs generally have the highest DOA (dead-on-arrival) rate. Wemos D1 Minis, TP4046 charging boards, and batteries are all fairly reliable with low DOA ratesβhowever it still may be worthwhile purchasing extras just in case.
-* TOC
-{:toc}
-
-
### Tools
You will need various tools to be able to put together a tracker. You may have these already, have a friend or a maker space nearby where you can borrow these:
@@ -153,16 +203,90 @@ Some parts are known to arrive DOA β including 1-2 spares in your order is rec
- IMU (Most notably MPU, BMI160)
*Created by Carl (), edited by calliepepper, nwbx01, Smeltie, and Amebun*
-
+