forgecad 0.10.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/assets/{AdminPage-DwYHz72L.js → AdminPage-DcCnj0qo.js} +1 -1
  2. package/dist/assets/{BenchmarkPage-a9_f-1US.js → BenchmarkPage-BVEpJSVk.js} +1 -1
  3. package/dist/assets/{BlogPage-DodHpvmf.js → BlogPage-DHaGP50_.js} +1 -1
  4. package/dist/assets/{DocsPage-B5LePEuj.js → DocsPage-CDoxHkz8.js} +33 -2
  5. package/dist/assets/{EditorApp-QXsAISLR.js → EditorApp-BJ0Dloyh.js} +174 -35
  6. package/dist/assets/{EmbedViewer-DdEHGUMU.js → EmbedViewer-CRKZbY0y.js} +2 -2
  7. package/dist/assets/{LandingPageProofDriven-yhhOodbf.js → LandingPageProofDriven-BxHkYRE7.js} +1 -1
  8. package/dist/assets/{LegalPage-5RbKRGYK.js → LegalPage-B-u6FrVv.js} +1 -1
  9. package/dist/assets/{PricingPage-E3Rma7aV.js → PricingPage-CzpZ6-Ce.js} +1 -1
  10. package/dist/assets/{SettingsPage-BJZcM97j.js → SettingsPage-CIZSSAd0.js} +1 -1
  11. package/dist/assets/{app-CE3sYcV7.css → app-CjsbDlb7.css} +143 -0
  12. package/dist/assets/{app-DSYrDg0V.js → app-DaTMg3nH.js} +612 -120
  13. package/dist/assets/cli/{render-ZMHR9HkV.js → render-DPf4AYJK.js} +38 -16
  14. package/dist/assets/{evalWorker-DbNs7Dkp.js → evalWorker-CjZZWRWW.js} +1428 -1038
  15. package/dist/assets/{jointPose-DO6mnXn_.js → jointPose-DzQOViQH.js} +1 -1
  16. package/dist/assets/{manifold-BGlQBBH9.js → manifold-BYlzU521.js} +1 -1
  17. package/dist/assets/{manifold-fy2MV7K1.js → manifold-DgXo0T5P.js} +2 -2
  18. package/dist/assets/{manifold-BU-tJwQh.js → manifold-K1SkarlQ.js} +1 -1
  19. package/dist/assets/{reportWorker-DO6hcQbh.js → reportWorker-B9nWwSrB.js} +1402 -1012
  20. package/dist/assets/{scalar-sampling-budget-o90NSNmF.js → scalar-sampling-budget-prBw_s8t.js} +2139 -1749
  21. package/dist/cli/render.html +1 -1
  22. package/dist/docs/index.html +2 -2
  23. package/dist/docs-raw/CLI.md +18 -3
  24. package/dist/docs-raw/generated/assembly.md +70 -5
  25. package/dist/docs-raw/generated/concepts.md +16 -2
  26. package/dist/docs-raw/generated/core.md +9 -2
  27. package/dist/docs-raw/generated/lib.md +1 -1
  28. package/dist/docs-raw/generated/output.md +14 -43
  29. package/dist/docs-raw/generated/runtime-names.md +4 -4
  30. package/dist/docs-raw/guides/simready-quickstart.md +171 -0
  31. package/dist/docs-raw/simulation-workflow.md +273 -0
  32. package/dist/index.html +2 -2
  33. package/dist/sitemap.xml +25 -13
  34. package/dist-cli/{check-compiler-JTVBITCR.js → check-compiler-II7NLPAB.js} +1 -1
  35. package/dist-cli/{check-query-propagation-3FFLSMVN.js → check-query-propagation-7462TR3R.js} +1 -1
  36. package/dist-cli/{chunk-OAN5T4XD.js → chunk-UWTJCGXF.js} +1455 -722
  37. package/dist-cli/forgecad.js +2994 -529
  38. package/dist-skill/CONTEXT.md +94 -55
  39. package/dist-skill/docs/API/core/concepts.md +1 -1
  40. package/dist-skill/docs/CLI.md +18 -3
  41. package/dist-skill/docs/generated/assembly.md +66 -5
  42. package/dist-skill/docs/generated/core.md +9 -2
  43. package/dist-skill/docs/generated/lib.md +1 -1
  44. package/dist-skill/docs/generated/output.md +14 -43
  45. package/dist-skill/docs/generated/runtime-names.md +4 -4
  46. package/examples/robotics/README.md +46 -0
  47. package/examples/robotics/scout-cam-rover-simready/README.md +119 -0
  48. package/examples/robotics/scout-cam-rover-simready/lib/dims.js +140 -0
  49. package/examples/robotics/scout-cam-rover-simready/main.forge.js +343 -0
  50. package/examples/robotics/scout-cam-rover-simready/parts/body.forge.js +304 -0
  51. package/examples/robotics/scout-cam-rover-simready/parts/chassis.forge.js +320 -0
  52. package/examples/robotics/scout-cam-rover-simready/parts/hardware.forge.js +21 -0
  53. package/examples/robotics/scout-cam-rover-simready/parts/turret.forge.js +70 -0
  54. package/examples/robotics/scout-cam-rover-simready/parts/wheel.forge.js +116 -0
  55. package/examples/robotics/simready-asset-crate.forge.js +79 -0
  56. package/examples/robotics/simready-diff-drive-rover.forge.js +141 -0
  57. package/examples/robotics/simready-parallel-gripper.forge.js +102 -0
  58. package/package.json +1 -1
@@ -0,0 +1,343 @@
1
+ // Scout Cam Rover — a two-wheel self-balancing camera robot, replicated from a
2
+ // reference render. Fully home-buildable: FDM-printed teal PLA shells, black PLA
3
+ // pods/turret, TPU tires, and cheap purchased electronics (2x N20 gearmotors,
4
+ // ESP32-CAM behind the front bezel, SG90 pan servo under the top turret,
5
+ // DRV8833 + MPU6050 + MP1584, 2x18650 pack high in the body — an inverted
6
+ // pendulum balances better with mass up high; firmware: any ESP32 balance-bot
7
+ // sketch, e.g. B-robot style PID on the MPU6050).
8
+ //
9
+ // World frame: Z up, ground z=0, axle along X at z=55, front faces -Y.
10
+ // Motion joints: drive_left / drive_right (wheel spin, mirrored axes; MuJoCo
11
+ // calibration confirms forward roll is drive_left=+θ with drive_right=−θ)
12
+ // and turret_pan (SG90, ±90°).
13
+
14
+ const wallP = param('wall_thickness', 2.4, { min: 2.0, max: 3.0, step: 0.1, unit: 'mm' });
15
+ const wheelODP = param('wheel_od', 110, { min: 96, max: 130, step: 1, unit: 'mm' });
16
+ const auditP = Param.bool('Audit collisions (slow)', false);
17
+
18
+ const dimsMod = require('./lib/dims.js');
19
+ const D = dimsMod.compute({ wall: wallP, wheelOD: wheelODP });
20
+
21
+ const paramsDown = { wall_thickness: wallP, wheel_od: wheelODP };
22
+ const wheelMod = require('./parts/wheel.forge.js', paramsDown);
23
+ const chassisMod = require('./parts/chassis.forge.js', paramsDown);
24
+ const bodyMod = require('./parts/body.forge.js', paramsDown);
25
+ const turretMod = require('./parts/turret.forge.js', paramsDown);
26
+ const hwMod = require('./parts/hardware.forge.js');
27
+
28
+ const simMaterials = {
29
+ plaTeal: Sim.material('FDM PLA teal', {
30
+ densityKgM3: 1240,
31
+ staticFriction: 0.42,
32
+ dynamicFriction: 0.32,
33
+ restitution: 0.05,
34
+ }),
35
+ plaBlack: Sim.material('FDM PLA black', {
36
+ densityKgM3: 1240,
37
+ staticFriction: 0.4,
38
+ dynamicFriction: 0.3,
39
+ restitution: 0.05,
40
+ }),
41
+ tpu: Sim.material('TPU 95A tire', {
42
+ densityKgM3: 1200,
43
+ staticFriction: 1.0,
44
+ dynamicFriction: 0.85,
45
+ restitution: 0.18,
46
+ }),
47
+ steel: Sim.material('steel hardware', {
48
+ densityKgM3: 7850,
49
+ staticFriction: 0.35,
50
+ dynamicFriction: 0.25,
51
+ restitution: 0.04,
52
+ }),
53
+ electronics: Sim.material('electronics assembly', {
54
+ densityKgM3: 1500,
55
+ staticFriction: 0.45,
56
+ dynamicFriction: 0.32,
57
+ restitution: 0.04,
58
+ }),
59
+ battery: Sim.material('18650 lithium cell', {
60
+ densityKgM3: 2600,
61
+ staticFriction: 0.35,
62
+ dynamicFriction: 0.25,
63
+ restitution: 0.03,
64
+ }),
65
+ };
66
+
67
+ const simBody = (massKg, material, options = {}) => Sim.body({
68
+ massKg,
69
+ material,
70
+ collider: options.collider ?? Sim.collider.convexHull(),
71
+ contacts: options.contacts,
72
+ });
73
+
74
+ const wheelContact = { tread: Sim.contact.wheelSurface('tread') };
75
+ const wheelRadiusMm = D.tireOD / 2;
76
+ const wheelSeparationMm = 2 * (D.wheelMouthX + D.rim.z0 + D.tireW / 2);
77
+
78
+ // ---- build all parts in their local frames ----------------------------------
79
+ const tub = chassisMod.make.buildTub(D);
80
+ const door = chassisMod.make.buildDoor(D);
81
+ const podL = chassisMod.make.buildPod(D);
82
+ const podR = chassisMod.make.buildPod(D);
83
+ const motorL = chassisMod.make.buildMotor(D);
84
+ const motorR = chassisMod.make.buildMotor(D);
85
+ const mods = chassisMod.make.buildBoards(D);
86
+ const rocker = chassisMod.make.buildSwitch(D);
87
+
88
+ const shell = bodyMod.make.buildShell(D);
89
+ const bezel = bodyMod.make.buildBezel(D);
90
+ const cowl = bodyMod.make.buildCowl(D);
91
+ const espParts = bodyMod.make.buildEsp(D);
92
+ const servoParts = bodyMod.make.buildServo(D);
93
+ const battHolder = bodyMod.make.buildBatteryHolder(D);
94
+ const cellA = bodyMod.make.buildCell(D, -1);
95
+ const cellB = bodyMod.make.buildCell(D, 1);
96
+
97
+ const wheelL = wheelMod.make.buildWheel(D);
98
+ const wheelR = wheelMod.make.buildWheel(D);
99
+ const turret = turretMod.make.buildTurret(D);
100
+
101
+ // ---- assembly ----------------------------------------------------------------
102
+ let rover = assembly('Scout Cam Rover')
103
+ .addPart('Chassis Tub', tub, {
104
+ transform: Transform.translation(0, 0, D.tub.z0),
105
+ metadata: { material: 'PLA teal', process: 'FDM', qty: 1 },
106
+ sim: simBody(0.11, simMaterials.plaTeal),
107
+ })
108
+ .addPart('Service Door', door, { metadata: { material: 'PLA teal', process: 'FDM' }, sim: simBody(0.018, simMaterials.plaTeal) })
109
+ .addPart('Motor Pod L', podL, { metadata: { material: 'PLA black', process: 'FDM' }, sim: simBody(0.022, simMaterials.plaBlack) })
110
+ .addPart('Motor Pod R', podR, { metadata: { material: 'PLA black', process: 'FDM' }, sim: simBody(0.022, simMaterials.plaBlack) })
111
+ .addPart('Gearmotor L', motorL, { metadata: { material: 'purchased', notes: 'N20 6V 150RPM' }, sim: simBody(0.012, simMaterials.steel, { collider: Sim.collider.boundingBox() }) })
112
+ .addPart('Gearmotor R', motorR, { metadata: { material: 'purchased', notes: 'N20 6V 150RPM' }, sim: simBody(0.012, simMaterials.steel, { collider: Sim.collider.boundingBox() }) })
113
+ .addPart('DRV8833', mods.drv, { metadata: { material: 'purchased' }, sim: simBody(0.004, simMaterials.electronics, { collider: Sim.collider.boundingBox() }) })
114
+ .addPart('MP1584 Buck', mods.buck, { metadata: { material: 'purchased' }, sim: simBody(0.006, simMaterials.electronics, { collider: Sim.collider.boundingBox() }) })
115
+ .addPart('MPU6050', mods.mpu, { metadata: { material: 'purchased' }, sim: simBody(0.003, simMaterials.electronics, { collider: Sim.collider.boundingBox() }) })
116
+ .addPart('Rocker Switch', rocker, { metadata: { material: 'purchased' }, sim: simBody(0.006, simMaterials.electronics, { collider: Sim.collider.boundingBox() }) })
117
+ .addPart('Body Shell', shell, { metadata: { material: 'PLA teal', process: 'FDM' }, sim: simBody(0.16, simMaterials.plaTeal) })
118
+ .addPart('Camera Bezel', bezel, { metadata: { material: 'PLA teal', process: 'FDM' }, sim: simBody(0.012, simMaterials.plaTeal) })
119
+ .addPart('Lens Cowl', cowl, { metadata: { material: 'PLA black', process: 'FDM' }, sim: simBody(0.008, simMaterials.plaBlack) })
120
+ .addPart('ESP32-CAM Board', espParts.board, { metadata: { material: 'purchased' }, sim: simBody(0.008, simMaterials.electronics, { collider: Sim.collider.boundingBox() }) })
121
+ .addPart('ESP32-CAM Optics', espParts.optics, { metadata: { material: 'purchased' }, sim: simBody(0.004, simMaterials.electronics, { collider: Sim.collider.boundingBox() }) })
122
+ .addPart('Servo Body', servoParts.body, { metadata: { material: 'purchased', notes: 'SG90 9g' }, sim: simBody(0.009, simMaterials.electronics, { collider: Sim.collider.boundingBox() }) })
123
+ .addPart('Servo Horn', servoParts.horn, { metadata: { material: 'purchased', notes: 'SG90 round horn' }, sim: simBody(0.002, simMaterials.plaBlack) })
124
+ .addPart('Battery Holder', battHolder, { metadata: { material: 'purchased', notes: '2x18650 holder' }, sim: simBody(0.025, simMaterials.electronics, { collider: Sim.collider.boundingBox() }) })
125
+ .addPart('18650 Cell A', cellA, { metadata: { material: 'purchased' }, sim: simBody(0.045, simMaterials.battery, { collider: Sim.collider.boundingBox() }) })
126
+ .addPart('18650 Cell B', cellB, { metadata: { material: 'purchased' }, sim: simBody(0.045, simMaterials.battery, { collider: Sim.collider.boundingBox() }) })
127
+ .addPart('Wheel Rim L', wheelL.rim, { metadata: { material: 'PLA teal', process: 'FDM' }, sim: simBody(0.028, simMaterials.plaTeal) })
128
+ .addPart('Tire L', wheelL.tire, { metadata: { material: 'TPU 95A', process: 'FDM' }, sim: simBody(0.038, simMaterials.tpu, { contacts: wheelContact }) })
129
+ .addPart('Drive Shaft L', wheelL.shaft, { metadata: { material: 'purchased', notes: 'N20 output shaft' }, sim: simBody(0.002, simMaterials.steel, { collider: Sim.collider.boundingBox() }) })
130
+ .addPart('Wheel Rim R', wheelR.rim, { metadata: { material: 'PLA teal', process: 'FDM' }, sim: simBody(0.028, simMaterials.plaTeal) })
131
+ .addPart('Tire R', wheelR.tire, { metadata: { material: 'TPU 95A', process: 'FDM' }, sim: simBody(0.038, simMaterials.tpu, { contacts: wheelContact }) })
132
+ .addPart('Drive Shaft R', wheelR.shaft, { metadata: { material: 'purchased', notes: 'N20 output shaft' }, sim: simBody(0.002, simMaterials.steel, { collider: Sim.collider.boundingBox() }) })
133
+ .addPart('Turret', turret, { metadata: { material: 'PLA black', process: 'FDM' }, sim: simBody(0.024, simMaterials.plaBlack) });
134
+
135
+ // individual screws (separate purchased parts, one bearing connector each)
136
+ const screwSpecs = [];
137
+ for (let i = 0; i < 4; i++) screwSpecs.push([`Door Screw ${i + 1}`, 8, 'Chassis Tub', `door_screw_${i}`]);
138
+ for (const sd of ['l', 'r']) for (let i = 0; i < 3; i++) {
139
+ screwSpecs.push([`Pod Screw ${sd.toUpperCase()}${i + 1}`, 8, 'Chassis Tub', `pod_screw_${sd}_${i}`]);
140
+ }
141
+ for (let i = 0; i < 4; i++) screwSpecs.push([`Shell Screw ${i + 1}`, 8, 'Chassis Tub', `shell_screw_${i}`]);
142
+ for (let i = 0; i < 4; i++) screwSpecs.push([`Bezel Screw ${i + 1}`, 6, 'Body Shell', `bezel_screw_${i}`]);
143
+ for (const [name, len, parent, conn] of screwSpecs) {
144
+ rover = rover.addPart(name, hwMod.make.screwM2(len), {
145
+ metadata: { material: 'steel', notes: `M2x${len}` },
146
+ sim: simBody(len >= 8 ? 0.0005 : 0.00035, simMaterials.steel, { collider: Sim.collider.boundingBox() }),
147
+ });
148
+ rover = rover.connect(`${parent}.${conn}`, `${name}.seat`, { as: `${conn}_joint`, type: 'fixed' });
149
+ }
150
+
151
+ // fixed mounts (each interface is a connector pair)
152
+ rover = rover
153
+ .connect('Chassis Tub.door_frame', 'Service Door.back', { as: 'door_mount', type: 'fixed' })
154
+ .connect('Chassis Tub.pod_seat_l', 'Motor Pod L.flange', { as: 'pod_l_mount', type: 'fixed' })
155
+ .connect('Chassis Tub.pod_seat_r', 'Motor Pod R.flange', { as: 'pod_r_mount', type: 'fixed' })
156
+ .connect('Chassis Tub.motor_seat_l', 'Gearmotor L.nose', { as: 'motor_l_mount', type: 'fixed' })
157
+ .connect('Chassis Tub.motor_seat_r', 'Gearmotor R.nose', { as: 'motor_r_mount', type: 'fixed' })
158
+ .connect('Chassis Tub.boards_drv', 'DRV8833.mount', { as: 'drv_mount', type: 'fixed' })
159
+ .connect('Chassis Tub.boards_buck', 'MP1584 Buck.mount', { as: 'buck_mount', type: 'fixed' })
160
+ .connect('Chassis Tub.boards_mpu', 'MPU6050.mount', { as: 'mpu_mount', type: 'fixed' })
161
+ .connect('Chassis Tub.switch_seat', 'Rocker Switch.mount', { as: 'switch_mount', type: 'fixed' })
162
+ .connect('Chassis Tub.deck', 'Body Shell.base', { as: 'shell_mount', type: 'fixed' })
163
+ .connect('Body Shell.cam_recess', 'Camera Bezel.back', { as: 'bezel_mount', type: 'fixed' })
164
+ .connect('Body Shell.cam_cowl', 'Lens Cowl.back', { as: 'cowl_mount', type: 'fixed' })
165
+ .connect('Body Shell.esp_cradle', 'ESP32-CAM Board.face', { as: 'esp_mount', type: 'fixed' })
166
+ .connect('Body Shell.esp_optics', 'ESP32-CAM Optics.face', { as: 'esp_optics_mount', type: 'fixed' })
167
+ .connect('Body Shell.servo_mount', 'Servo Body.tabs', { as: 'servo_mount', type: 'fixed' })
168
+ .connect('Body Shell.servo_horn_mount', 'Servo Horn.tabs', { as: 'servo_horn_mount', type: 'fixed' })
169
+ .connect('Body Shell.batt_mount', 'Battery Holder.back', { as: 'batt_mount', type: 'fixed' })
170
+ .connect('Body Shell.cell_a', '18650 Cell A.back', { as: 'cell_a_mount', type: 'fixed' })
171
+ .connect('Body Shell.cell_b', '18650 Cell B.back', { as: 'cell_b_mount', type: 'fixed' });
172
+
173
+ // motion joints; tires and shafts are fixed children of the spinning rims
174
+ rover = rover
175
+ .connect('Gearmotor L.shaft_tip', 'Wheel Rim L.bore', {
176
+ as: 'drive_left', min: -720, max: 720, default: 0, unit: '°',
177
+ drive: Sim.drive.velocity({ maxTorqueNm: 0.08, maxSpeedRpm: 150, damping: 0.015, friction: 0.006 }),
178
+ })
179
+ .connect('Gearmotor R.shaft_tip', 'Wheel Rim R.bore', {
180
+ as: 'drive_right', min: -720, max: 720, default: 0, unit: '°',
181
+ drive: Sim.drive.velocity({ maxTorqueNm: 0.08, maxSpeedRpm: 150, damping: 0.015, friction: 0.006 }),
182
+ })
183
+ .connect('Wheel Rim L.tire_seat', 'Tire L.seat', { as: 'tire_l_fit', type: 'fixed' })
184
+ .connect('Wheel Rim L.shaft_seat', 'Drive Shaft L.seat', { as: 'shaft_l_fit', type: 'fixed' })
185
+ .connect('Wheel Rim R.tire_seat', 'Tire R.seat', { as: 'tire_r_fit', type: 'fixed' })
186
+ .connect('Wheel Rim R.shaft_seat', 'Drive Shaft R.seat', { as: 'shaft_r_fit', type: 'fixed' })
187
+ .connect('Servo Horn.spline', 'Turret.hub', {
188
+ as: 'turret_pan', min: -90, max: 90, default: 0, unit: '°',
189
+ drive: Sim.drive.velocity({ maxTorqueNm: 0.16, maxSpeedRpm: 60, damping: 0.04, friction: 0.01 }),
190
+ });
191
+
192
+ rover = rover.withSimulation({
193
+ rootPart: 'Chassis Tub',
194
+ profile: Sim.profile.robotBodyRunnable(),
195
+ controllers: [
196
+ Sim.controller.diffDrive({
197
+ leftJoints: ['drive_left'],
198
+ rightJoints: ['drive_right'],
199
+ wheelRadiusMm,
200
+ wheelSeparationMm,
201
+ }),
202
+ ],
203
+ });
204
+
205
+ // ---- animations ---------------------------------------------------------------
206
+ // forward roll = drive_left +θ, drive_right −θ (mirrored revolute axes)
207
+ rover = rover
208
+ .addAnimation('Drive & scan', {
209
+ duration: 9, loop: true, continuous: true, default: true,
210
+ keyframes: [
211
+ { values: { drive_left: 0, drive_right: 0, turret_pan: 0 } },
212
+ { values: { drive_left: 240, drive_right: -240, turret_pan: 55 } },
213
+ { values: { drive_left: 480, drive_right: -480, turret_pan: -55 } },
214
+ { values: { drive_left: 720, drive_right: -720, turret_pan: 0 } },
215
+ ],
216
+ })
217
+ .addAnimation('Spin in place', {
218
+ duration: 6, loop: true, continuous: true,
219
+ keyframes: [
220
+ { values: { drive_left: 0, drive_right: 0, turret_pan: 0 } },
221
+ { values: { drive_left: -360, drive_right: -360, turret_pan: 45 } },
222
+ { values: { drive_left: -720, drive_right: -720, turret_pan: -45 } },
223
+ { values: { drive_left: -720, drive_right: -720, turret_pan: 0 } },
224
+ ],
225
+ })
226
+ .addAnimation('Camera sweep', {
227
+ duration: 4, loop: true, continuous: true,
228
+ keyframes: [
229
+ { values: { turret_pan: 0 } },
230
+ { values: { turret_pan: 88 } },
231
+ { values: { turret_pan: -88 } },
232
+ { values: { turret_pan: 0 } },
233
+ ],
234
+ });
235
+
236
+ // ---- pose verification ---------------------------------------------------------
237
+ const poses = [
238
+ { drive_left: 0, drive_right: 0, turret_pan: 0 },
239
+ { drive_left: -45, drive_right: 45, turret_pan: 60 },
240
+ { drive_left: -137, drive_right: 290, turret_pan: -90 },
241
+ { drive_left: 720, drive_right: -720, turret_pan: 90 },
242
+ ];
243
+ for (const pose of poses) {
244
+ const s = rover.solve(pose);
245
+ const tag = `dl=${pose.drive_left} dr=${pose.drive_right} pan=${pose.turret_pan}`;
246
+ verify.that(`solve converges (${tag})`, () => s.warnings().length === 0,
247
+ `solver warnings: ${s.warnings().join('; ')}`);
248
+ verify.clearanceBetween(`wheel R clears pod R (${tag})`, s.getPart('Wheel Rim R'), s.getPart('Motor Pod R'), 0.4, 80);
249
+ verify.clearanceBetween(`wheel L clears tub (${tag})`, s.getPart('Wheel Rim L'), s.getPart('Chassis Tub'), 0.4, 80);
250
+ verify.clearanceBetween(`tire R clears body shell (${tag})`, s.getPart('Tire R'), s.getPart('Body Shell'), 2.0, 80);
251
+ verify.clearanceBetween(`turret clears shell neck (${tag})`, s.getPart('Turret'), s.getPart('Body Shell'), 0.4, 80);
252
+ verify.clearanceBetween(`turret clears servo body (${tag})`, s.getPart('Turret'), s.getPart('Servo Body'), 0.4, 80);
253
+ }
254
+
255
+ const sDefault = rover.solve({});
256
+ const gDefault = sDefault.toGroup();
257
+ verify.connectorDistance('wheel L bore on motor L shaft', gDefault, 'Wheel Rim L.bore', 'Gearmotor L.shaft_tip', 0, 0.01);
258
+ verify.connectorDistance('wheel R bore on motor R shaft', gDefault, 'Wheel Rim R.bore', 'Gearmotor R.shaft_tip', 0, 0.01);
259
+ verify.connectorDistance('turret hub on servo spline', gDefault, 'Turret.hub', 'Servo Horn.spline', 0, 0.01);
260
+ verify.connectorDistance('shell base on tub deck', gDefault, 'Body Shell.base', 'Chassis Tub.deck', 0, 0.01);
261
+
262
+ // ground contact: tire bottoms at z=0
263
+ const wheelBB = sDefault.getPart('Tire L').boundingBox();
264
+ verify.equal('left tire touches the ground', wheelBB.min[2], 0, 0.05);
265
+ verify.equal('wheel OD as designed', wheelBB.max[2] - wheelBB.min[2], wheelODP, 0.1);
266
+
267
+ // full pairwise audit (slow) — run with --param "Audit collisions (slow)=1"
268
+ if (auditP) {
269
+ for (const pose of poses) {
270
+ const s = rover.solve(pose);
271
+ const findings = s.collisionReport({ minOverlapVolume: 0.5 });
272
+ verify.that(`no collisions at dl=${pose.drive_left} pan=${pose.turret_pan}`,
273
+ () => findings.length === 0,
274
+ findings.map(f => `${f.partA ?? f.a} vs ${f.partB ?? f.b}: ${JSON.stringify(f)}`).join(' | ').slice(0, 400));
275
+ }
276
+ }
277
+
278
+ verify.physicalComponentCount('one jointed assembly', 1);
279
+
280
+ // ---- BOM ------------------------------------------------------------------------
281
+ bom(1, 'chassis tub', { material: 'PLA teal', process: 'FDM printed' });
282
+ bom(1, 'service door', { material: 'PLA teal', process: 'FDM printed' });
283
+ bom(1, 'body shell', { material: 'PLA teal', process: 'FDM printed' });
284
+ bom(1, 'camera bezel', { material: 'PLA teal', process: 'FDM printed' });
285
+ bom(2, 'wheel rim', { material: 'PLA teal', process: 'FDM printed' });
286
+ bom(2, 'motor pod', { material: 'PLA black', process: 'FDM printed' });
287
+ bom(1, 'lens cowl', { material: 'PLA black', process: 'FDM printed' });
288
+ bom(1, 'pan turret', { material: 'PLA black', process: 'FDM printed' });
289
+ bom(2, 'tire', { material: 'TPU 95A gray', process: 'FDM printed', notes: 'print ID 0.5mm under rim OD for stretch fit' });
290
+ bom(2, 'N20 micro gearmotor 6V 150RPM, 3mm D-shaft', { notes: '~$3 ea' });
291
+ bom(1, 'ESP32-CAM (AI-Thinker) + OV2640', { notes: 'camera + wifi + controller, ~$9' });
292
+ bom(1, 'SG90 micro servo + round horn', { notes: 'turret pan, ~$2.5' });
293
+ bom(1, 'DRV8833 dual H-bridge module', { notes: '~$2' });
294
+ bom(1, 'MPU6050 IMU module', { notes: 'balance sensing, ~$2' });
295
+ bom(1, 'MP1584EN 5V buck module', { notes: '~$1.5' });
296
+ bom(1, 'KCD11 mini rocker switch', { notes: '~$0.5' });
297
+ bom(1, '2x18650 battery holder (wire leads)', { notes: '~$1.5' });
298
+ bom(2, '18650 Li-ion cell, flat top', { notes: 'charge externally' });
299
+ bom(14, 'M2x8 self-tapping screw', { notes: 'door 4, pods 6, shell 4' });
300
+ bom(4, 'M2x6 self-tapping screw, bezel set');
301
+ bom(2, 'M2x6 screw, servo tabs' );
302
+ bom(2, 'M2x6 screw, battery holder');
303
+ bom(1, 'M2x10 screw, turret-to-horn retention');
304
+ bom(1, 'hookup wire + heat-shrink set', { notes: 'motor, battery, servo runs' });
305
+
306
+ // ---- dimension annotations -------------------------------------------------------
307
+ const trackOuter = D.wheelMouthX + D.rim.z0 + D.tireW;
308
+ dim([-trackOuter, 0, D.axleZ], [trackOuter, 0, D.axleZ], { label: 'Overall width', offset: 30 });
309
+ dim([0, -D.tireOD / 2, 0], [0, -D.tireOD / 2, D.tireOD], { label: 'Wheel OD', offset: 20 });
310
+ dim([0, 60, 0], [0, 60, D.tub.z0 + D.tub.h + D.body.h + D.neck.h + D.turret.baseH + D.turret.barrelH + D.turret.capH + D.turret.gapAboveNeck],
311
+ { label: 'Overall height', offset: 24 });
312
+ dim([-D.body.w / 2, -4, D.tub.z0 + D.tub.h + 2], [D.body.w / 2, -4, D.tub.z0 + D.tub.h + 2], { label: 'Body width', offset: 16 });
313
+ dim([-D.tub.w / 2, 0, D.tub.z0], [D.tub.w / 2, 0, D.tub.z0], { label: 'Tub width', offset: 12 });
314
+ dim([0, 0, 0], [0, 0, D.axleZ], { label: 'Axle height', offset: 18 });
315
+
316
+ // ---- scene -----------------------------------------------------------------------
317
+ scene({
318
+ background: { top: '#c3ccd7', bottom: '#566474' },
319
+ camera: { position: [320, -380, 300], target: [0, 0, 100], fov: 38 },
320
+ environment: { preset: 'studio', intensity: 0.2, background: false },
321
+ lights: [
322
+ { type: 'ambient', color: '#efe7dc', intensity: 0.16 },
323
+ { type: 'directional', position: [260, -320, 420], color: '#ffe2bf', intensity: 2.8, castShadow: true },
324
+ { type: 'directional', position: [-260, 210, 220], color: '#d4e6fb', intensity: 0.85 },
325
+ { type: 'hemisphere', skyColor: '#c7d3df', groundColor: '#495463', intensity: 0.15 },
326
+ ],
327
+ ground: { visible: true, color: '#3c4654', height: 0, receiveShadow: true },
328
+ postProcessing: {
329
+ bloom: { intensity: 0.04, threshold: 0.94, radius: 0.28 },
330
+ vignette: { darkness: 0.4, offset: 0.32 },
331
+ toneMappingExposure: 1.1,
332
+ },
333
+ views: {
334
+ hero: { camera: { position: [320, -380, 300], target: [0, 0, 100], up: [0, 0, 1], fov: 38 } },
335
+ front: { camera: { position: [0, -520, 130], target: [0, 0, 105], up: [0, 0, 1], fov: 32 } },
336
+ side: { camera: { position: [520, 0, 130], target: [0, 0, 105], up: [0, 0, 1], fov: 32 } },
337
+ rear: { camera: { position: [-300, 380, 280], target: [0, 0, 100], up: [0, 0, 1], fov: 38 } },
338
+ top: { camera: { position: [10, -40, 560], target: [0, 0, 80], up: [0, 1, 0], fov: 32 } },
339
+ underside: { camera: { position: [180, -260, -180], target: [0, 0, 70], up: [0, 0, 1], fov: 40 } },
340
+ },
341
+ });
342
+
343
+ return rover;
@@ -0,0 +1,304 @@
1
+ // Upper body of the scout cam rover: printed shell (rounded cube + neck + vents
2
+ // + camera recess + corner plugs + servo bridge), camera bezel + black lens cowl,
3
+ // ESP32-CAM module, SG90 pan servo, and the 2x18650 battery holder.
4
+ // Shell-local frame: centered on XY, base z=0 (sits on the tub deck), front = -Y.
5
+
6
+ const wallP = param('wall_thickness', 2.4, { min: 2.0, max: 3.0, step: 0.1, unit: 'mm' });
7
+ const wheelODP = param('wheel_od', 110, { min: 96, max: 130, step: 1, unit: 'mm' });
8
+
9
+ const dimsMod = require('../lib/dims.js');
10
+
11
+ const COL_TEAL = '#2fb3b8';
12
+ const COL_BLACK = '#1f2023';
13
+ const COL_SCREW = '#3a3d42';
14
+
15
+ function tealMat(s) { return s.color(COL_TEAL).material({ metalness: 0.05, roughness: 0.45 }); }
16
+
17
+ // ----------------------------------------------------------------- shell -----
18
+ function buildShell(D) {
19
+ const B = D.body, N = D.neck, C = D.cam, V = D.vent, S = D.servo, BR = D.bridge;
20
+ const hw = B.w / 2, hd = B.d / 2;
21
+ const w = D.wall;
22
+
23
+ let sh = roundedRect(B.w, B.d, B.cornerR).extrude(B.h).shell(w, { openFaces: ['bottom'] });
24
+
25
+ // ---- neck + ring on top (one printed piece with the shell)
26
+ let neckSolid = cylinder(N.h + 0.3, N.od / 2).translate(0, 0, B.h - 0.3)
27
+ .add(cylinder(N.ringH, N.ringOD / 2).translate(0, 0, B.h + N.h - N.ringH));
28
+ neckSolid = neckSolid.subtract(cylinder(N.h + 1, N.boreD / 2).translate(0, 0, B.h - 0.1));
29
+ sh = sh.add(neckSolid);
30
+ // top-wall opening under the neck: bore minus a bridge bar (servo hangs from it)
31
+ const topHole = circle2d(N.boreD / 2 - 2).subtract(rect(N.boreD + 2, BR.w))
32
+ .add(circle2d(BR.holeD / 2)); // central pass-through for the servo tower/spline
33
+ sh = sh.subtract(topHole.extrude(w + 2).translate(0, 0, B.h - w - 1));
34
+
35
+ // ---- camera recess, through-hole, bezel pilots (front face, -Y) — one batched cut
36
+ const fw = -hd; // front outer face
37
+ const camCuts = [
38
+ cylinder(C.recessDepth + 0.1, C.recessD / 2).pointAlong([0, -1, 0]).translate(0, fw + C.recessDepth, C.zc),
39
+ cylinder(w + 2, C.holeD / 2).pointAlong([0, -1, 0]).translate(0, fw + w + 1, C.zc),
40
+ ];
41
+ for (const pt of circularLayout(C.screwN, C.screwR, { startDeg: C.screwStartDeg })) {
42
+ camCuts.push(cylinder(6.2, C.pilotD / 2).pointAlong([0, 1, 0])
43
+ .translate(pt.x, fw + C.recessDepth - 0.1, C.zc + pt.y));
44
+ }
45
+ sh = sh.subtract(...camCuts);
46
+
47
+ // ---- ESP32-CAM slide rails on the inner front wall
48
+ const innerF = fw + w; // inner front wall face
49
+ const pcbFrontY = innerF + 2.7; // PCB front face plane (barrel tip flush with cowl face)
50
+ for (const sx of [-1, 1]) {
51
+ let rail = box(3.4, 6.4, 38).translate(sx * 15.3, innerF + 6.4 / 2 - 0.3, 24);
52
+ rail = rail.subtract(
53
+ box(2.2, 2.0, 40).translate(sx * (13.4 + 1.1), pcbFrontY + 1.0, 28)
54
+ );
55
+ sh = sh.add(rail);
56
+ }
57
+
58
+ // ---- side vent bands: skewed recessed panel + 5 through slots (both faces)
59
+ const slope = V.skewDZ / V.bandW;
60
+ const bandPts = [
61
+ [V.yc - V.bandW / 2, V.zc - V.bandH / 2],
62
+ [V.yc + V.bandW / 2, V.zc - V.bandH / 2 + V.skewDZ],
63
+ [V.yc + V.bandW / 2, V.zc + V.bandH / 2],
64
+ [V.yc - V.bandW / 2, V.zc + V.bandH / 2 - V.skewDZ],
65
+ ];
66
+ // rotate([1,1,1],120) maps sketch X -> world Y, sketch Y -> world Z, extrusion -> world X
67
+ const recessCut = polygon(bandPts).extrude(V.recessDepth + 0.4)
68
+ .rotate([1, 1, 1], 120).translate(hw - V.recessDepth, 0, 0);
69
+ let slotSk = null;
70
+ for (let i = 0; i < V.n; i++) {
71
+ const yi = V.yc + (i - (V.n - 1) / 2) * V.pitch;
72
+ const zi = V.zc + (yi - V.yc) * slope;
73
+ const one = slot(V.slotL, V.slotW).rotate(90).translate(yi, zi);
74
+ slotSk = slotSk ? slotSk.add(one) : one;
75
+ }
76
+ const slotCut = slotSk.extrude(w + 3).rotate([1, 1, 1], 120).translate(hw - w - 1.5, 0, 0);
77
+ sh = sh.subtract(recessCut, slotCut,
78
+ recessCut.mirrorThrough([0, 0, 0], [1, 0, 0]), slotCut.mirrorThrough([0, 0, 0], [1, 0, 0]));
79
+
80
+ // ---- corner plugs (drop into the tub posts) + bridge ribs to the side walls
81
+ // tub posts are at tub-centered (±26.6, ±27.6); shell is offset yOff toward the front
82
+ const T = D.tub;
83
+ const pcx = T.w / 2 - T.wall - T.postInset, pcy = T.d / 2 - T.wall - T.postInset;
84
+ for (const sx of [-1, 1]) for (const sy of [-1, 1]) {
85
+ const px = sx * pcx, py = sy * pcy - B.yOff;
86
+ let plug = box(B.plugSq, B.plugSq, B.plugL + 6).translate(px, py, -B.plugL);
87
+ // pilot for the horizontal shell screw (world z = tub.sideScrewZ)
88
+ const pz = T.sideScrewZ - T.h; // shell-local
89
+ plug = plug.subtract(cylinder(5.7, 0.85).pointAlong([-sx, 0, 0])
90
+ .translate(sx * (B.plugSq / 2 + pcx + 0.1), py, pz));
91
+ const ribLen = (B.w / 2 - w + 0.4) - (pcx + B.plugSq / 2);
92
+ const rib = box(ribLen, B.plugSq, 6)
93
+ .translate(sx * (pcx + B.plugSq / 2 + ribLen / 2), py, 0);
94
+ sh = sh.add(plug, rib);
95
+ }
96
+
97
+ // ---- servo bosses hanging from the bridge bar
98
+ for (const hx of [S.bodyXc + S.tabHoleXa, S.bodyXc + S.tabHoleXb]) {
99
+ let boss = box(4, S.bossSq, B.h - w + 0.4 - S.tabPlaneZ)
100
+ .translate(hx, 0, S.tabPlaneZ);
101
+ boss = boss.subtract(cylinder(5.2, 0.85).translate(hx, 0, S.tabPlaneZ - 0.1));
102
+ sh = sh.add(boss);
103
+ }
104
+
105
+ // ---- battery-holder standoff bosses + pilots (rear inner wall is curved at
106
+ // the holder's corners, so the holder mounts 2.4mm off the wall on two bosses)
107
+ const bi = hd - w; // rear inner face at the wall centerline
108
+ const battBack = bi - 2.4;
109
+ for (const sx of [-1, 1]) {
110
+ sh = sh.add(cylinder(2.7, 3).pointAlong([0, -1, 0])
111
+ .translate(sx * D.batt.mountHoleSpan / 2, bi + 0.3, D.batt.zc));
112
+ }
113
+ sh = sh.subtract(
114
+ cylinder(4.5, 0.85).pointAlong([0, 1, 0]).translate(-D.batt.mountHoleSpan / 2, battBack - 0.1, D.batt.zc),
115
+ cylinder(4.5, 0.85).pointAlong([0, 1, 0]).translate(D.batt.mountHoleSpan / 2, battBack - 0.1, D.batt.zc),
116
+ );
117
+
118
+ const conns = {
119
+ base: connector('mount', { origin: [0, -B.yOff, 0], axis: [0, 0, -1], up: [0, 1, 0], kind: 'fixed' }),
120
+ cam_recess: connector('mount', { origin: [0, fw + C.recessDepth, C.zc], axis: [0, -1, 0], up: [0, 0, 1], kind: 'fixed' }),
121
+ cam_cowl: connector('mount', { origin: [0, fw + C.recessDepth, C.zc], axis: [0, -1, 0], up: [0, 0, 1], kind: 'fixed' }),
122
+ esp_cradle: connector('mount', { origin: [0, pcbFrontY, C.zc], axis: [0, -1, 0], up: [0, 0, 1], kind: 'fixed' }),
123
+ esp_optics: connector('mount', { origin: [0, pcbFrontY, C.zc], axis: [0, -1, 0], up: [0, 0, 1], kind: 'fixed' }),
124
+ servo_mount: connector('mount', { origin: [0, 0, S.tabPlaneZ], axis: [0, 0, -1], up: [0, 1, 0], kind: 'fixed' }),
125
+ servo_horn_mount: connector('mount', { origin: [0, 0, S.tabPlaneZ], axis: [0, 0, -1], up: [0, 1, 0], kind: 'fixed' }),
126
+ batt_mount: connector('mount', { origin: [0, battBack, D.batt.zc], axis: [0, -1, 0], up: [0, 0, 1], kind: 'fixed' }),
127
+ cell_a: connector('mount', { origin: [0, battBack, D.batt.zc], axis: [0, -1, 0], up: [0, 0, 1], kind: 'fixed' }),
128
+ cell_b: connector('mount', { origin: [0, battBack, D.batt.zc], axis: [0, -1, 0], up: [0, 0, 1], kind: 'fixed' }),
129
+ };
130
+ // bezel screws bear on the bezel front face (proud of the recess)
131
+ const bezelFaceY = fw + C.recessDepth - C.bezelT;
132
+ let bi2 = 0;
133
+ for (const pt of circularLayout(C.screwN, C.screwR, { startDeg: C.screwStartDeg })) {
134
+ conns[`bezel_screw_${bi2++}`] = connector('mount', {
135
+ origin: [pt.x, bezelFaceY, C.zc + pt.y], axis: [0, -1, 0], up: [0, 0, 1], kind: 'fixed',
136
+ });
137
+ }
138
+ return tealMat(sh).withConnectors(conns);
139
+ }
140
+
141
+ // ----------------------------------------------------------------- bezel -----
142
+ function buildBezel(D) {
143
+ const C = D.cam;
144
+ let bz = cylinder(C.bezelT, C.bezelOD / 2).subtract(cylinder(C.bezelT + 1, C.bezelID / 2).translate(0, 0, -0.5));
145
+ for (const pt of circularLayout(C.screwN, C.screwR, { startDeg: C.screwStartDeg })) {
146
+ bz = bz.subtract(cylinder(C.bezelT + 1, 1.0).translate(pt.x, pt.y, -0.5));
147
+ }
148
+ return tealMat(bz).withConnectors({
149
+ back: connector('mount', { origin: [0, 0, 0], axis: [0, 0, -1], up: [0, 1, 0], kind: 'fixed' }),
150
+ });
151
+ }
152
+
153
+ function buildCowl(D) {
154
+ const C = D.cam;
155
+ let cw = cylinder(C.cowlT, C.cowlD / 2, C.cowlD / 2 - 1.4);
156
+ cw = cw.subtract(cylinder(C.cowlT + 1, C.apertureD / 2).translate(0, 0, -0.5));
157
+ return cw.color(COL_BLACK).material({ metalness: 0.1, roughness: 0.35, clearcoat: 0.5 })
158
+ .withConnectors({
159
+ back: connector('mount', { origin: [0, 0, 0], axis: [0, 0, -1], up: [0, 1, 0], kind: 'fixed' }),
160
+ });
161
+ }
162
+
163
+ // -------------------------------------------------------------- ESP32-CAM ----
164
+ function buildEsp(D) {
165
+ const E = D.esp;
166
+ // local: z=0 at the PCB front face, lens barrel +Z. union() keeps only the
167
+ // first operand's color, so board and optics are separate shapes in a group.
168
+ const pcb = box(E.pcbW, E.pcbH, E.pcbT).translate(0, 0, -E.pcbT);
169
+ const shield = box(18, 17, E.compT).translate(0, -8, -E.pcbT - E.compT);
170
+ const camChip = box(9, 9, 1.4).translate(0, 0, -E.pcbT - 1.4);
171
+ const board = pcb.add(shield, camChip).color('#1a7a3a').material({ metalness: 0.3, roughness: 0.5 });
172
+ const barrel = cylinder(E.barrelL, E.barrelD / 2);
173
+ const lens = cylinder(1.3, 2.6).translate(0, 0, E.barrelL - 0.3); // welded into the barrel
174
+ const optics = barrel.add(lens).color(COL_BLACK)
175
+ .material({ metalness: 0.2, roughness: 0.35 });
176
+ const faceConn = () => connector('mount', { origin: [0, 0, 0], axis: [0, 0, -1], up: [0, 1, 0], kind: 'fixed' });
177
+ return {
178
+ board: board.withConnectors({ face: faceConn() }),
179
+ optics: optics.withConnectors({ face: faceConn() }),
180
+ };
181
+ }
182
+
183
+ // ------------------------------------------------------------------ servo ----
184
+ function buildServo(D) {
185
+ const S = D.servo;
186
+ // local: z=0 tab top plane, spline axis at x=0, body hangs -Z
187
+ const body = box(S.bodyW, S.bodyD, S.bodyDown + S.bodyUp)
188
+ .translate(S.bodyXc, 0, -S.bodyDown);
189
+ const tabs = box(S.tabSpan, S.bodyD, S.tabT).translate(S.bodyXc, 0, -S.tabT);
190
+ const tower = cylinder(S.towerH, S.towerD / 2).translate(0, 0, S.bodyUp);
191
+ const spline = cylinder(S.splineH, S.splineD / 2).translate(0, 0, S.bodyUp + S.towerH);
192
+ const hornHub = cylinder(4.6, S.hornHubD / 2).translate(0, 0, S.bodyUp + S.towerH + S.splineH - 2.4);
193
+ const hornDisc = cylinder(S.hornT, S.hornD / 2).translate(0, 0, S.bodyUp + S.towerH + S.splineH + 2.2 - S.hornT);
194
+ // two M2 screws up through the tab holes into the boss pilots
195
+ let screws = null;
196
+ for (const hx of [S.bodyXc + S.tabHoleXa, S.bodyXc + S.tabHoleXb]) {
197
+ const sc = cylinder(1.5, 1.9).translate(hx, 0, -S.tabT - 1.5)
198
+ .add(cylinder(6.5, 0.8).translate(hx, 0, -S.tabT));
199
+ screws = screws ? screws.add(sc) : sc;
200
+ }
201
+ const blue = body.add(tabs, screws.color(COL_SCREW)).color('#2563b0')
202
+ .material({ metalness: 0.15, roughness: 0.55 });
203
+ const white = tower.add(spline, hornHub, hornDisc).color('#e8e6df')
204
+ .material({ metalness: 0.05, roughness: 0.5 });
205
+ const hornTopZ = S.bodyUp + S.towerH + S.splineH + 2.2;
206
+ const tabsConn = () => connector('mount', { origin: [0, 0, 0], axis: [0, 0, 1], up: [0, 1, 0], kind: 'fixed' });
207
+ return {
208
+ body: blue.withConnectors({ tabs: tabsConn() }),
209
+ horn: white.withConnectors({
210
+ tabs: tabsConn(),
211
+ spline: connector('axle', { origin: [0, 0, hornTopZ], axis: [0, 0, 1], up: [0, 1, 0], kind: 'revolute' }),
212
+ }),
213
+ };
214
+ }
215
+
216
+ // ---------------------------------------------------------------- battery ----
217
+ function buildBatteryHolder(D) {
218
+ const BT = D.batt;
219
+ // local: z=0 back face (on the standoff bosses), +Z toward the interior
220
+ let holder = box(BT.w, BT.h, BT.d);
221
+ holder = holder.subtract(box(65, BT.h - 3.2, BT.d).translate(0, 0, 1.6));
222
+ return holder.color('#23262b').material({ metalness: 0.1, roughness: 0.6 }).withConnectors({
223
+ back: connector('mount', { origin: [0, 0, 0], axis: [0, 0, -1], up: [0, 1, 0], kind: 'fixed' }),
224
+ });
225
+ }
226
+
227
+ function buildCell(D, sy) {
228
+ return cylinder(65, 9.2).pointAlong([1, 0, 0]).translate(-32.5, sy * 9.4, 1.6 + 9.2)
229
+ .color('#2e6647').material({ metalness: 0.5, roughness: 0.35 }).withConnectors({
230
+ back: connector('mount', { origin: [0, 0, 0], axis: [0, 0, -1], up: [0, 1, 0], kind: 'fixed' }),
231
+ });
232
+ }
233
+
234
+ // ------------------------------------------------------------------------------
235
+ const D = dimsMod.compute({ wall: wallP, wheelOD: wheelODP });
236
+
237
+ const make = {
238
+ buildShell, buildBezel, buildCowl, buildEsp,
239
+ buildServo, buildBatteryHolder, buildCell,
240
+ };
241
+
242
+ if (require.main === module) {
243
+ const sh = buildShell(D);
244
+ const bezel = buildBezel(D).matchTo(sh, 'back', 'cam_recess');
245
+ const cowl = buildCowl(D).matchTo(sh, 'back', 'cam_cowl');
246
+ const hw = require('./hardware.forge.js');
247
+ const bezelScrews = [];
248
+ for (let i = 0; i < 4; i++) {
249
+ bezelScrews.push({ name: `Bezel Screw ${i + 1}`, shape: hw.make.screwM2(6).matchTo(sh, 'seat', `bezel_screw_${i}`) });
250
+ }
251
+ const espParts = buildEsp(D);
252
+ const espBoard = espParts.board.matchTo(sh, 'face', 'esp_cradle');
253
+ const espOptics = espParts.optics.matchTo(sh, 'face', 'esp_optics');
254
+ const servoParts = buildServo(D);
255
+ const servoBody = servoParts.body.matchTo(sh, 'tabs', 'servo_mount');
256
+ const servoHorn = servoParts.horn.matchTo(sh, 'tabs', 'servo_horn_mount');
257
+ const holder = buildBatteryHolder(D).matchTo(sh, 'back', 'batt_mount');
258
+ const cellA = buildCell(D, -1).matchTo(sh, 'back', 'cell_a');
259
+ const cellB = buildCell(D, 1).matchTo(sh, 'back', 'cell_b');
260
+
261
+ verify.notColliding('bezel clear of shell', bezel, sh);
262
+ verify.notColliding('cowl clear of shell', cowl, sh);
263
+ verify.notColliding('cowl clear of bezel', cowl, bezel);
264
+ verify.notColliding('ESP32 board clear of shell', espBoard, sh);
265
+ verify.notColliding('ESP32 optics clear of shell', espOptics, sh);
266
+ verify.notColliding('ESP32 optics clear of cowl', espOptics, cowl);
267
+ verify.notColliding('servo body clear of shell', servoBody, sh);
268
+ verify.notColliding('servo horn clear of shell', servoHorn, sh);
269
+ verify.notColliding('battery holder clear of shell', holder, sh);
270
+ verify.notColliding('cell A clear of holder', cellA, holder);
271
+ verify.notColliding('cells clear of each other', cellA, cellB);
272
+ verify.clearanceBetween('bezel seats in recess', bezel, sh, -0.01, 0.1);
273
+ verify.clearanceBetween('ESP32 board held by rails', espBoard, sh, -0.01, 0.3);
274
+ verify.clearanceBetween('servo tabs seat on bosses', servoBody, sh, -0.01, 0.1);
275
+ verify.clearanceBetween('holder seats on rear wall', holder, sh, -0.01, 0.1);
276
+
277
+ scene({
278
+ camera: { position: [200, -230, 200], target: [0, 0, 55], fov: 40 },
279
+ environment: { preset: 'studio', intensity: 0.25, background: false },
280
+ lights: [
281
+ { type: 'ambient', color: '#efe7dc', intensity: 0.18 },
282
+ { type: 'directional', position: [140, -200, 260], color: '#ffe2bf', intensity: 2.6, castShadow: true },
283
+ { type: 'directional', position: [-150, 120, 140], color: '#d4e6fb', intensity: 0.8 },
284
+ ],
285
+ });
286
+ return {
287
+ preview: [
288
+ { name: 'Body Shell', shape: sh },
289
+ { name: 'Camera Bezel', shape: bezel },
290
+ { name: 'Lens Cowl', shape: cowl },
291
+ ...bezelScrews,
292
+ { name: 'ESP32-CAM Board', shape: espBoard },
293
+ { name: 'ESP32-CAM Optics', shape: espOptics },
294
+ { name: 'Servo Body', shape: servoBody },
295
+ { name: 'Servo Horn', shape: servoHorn },
296
+ { name: 'Battery Holder', shape: holder },
297
+ { name: '18650 Cell A', shape: cellA },
298
+ { name: '18650 Cell B', shape: cellB },
299
+ ],
300
+ make,
301
+ };
302
+ }
303
+
304
+ return { make };