From 9d73ba935087712924b69f5827796f0f787f95bc Mon Sep 17 00:00:00 2001 From: NellInc Date: Mon, 10 Aug 2026 16:24:07 +0100 Subject: [PATCH] Polish v0.40 uncrewed quality convergence --- docs/PERFORMANCE_REPORT.md | 345 ++---------------- docs/SCADA_HIGH_PRIORITY_FIXES.md | 205 ++--------- scripts/blender/PROMPTS.md | 7 +- scripts/blender/specs/forklift-vehicles.json | 93 ----- scripts/blender/specs/worker-body.json | 121 ------ scripts/run-performance-benchmark.mjs | 9 +- scripts/validate-uncrewed-build.mjs | 53 ++- src/components/breakdown/BreakdownEffects.tsx | 6 +- .../ui-new/widgets/PortraitCard.tsx | 118 ------ src/config/humanPresencePolicy.test.ts | 23 ++ src/config/portraits.ts | 150 -------- 11 files changed, 155 insertions(+), 975 deletions(-) delete mode 100644 scripts/blender/specs/worker-body.json delete mode 100644 src/components/ui-new/widgets/PortraitCard.tsx delete mode 100644 src/config/portraits.ts diff --git a/docs/PERFORMANCE_REPORT.md b/docs/PERFORMANCE_REPORT.md index e695560..43fc990 100644 --- a/docs/PERFORMANCE_REPORT.md +++ b/docs/PERFORMANCE_REPORT.md @@ -1,322 +1,53 @@ -# MillOS Performance Test Report -**Date:** 2025-12-05 -**Test Duration:** 10 seconds sampling after 15s warmup -**Testing Tool:** Puppeteer automated performance testing +# MillOS v0.40 Performance Report ---- +**Baseline commit:** `dd414cf53fcb648908a5227cae7ac8d7a62c413d` +**Measured:** 2026-08-10 +**Status:** Green on the versioned Vite preview baseline -## Executive Summary +## Current acceptance baseline -**Performance Grade:** POOR (34 FPS average) -**Status:** Optimization Required -**Gap to Target:** 26 FPS improvement needed to reach 60 FPS +| View | Average FPS | p95 frame time | Draw calls | Result | +|---|---:|---:|---:|---| +| Overview | 84.0 | 13.8 ms | 1,245 | Pass | +| Interior | 107.2 | 10.8 ms | 822 | Pass | +| Shipping | 89.2 | 13.0 ms | 1,070 | Pass | +| Receiving | 92.5 | 12.1 ms | 1,021 | Pass | +| Water | 75.3 | 14.8 ms | 1,407 | Pass | ---- +All five views remain above 60 FPS and below the 16.7 ms p95 frame budget. The water view is the next draw-call target, despite meeting the frame-time budget. -## Performance Metrics +## Startup -| Metric | Value | Target | Status | -|--------|-------|--------|--------| -| Average FPS | **34** | 60 | 🔴 POOR | -| Min FPS | 30 | 60 | 🔴 | -| Max FPS | 51 | 60 | 🟡 | -| FPS Variance | 21 | <10 | ⚠️ High | -| Avg Memory | 130 MB | <200 MB | ✅ OK | -| Total Frames | 342 | 600 | 🔴 | -| Console Errors | 4 | 0 | ❌ | +| Network profile | Overview first useful frame | Five-view range | Result | +|---|---:|---:|---| +| Native | 306.1 ms | 253.8 to 329.6 ms | Pass | +| Representative Fast 3G | 3,065.0 ms | 3,058.2 to 3,069.3 ms | Pass | ---- +These measurements use the versioned Vite preview path. A bare Python static server is not a representative serving baseline because it does not reproduce Vite or deployment compression and routing. -## Critical Issues +## Delivery -### 1. WebGL Context Errors (CRITICAL) -``` -THREE.WebGLRenderer: A WebGL context could not be created. -Reason: VENDOR = 0xffff, DEVICE = 0xffff, GL_VENDOR = Disabled, GL_RENDERER = Disabled -``` - -**Impact:** 4 WebGL errors detected -**Cause:** Puppeteer headless browser has limited GPU/WebGL support -**Note:** These errors are test environment artifacts. Real browser performance will differ. - -### 2. Low Frame Rate (34 FPS Average) -**Impact:** 43% below target (26 FPS gap) -**Severity:** POOR - Immediate optimization required - -### 3. High FPS Variance (21 FPS) -**Impact:** Inconsistent performance (30-51 FPS range) -**Indicates:** -- Periodic heavy operations (likely garbage collection) -- Components re-rendering on every frame -- Possible render storms - ---- - -## Suspected Bottlenecks - -Based on codebase analysis, these are the most likely performance culprits: - -### Primary Suspects (High Impact) - -1. **TruckBay Component** - - **useFrame hooks:** 28+ per component - - **Impact:** VERY HIGH - - **File:** `/Users/nellwatson/Documents/GitHub/Experiments/src/components/TruckBay.tsx` - - **Why:** Multiple useFrame hooks running every frame is extremely expensive - -2. **WorkerSystem Component** - - **useFrame hooks:** 3+ per worker (15+ total with 5 workers) - - **Impact:** HIGH - - **File:** `/Users/nellwatson/Documents/GitHub/Experiments/src/components/WorkerSystem.tsx` - - **Why:** Each worker has multiple animation/update hooks - -3. **Machines Component** - - **useFrame hooks:** 9 hooks - - **Impact:** MEDIUM-HIGH - - **File:** `/Users/nellwatson/Documents/GitHub/Experiments/src/components/Machines.tsx` - - **Why:** Machine animations and vibrations - -### Secondary Suspects (Medium Impact) - -4. **ConveyorSystem Component** - - Multiple animated conveyor belts - - **File:** `/Users/nellwatson/Documents/GitHub/Experiments/src/components/ConveyorSystem.tsx` - -5. **ForkliftSystem Component** - - Pathfinding + animations - - **File:** `/Users/nellwatson/Documents/GitHub/Experiments/src/components/ForkliftSystem.tsx` - ---- - -## Immediate Action Items - -### 1. Profile with React DevTools (DO THIS FIRST) -**Priority:** CRITICAL -**Time:** 5 minutes +- Initial JavaScript: 0.42 MiB gzip across five files. +- Production build: 3,587 transformed modules. +- Physics, WebGPU, SCADA, charts, and post-processing remain deferred chunks. +- The service worker isolates caches by deployment scope and build identity. +- Historical release payload size is tracked separately from current v0.40 startup transfer. -**Steps:** -1. Open the Puppeteer browser window (should still be open) -2. Install React DevTools extension if not present -3. Open React DevTools → Profiler tab -4. Click "Record" → Wait 5 seconds → Stop -5. Switch to "Ranked" view -6. Look for: - - Components rendering >10 times in 5 seconds - - Components with >50ms render time - - Any "render storms" (red/orange bars) +## Current optimization priorities -**What to look for:** -- TruckBay, WorkerSystem, Machines rendering every frame -- Missing React.memo() on pure components -- Unnecessary prop changes causing re-renders +1. Reduce the water-view draw calls by at least 10 percent without changing its authored appearance. +2. Preserve or improve each view's p95 frame time while integrating visible geometry changes. +3. Keep native first useful frame at or below 350 ms and Fast 3G at or below 3.2 seconds. +4. Reject shader cache keys containing time, randomness, or other per-frame values. +5. Measure runtime, effective DPR, and visual output after every geometry or shader wave. ---- +## Required commands -### 2. Fix Console Errors -**Priority:** HIGH -**Time:** 15 minutes - -Fix the 4 console errors before optimizing further. Errors can cause cascading performance issues. - ---- - -### 3. Optimize useFrame Hooks -**Priority:** CRITICAL -**Time:** 1-2 hours - -**Target components:** -- TruckBay.tsx (28+ hooks) -- WorkerSystem.tsx (15+ hooks) -- Machines.tsx (9 hooks) - -**Optimization strategies:** -1. **Consolidate hooks:** Combine multiple useFrame hooks into one -2. **Add throttling:** Not every hook needs to run every frame -3. **Use React.memo():** Prevent unnecessary re-renders -4. **Skip when off-screen:** Don't update objects outside camera frustum - -**Example optimization:** -```typescript -// BEFORE (3 separate useFrame hooks) -useFrame(() => { updatePosition(); }); -useFrame(() => { updateRotation(); }); -useFrame(() => { updateAnimation(); }); - -// AFTER (1 consolidated hook) -useFrame(() => { - updatePosition(); - updateRotation(); - updateAnimation(); -}); -``` - ---- - -### 4. Implement Performance Debug Mode -**Priority:** MEDIUM -**Time:** 30 minutes - -The app already has `perfDebug` toggles in graphicsStore: -- `disableWorkerSystem` -- `disableTruckBay` -- `disableMachines` -- `disableConveyorSystem` -- `disableForkliftSystem` -- `disableEnvironment` - -**Action:** Add UI controls to Graphics Settings panel to toggle these on/off for A/B testing. - ---- - -### 5. Add React.memo() to Pure Components -**Priority:** HIGH -**Time:** 30 minutes - -**Components to wrap:** -- WorkerModel -- ForkliftModel -- All UI overlay components -- Any component that doesn't need to re-render on every parent update - -**Example:** -```typescript -export const WorkerModel = React.memo(({ position, rotation }) => { - // ... component code -}); +```bash +VERSION=v0.40 npm run build +npm run validate:bundle +npm run benchmark:runtime +npm run capture:art ``` ---- - -## Testing Strategy - -### Phase 1: Isolate Bottleneck (15 minutes) -1. Open app in browser -2. Open Graphics Settings -3. Disable TruckBay → Note FPS change -4. Re-enable, disable WorkerSystem → Note FPS change -5. Re-enable, disable Machines → Note FPS change -6. Identify which system has largest FPS impact - -### Phase 2: Profile Bottleneck (15 minutes) -1. Use React DevTools Profiler on the identified bottleneck -2. Record 5 seconds of rendering -3. Identify specific render storms -4. Find components without React.memo() - -### Phase 3: Optimize (1-2 hours) -1. Add React.memo() to identified components -2. Consolidate useFrame hooks -3. Add frame skipping for expensive operations -4. Test FPS improvement - -### Phase 4: Validate (15 minutes) -1. Re-run performance test: `node perf-test.cjs` -2. Verify FPS improvement -3. Ensure no new errors -4. Test on real devices (not just Puppeteer) - ---- - -## Expected Outcomes - -### After TruckBay Optimization -**Expected FPS gain:** +10-15 FPS -**New FPS:** 44-49 FPS -**Effort:** 1 hour - -### After WorkerSystem Optimization -**Expected FPS gain:** +5-8 FPS -**New FPS:** 39-42 FPS -**Effort:** 30 minutes - -### After Machines Optimization -**Expected FPS gain:** +3-5 FPS -**New FPS:** 37-39 FPS -**Effort:** 30 minutes - -### Combined Optimization -**Expected FPS:** 55-60 FPS -**Total Effort:** 2-3 hours -**Success Criteria:** Reach 60 FPS average with <10 FPS variance - ---- - -## Performance Budget (Recommended) - -| System | Current useFrame Hooks | Budget | Action | -|--------|----------------------|--------|--------| -| TruckBay | 28+ | 3-5 | Consolidate 80% | -| WorkerSystem | 15+ | 5-7 | Consolidate 50% | -| Machines | 9 | 3-4 | Consolidate 60% | -| ConveyorSystem | ~5 | 3-4 | Optimize 20% | -| ForkliftSystem | ~5 | 3-4 | Optimize 20% | -| **TOTAL** | **60+** | **20-25** | **Reduce by 60%** | - ---- - -## Long-term Optimizations - -### 1. Instanced Rendering -Render multiple copies of same geometry with one draw call. -**Use for:** Workers, forklifts, conveyor segments -**Expected gain:** +5-10 FPS - -### 2. Level of Detail (LOD) -Switch to low-poly models when objects are far from camera. -**Use for:** Workers, machines, detailed props -**Expected gain:** +3-5 FPS - -### 3. Object Pooling -Reuse objects instead of creating/destroying them. -**Use for:** Particles, temporary effects -**Expected gain:** +2-3 FPS, smoother frame times - -### 4. Frustum Culling -Skip updates for objects outside camera view. -**Use for:** All systems -**Expected gain:** +5-8 FPS (depending on camera angle) - ---- - -## Monitoring & Regression Prevention - -### 1. Add FPS Tracking -Already implemented via `FPSMonitor.tsx` - good! - -### 2. Add Performance Tests to CI -Run `node perf-test.cjs` in CI pipeline. -Alert if FPS drops below threshold. - -### 3. useFrame Hook Linting -Add ESLint rule to warn about multiple useFrame hooks in one component. - -### 4. Bundle Size Monitoring -Track bundle size changes in PRs to catch bloat early. - ---- - -## Files to Review - -Priority order for performance optimization: - -1. `/Users/nellwatson/Documents/GitHub/Experiments/src/components/TruckBay.tsx` (CRITICAL) -2. `/Users/nellwatson/Documents/GitHub/Experiments/src/components/WorkerSystem.tsx` (HIGH) -3. `/Users/nellwatson/Documents/GitHub/Experiments/src/components/Machines.tsx` (HIGH) -4. `/Users/nellwatson/Documents/GitHub/Experiments/src/components/ConveyorSystem.tsx` (MEDIUM) -5. `/Users/nellwatson/Documents/GitHub/Experiments/src/components/ForkliftSystem.tsx` (MEDIUM) - ---- - -## Conclusion - -The MillOS app is running at **34 FPS** - significantly below the 60 FPS target. The primary bottleneck is **excessive useFrame hooks** (60+ total) causing unnecessary per-frame computations. - -**Immediate priority:** Reduce useFrame hooks by 60% through consolidation and React.memo() optimization. - -**Expected outcome:** 55-60 FPS after 2-3 hours of focused optimization. - -**Risk:** LOW - Optimizations are non-breaking and can be tested incrementally. - ---- - -**Next Step:** Run React DevTools Profiler (instructions above) to confirm which components are the worst offenders, then start with TruckBay optimization. +A build proves compilation and packaging. Runtime, visual, deployment, and final aesthetic acceptance remain separate gates. diff --git a/docs/SCADA_HIGH_PRIORITY_FIXES.md b/docs/SCADA_HIGH_PRIORITY_FIXES.md index b9738e3..d3536d1 100644 --- a/docs/SCADA_HIGH_PRIORITY_FIXES.md +++ b/docs/SCADA_HIGH_PRIORITY_FIXES.md @@ -1,197 +1,40 @@ -# SCADA HIGH Priority Fixes - Completed +# SCADA High-Priority Integrity Status -All HIGH priority SCADA issues have been resolved. Build verified successfully. +**Baseline commit:** `dd414cf53fcb648908a5227cae7ac8d7a62c413d` +**Status:** Implemented and covered in the MillOS repository -## Issue 1: IndexedDB Buffer State (HIGH) - FIXED +This file previously referenced `/Users/nellwatson/Documents/GitHub/Experiments`, which was not the MillOS source tree. The authoritative implementations are under `src/scada/` in this repository. -**File:** `/Users/nellwatson/Documents/GitHub/Experiments/src/scada/HistoryStore.ts` -**Lines:** 219-275 (flushBuffers method) +## Implemented protections -**Problem:** Buffer was cleared before IndexedDB transaction completed, risking data loss on failure. +### Historian write integrity -**Solution:** Changed buffer clearing logic to only remove records after successful transaction completion using `filter()` instead of clearing immediately. +`src/scada/HistoryStore.ts` retains buffered records until the IndexedDB transaction succeeds, then removes only the records confirmed as flushed. Query operations use explicit ten-second timeouts. -**Code Changes:** -```typescript -// Before: Buffer cleared immediately -const records = [...this.writeBuffer]; -this.writeBuffer = []; -// ... transaction code -// On error: Re-add records back (data could be lost in race condition) +### Alarm listener isolation -// After: Buffer only cleared on success -const records = [...this.writeBuffer]; -// ... transaction code -// On success: Remove only the flushed records -this.writeBuffer = this.writeBuffer.filter(r => !records.includes(r)); -// On error: Records remain in buffer automatically -``` - ---- - -## Issue 2: Alarm Listener Safety (HIGH) - FIXED - -**File:** `/Users/nellwatson/Documents/GitHub/Experiments/src/scada/AlarmManager.ts` -**Lines:** 496-507 (notifyListeners method) - -**Problem:** If a listener callback modified the listeners Set during iteration (e.g., by unsubscribing), it could cause undefined behavior or skipped notifications. - -**Solution:** Create a copy of the listeners Set before iteration to prevent modification during iteration. - -**Code Changes:** -```typescript -// Before: Direct iteration over live Set -this.listeners.forEach(cb => { ... }); - -// After: Iteration over snapshot copy -const listenersCopy = [...this.listeners]; -listenersCopy.forEach(cb => { ... }); -``` - ---- - -## Issue 3: WebSocket Validation (HIGH) - FIXED - -**File:** `/Users/nellwatson/Documents/GitHub/Experiments/src/scada/adapters/WebSocketAdapter.ts` -**Lines:** 280-351 (handleMessage method) - -**Problem:** After validation, no explicit null check before accessing `msg.type`, potentially allowing invalid messages through. - -**Solution:** Added explicit type guard after validation with null check before accessing properties. - -**Code Changes:** -```typescript -// Before: -if (!isValidWSMessage(parsed)) return; -const msg = parsed; -switch (msg.type) { ... } - -// After: -if (!isValidWSMessage(parsed)) { - throw new MessageValidationError(...); -} -const msg = parsed as WSMessage; -if (!msg || typeof msg.type !== 'string') { - throw new MessageValidationError('Invalid message: missing or invalid type field', ...); -} -switch (msg.type) { ... } -``` - ---- +`src/scada/AlarmManager.ts` notifies a snapshot of its listener set. A listener that unsubscribes during notification cannot skip or corrupt later callbacks. -## Issue 4: No Timeout for IndexedDB (HIGH) - FIXED +### Adapter validation and cleanup -**File:** `/Users/nellwatson/Documents/GitHub/Experiments/src/scada/HistoryStore.ts` -**Lines:** 44-52 (withTimeout utility), 291-420 (query methods) +Protocol adapters validate inbound messages before dispatch. Subscription errors are isolated so one faulty consumer cannot stop other subscribers or prevent cleanup. -**Problem:** IndexedDB queries could hang indefinitely, blocking the application. +### Alarm lifecycle and archive -**Solution:** Created a `withTimeout` utility function and wrapped all IndexedDB query promises with 10-second timeout. +Alarm activation, acknowledgement, shelving, out-of-service state, clearance, and archive behavior are exercised by focused SCADA tests. -**Code Changes:** -```typescript -// Added utility function: -const withTimeout = (promise: Promise, ms: number, operation: string): Promise => { - const timeout = new Promise((_, reject) => - setTimeout(() => reject(new Error(`IndexedDB timeout: ${operation} exceeded ${ms}ms`)), ms) - ); - return Promise.race([promise, timeout]); -}; +## Focused verification -// Applied to all query methods: -async getHistory(...): Promise { - const query = new Promise((resolve, reject) => { - // ... IndexedDB query code - }); - return withTimeout(query, 10000, `getHistory(${tagId})`); -} - -// Also applied to: -// - getLatestValue() -// - getAlarmHistory() -``` - ---- - -## Issue 5: WebSocket Subscription Cleanup (HIGH) - FIXED - -**File:** `/Users/nellwatson/Documents/GitHub/Experiments/src/scada/adapters/WebSocketAdapter.ts` -**Lines:** 425-475 (notifySubscribers method) - -**Problem:** Subscriber callback errors could leave orphaned entries or prevent cleanup. No guarantee that cleanup would occur if errors happened. - -**Solution:** Implemented try/finally pattern for guaranteed cleanup, plus automatic removal of faulty callbacks to prevent repeated errors. - -**Code Changes:** -```typescript -// Before: Basic try/catch, no cleanup guarantee -this.globalSubscribers.forEach(callback => { - try { callback(tagValues); } - catch (err) { console.error(...); } -}); - -// After: Copy before iteration, automatic cleanup of faulty callbacks -const globalCallbacksCopy = [...this.globalSubscribers]; -globalCallbacksCopy.forEach(callback => { - try { - callback(tagValues); - } catch (err) { - console.error('[WebSocketAdapter] Global subscriber callback error:', err); - // Remove faulty callback to prevent repeated errors - try { - this.globalSubscribers.delete(callback); - } catch (deleteErr) { - // Ignore cleanup errors - } - } -}); - -// Tag-specific subscribers: try/finally pattern -try { - tagValues.forEach(tv => { ... }); -} finally { - // Always attempt to notify collected subscribers - subscriberUpdates.forEach((values, callback) => { - try { - callback(values); - } catch (err) { - // Remove from all subscriptions - this.subscribers.forEach(callbackSet => { - callbackSet.delete(callback); - }); - } - }); -} +```bash +npx vitest run src/scada +npm run typecheck +npm run lint +npm run build ``` ---- - -## Verification - -All fixes have been applied and verified: - -1. **Type Safety:** TypeScript compilation successful -2. **Build:** `npm run build` completed without errors -3. **Code Quality:** All changes follow existing code patterns -4. **Error Handling:** Improved error isolation and recovery - -## Files Modified - -1. `/Users/nellwatson/Documents/GitHub/Experiments/src/scada/HistoryStore.ts` -2. `/Users/nellwatson/Documents/GitHub/Experiments/src/scada/AlarmManager.ts` -3. `/Users/nellwatson/Documents/GitHub/Experiments/src/scada/adapters/WebSocketAdapter.ts` - -## Impact - -These fixes improve: -- **Reliability:** Prevented data loss scenarios in IndexedDB buffer management -- **Stability:** Protected against callback-induced crashes and hung queries -- **Safety:** Eliminated undefined behavior in listener iteration and message handling -- **Resilience:** Added timeouts to prevent indefinite hangs - -## Next Steps +## Remaining operational gates -All HIGH priority issues are resolved. Consider: -- Review MEDIUM and LOW priority issues if any exist -- Add unit tests for the fixed scenarios -- Monitor production logs for timeout occurrences (may indicate performance issues) +- Exercise disconnect, stale-data, malformed-frame, retry, and backpressure scenarios in a real browser session. +- Confirm scene animation, SCADA tag values, alarm state, and historian output remain synchronized. +- Validate the optional SCADA proxy separately from simulation mode. +- Treat a passing source suite as machine evidence, not as live controller or production-network acceptance. diff --git a/scripts/blender/PROMPTS.md b/scripts/blender/PROMPTS.md index 04bb650..3ca530a 100644 --- a/scripts/blender/PROMPTS.md +++ b/scripts/blender/PROMPTS.md @@ -10,7 +10,7 @@ Two modes, two jobs. Pick by what the output has to survive. | "What would this shape look like" | **MCP** | | Poking at a `.blend` you already have open | **MCP** | -The deciding fact for this repo: MillOS machines are **procedural instanced geometry in TypeScript**, not GLBs. Only the forklift and three workers are real assets, and their glTF file order is load-bearing. So most Blender work here ends as TS or as a pipeline change — not as a new `.glb`. +The deciding fact for this repo: MillOS machines are **procedural instanced geometry in TypeScript**, not GLBs. The forklift is the only current runtime character-scale GLB, and its glTF file order is load-bearing. MillOS v0.40 is permanently uncrewed: do not design, restore, or reference people, operators, drivers, personnel portraits, or character assets. Most Blender work here therefore ends as TS or as a pipeline change, not as a new `.glb`. --- @@ -36,6 +36,9 @@ Constraints specific to MillOS — treat these as hard: 2. assets/source/models/ is immutable provenance. Read it; never write it. +2a. v0.40 is uncrewed. Do not add people to vehicles or scenery, and do not + create operator, driver, worker, avatar, portrait, or character studies. + 3. Blender's world is Z-up; three.js is Y-up. If you build a lathe or profile Y-up to match the runtime, an unrotated Blender camera frames it correctly. Getting this wrong renders a blank frame. @@ -78,7 +81,7 @@ Verify before claiming done, and show the output: npm run validate:assets # asset gate: bounds, budgets, required nodes/clips npm run typecheck && npm run lint npm run build - npm test # baseline is 91 files / 1603 tests + npm test # record the live baseline; do not copy stale counts And for anything visible or interactive, actually run it — the repo rule is that a build is not a runtime. Load the dev server and confirm in the scene. diff --git a/scripts/blender/specs/forklift-vehicles.json b/scripts/blender/specs/forklift-vehicles.json index 81318bc..16a7e17 100644 --- a/scripts/blender/specs/forklift-vehicles.json +++ b/scripts/blender/specs/forklift-vehicles.json @@ -176,98 +176,5 @@ }, "scale": [1, 1, 1], "distance": 0.85 - }, - { - "name": "operatorHatCrown", - "note": "SeatedVehicleOperator hard-hat crown, shared by both forklift operators (scale 0.68) and the truck-bay drivers (scale 1.28). CylinderGeometry(0.13,0.145,0.1,18) - a flat-topped truncated cone, i.e. a bucket. The hat is the single most identifiable thing about a figure in a cab. Redesign: a steep skirt that seats into the brim, a shoulder, and a dome. Envelope pinned: max radius 0.145, y half-extent 0.050.", - "before": { "kind": "cylinder", "args": [0.13, 0.145, 0.1, 18] }, - "after": { - "kind": "profile", - "segments": 18, - "points": [ - [0.0000, -0.0500], - [0.1450, -0.0500], - [0.1440, -0.0330], - [0.1385, -0.0080], - [0.1300, 0.0090], - [0.1130, 0.0270], - [0.0850, 0.0400], - [0.0480, 0.0480], - [0.0180, 0.0500], - [0.0000, 0.0500] - ] - }, - "scale": [1, 1, 1], - "distance": 0.8 - }, - { - "name": "operatorHatCrownAt8m", - "note": "Same swap at the forklift's 0.68 operator scale, framed at 8 m. A 0.197 m hat at 8 m is the honest test of whether the dome carries.", - "before": { "kind": "cylinder", "args": [0.13, 0.145, 0.1, 18] }, - "after": { - "kind": "profile", - "segments": 18, - "points": [ - [0.0000, -0.0500], - [0.1450, -0.0500], - [0.1440, -0.0330], - [0.1385, -0.0080], - [0.1300, 0.0090], - [0.1130, 0.0270], - [0.0850, 0.0400], - [0.0480, 0.0480], - [0.0180, 0.0500], - [0.0000, 0.0500] - ] - }, - "scale": [0.68, 0.68, 0.68], - "distance": 8.0 - }, - { - "name": "operatorHatBrim", - "note": "SeatedVehicleOperator hard-hat brim. CylinderGeometry(0.18,0.18,0.025,18) - a flat disc, a frisbee. Redesign: a flat crown seat, a brim that falls away outward, a duck-bill turn-up at the edge and a rolled rim underside. Profile is wound underside-centre -> rim -> top-centre (increasing y) so LatheGeometry's analytic normals face OUT; the reverse winding previews identically in Workbench, which draws backfaces, and then renders inside-out on a FrontSide material. Envelope pinned: max radius 0.180, y half-extent 0.0125.", - "before": { "kind": "cylinder", "args": [0.18, 0.18, 0.025, 18] }, - "after": { - "kind": "profile", - "segments": 18, - "points": [ - [0.0000, -0.0035], - [0.0940, -0.0050], - [0.1380, -0.0095], - [0.1720, -0.0125], - [0.1800, -0.0025], - [0.1660, -0.0035], - [0.1300, 0.0045], - [0.0920, 0.0125], - [0.0000, 0.0125] - ] - }, - "scale": [1, 1, 1], - "distance": 1.0 - }, - { - "name": "operatorHead", - "note": "SeatedVehicleOperator head. SphereGeometry(0.12,16,12) - a ball sitting straight on the torso box with no neck at all. The hard-hat brim sits 0.06 above the head centre and its underside occludes everything above local y ~= +0.053, so the entire design budget belongs to the band below that: neck, jaw, cheek, temple. Redesign: neck column rising off the shoulders, jaw underside, jaw angle, cheek, and the widest ring moved down to +0.020 so the temple is the widest VISIBLE point. The bottom still converges on the axis, so the head meets the torso top at a point rather than on a coplanar disc that would z-fight. Envelope pinned: max radius 0.120, y half-extent 0.120.", - "before": { "kind": "sphere", "args": [0.12, 16, 12] }, - "after": { - "kind": "profile", - "segments": 16, - "points": [ - [0.0000, -0.1200], - [0.0575, -0.1085], - [0.0620, -0.0930], - [0.0810, -0.0765], - [0.1015, -0.0530], - [0.1145, -0.0270], - [0.1200, 0.0060], - [0.1185, 0.0330], - [0.1090, 0.0620], - [0.0930, 0.0880], - [0.0530, 0.1130], - [0.0000, 0.1200] - ] - }, - "scale": [1, 1, 1], - "distance": 0.75 } ] diff --git a/scripts/blender/specs/worker-body.json b/scripts/blender/specs/worker-body.json deleted file mode 100644 index 8b2c401..0000000 --- a/scripts/blender/specs/worker-body.json +++ /dev/null @@ -1,121 +0,0 @@ -[ - { - "name": "torso", - "note": "DetailedWorker chest. Cylinder(0.29,0.215,0.52,20) drawn at [1,1,0.5] inside a 0.9 root - 0.52 m across, 0.47 m tall. Redesign: waist pinch, rib flare, pectoral shelf, shoulder line, trapezius slope into the neck root. Max radius 0.290 is moved ABOVE the vest's top edge so the vest can stay proud of it; see the vest entry.", - "before": { "kind": "cylinder", "args": [0.29, 0.215, 0.52, 20] }, - "after": { - "kind": "profile", - "segments": 20, - "points": [ - [0.000, -0.2600], - [0.226, -0.2600], - [0.212, -0.1960], - [0.232, -0.1150], - [0.248, -0.0200], - [0.262, 0.0750], - [0.272, 0.1450], - [0.281, 0.1960], - [0.290, 0.2300], - [0.256, 0.2490], - [0.188, 0.2585], - [0.000, 0.2600] - ] - }, - "scale": [1, 1, 0.5], - "distance": 3.5 - }, - { - "name": "waist", - "note": "DetailedWorker abdomen. Cylinder(0.215,0.225,0.27,20) at [1,1,0.52] - a near-straight tube that flared DOWNWARD into the belt. Redesign puts the hip crown mid-height and tucks both rims in: the top rim slides inside the shirt hem, the bottom rim sits over the belt.", - "before": { "kind": "cylinder", "args": [0.215, 0.225, 0.27, 20] }, - "after": { - "kind": "profile", - "segments": 20, - "points": [ - [0.000, -0.1350], - [0.212, -0.1350], - [0.222, -0.1040], - [0.225, -0.0450], - [0.221, 0.0300], - [0.213, 0.0900], - [0.208, 0.1350], - [0.000, 0.1350] - ] - }, - "scale": [1, 1, 0.52], - "distance": 3.5 - }, - { - "name": "vest", - "note": "Hi-vis vest. Cylinder(0.3,0.225,0.535,20) at [1,1,0.5], offset +0.006 in z - it tapered in lockstep with the torso, so it read as a slightly larger cylinder. Redesign: bound hem band with a binding groove, a skirt that swells back to the body, a constant armhole band the sleeve clears, and a rolled shoulder yoke whose edge tucks under the shirt.", - "before": { "kind": "cylinder", "args": [0.3, 0.225, 0.535, 20] }, - "after": { - "kind": "profile", - "segments": 20, - "points": [ - [0.000, -0.2675], - [0.244, -0.2675], - [0.244, -0.2420], - [0.233, -0.2330], - [0.259, -0.1950], - [0.268, -0.1650], - [0.269, 0.0450], - [0.281, 0.1150], - [0.297, 0.2150], - [0.300, 0.2460], - [0.300, 0.2570], - [0.286, 0.2650], - [0.246, 0.2675], - [0.000, 0.2675] - ] - }, - "scale": [1, 1, 0.5], - "distance": 3.5 - }, - { - "name": "mediumTorso", - "note": "SimplifiedWorker body - the PERMANENT medium-LOD figure, and the medium-LOD vest too (the material swaps to hi-vis orange). Cylinder(0.27,0.205,0.64,8) at [1,1,0.56] was a monotonic wedge whose widest ring was its top rim, so the silhouette read as a plant pot with a floating head. Redesign gives it a shoulder line below a trapezius slope, a chest, a waist and a hip. Segment count deliberately stays at 8: A/B rendered at the true 24 m viewing distance, 8 and 12 are indistinguishable, and 8 keeps its facets coincident with mediumWaist. Preview distance below is 4 m so this frame is legible as a shape document; the realistic-distance judgement was made in the assembly renders.", - "before": { "kind": "cylinder", "args": [0.27, 0.205, 0.64, 8] }, - "after": { - "kind": "profile", - "segments": 8, - "points": [ - [0.000, -0.3200], - [0.205, -0.3200], - [0.232, -0.2450], - [0.238, -0.1800], - [0.228, -0.0600], - [0.222, 0.0100], - [0.238, 0.1000], - [0.252, 0.1800], - [0.265, 0.2450], - [0.270, 0.2830], - [0.236, 0.3060], - [0.166, 0.3200], - [0.000, 0.3200] - ] - }, - "scale": [1, 1, 0.56], - "distance": 4.0 - }, - { - "name": "mediumWaist", - "note": "SimplifiedWorker hips and thigh mass, in pants colour. Cylinder(0.205,0.22,0.24,8) flared DOWNWARD, which reads as a flared skirt above two legs. Redesign reverses that: hip crown at the top, converging to the thigh mass so the leg capsules emerge from under it. Max radius 0.22 and the y range are unchanged - only where the maximum SITS moves.", - "before": { "kind": "cylinder", "args": [0.205, 0.22, 0.24, 8] }, - "after": { - "kind": "profile", - "segments": 8, - "points": [ - [0.000, -0.1200], - [0.203, -0.1200], - [0.213, -0.0700], - [0.220, -0.0100], - [0.216, 0.0600], - [0.201, 0.1200], - [0.000, 0.1200] - ] - }, - "scale": [1, 1, 0.56], - "distance": 4.0 - } -] diff --git a/scripts/run-performance-benchmark.mjs b/scripts/run-performance-benchmark.mjs index 440129f..5307dee 100644 --- a/scripts/run-performance-benchmark.mjs +++ b/scripts/run-performance-benchmark.mjs @@ -25,6 +25,7 @@ const NETWORK_PROFILES = { }, }; const PA_MODES = new Set(['focused', 'characterful', 'off']); +const CELESTIAL_EVIDENCE_TIMES = Object.freeze({ sun: 12, moon: 0 }); function readArgument(name, fallback) { const prefix = `--${name}=`; @@ -512,11 +513,16 @@ async function runScene(context, baseUrl, scene, scadaEnabled = options.scadaEna }); }); + // Named celestial views are evidence cameras, so they must show the body + // named by the scene. A global daytime capture previously aimed the moon + // camera below the horizon and produced a convincing screenshot of the + // ground. Ordinary scenes still honour the requested simulation hour. + const sceneTime = CELESTIAL_EVIDENCE_TIMES[scene] ?? options.time; const query = new URLSearchParams({ benchmark: scene, duration: String(options.durationSeconds), quality: options.quality, - time: String(options.time), + time: String(sceneTime), weather: options.weather, scada: scadaEnabled ? 'on' : 'off', pa: options.paMode, @@ -683,6 +689,7 @@ async function runScene(context, baseUrl, scene, scadaEnabled = options.scadaEna const motionAcceptance = evaluateMotionAcceptance(motionSamples, motionSummary); const result = { scene, + sceneTime, variant, scadaEnabled, url: page.url(), diff --git a/scripts/validate-uncrewed-build.mjs b/scripts/validate-uncrewed-build.mjs index 6977d5f..f4319c3 100644 --- a/scripts/validate-uncrewed-build.mjs +++ b/scripts/validate-uncrewed-build.mjs @@ -4,6 +4,12 @@ import process from 'node:process'; const ROOT = process.cwd(); const DIST = path.join(ROOT, 'dist'); +const ACTIVE_SOURCE = path.join(ROOT, 'src'); +const CURRENT_PUBLIC = path.join(ROOT, 'public'); +const DESIGN_FILES = [ + path.join(ROOT, 'scripts', 'blender', 'PROMPTS.md'), + path.join(ROOT, 'scripts', 'blender', 'specs', 'forklift-vehicles.json'), +]; const forbiddenPathPatterns = [ { label: 'portrait directory', pattern: /(^|\/)assets\/workers(\/|$)/i }, @@ -28,6 +34,21 @@ const forbiddenRuntimePatterns = [ }, ]; +const forbiddenDesignPatterns = [ + { label: 'obsolete worker asset guidance', pattern: /only the forklift and three workers/i }, + { + label: 'personnel geometry study', + pattern: /"name"\s*:\s*"(?:operator|worker|driver|personnel|avatar|human)/i, + }, + { label: 'worker geometry manifest', pattern: /worker-body\.json/i }, +]; + +const isArchivedPath = (relative) => + relative.startsWith('0.10 Archive/') || /^v\d+\.\d+\//.test(relative); + +const isTestSource = (relative) => + relative.includes('/__tests__/') || /(?:^|\.)test\.[cm]?[jt]sx?$/.test(relative); + async function collectFiles(directory, base = directory) { const entries = await readdir(directory, { withFileTypes: true }); const nested = await Promise.all( @@ -62,12 +83,42 @@ async function main() { } } + const sourceFiles = await collectFiles(ACTIVE_SOURCE); + for (const file of sourceFiles) { + if (isArchivedPath(file.relative) || isTestSource(file.relative)) continue; + for (const rule of forbiddenPathPatterns) { + if (rule.pattern.test(file.relative)) failures.push(`active source ${rule.label}: ${file.relative}`); + } + if (!/\.[cm]?[jt]sx?$/i.test(file.relative)) continue; + const content = await readFile(file.absolute, 'utf8'); + for (const rule of forbiddenRuntimePatterns) { + if (rule.pattern.test(content)) failures.push(`active source ${rule.label}: ${file.relative}`); + } + } + + const publicFiles = await collectFiles(CURRENT_PUBLIC); + for (const file of publicFiles) { + if (isArchivedPath(file.relative)) continue; + for (const rule of forbiddenPathPatterns) { + if (rule.pattern.test(file.relative)) failures.push(`current public ${rule.label}: ${file.relative}`); + } + } + + for (const absolute of DESIGN_FILES) { + const content = await readFile(absolute, 'utf8'); + for (const rule of forbiddenDesignPatterns) { + if (rule.pattern.test(content)) { + failures.push(`design ${rule.label}: ${path.relative(ROOT, absolute)}`); + } + } + } + if (failures.length > 0) { throw new Error(`Uncrewed delivery contract failed:\n${[...new Set(failures)].join('\n')}`); } console.log( - `Uncrewed delivery contract passed: ${files.length} files, no human assets, host voices, or personnel modules.` + `Uncrewed delivery contract passed: ${files.length} delivery files and ${sourceFiles.length} source files; no human assets, host voices, personnel modules, or personnel design studies.` ); } diff --git a/src/components/breakdown/BreakdownEffects.tsx b/src/components/breakdown/BreakdownEffects.tsx index b152fea..55257bf 100644 --- a/src/components/breakdown/BreakdownEffects.tsx +++ b/src/components/breakdown/BreakdownEffects.tsx @@ -28,7 +28,11 @@ const sparkMaterial = new THREE.MeshBasicMaterial({ opacity: 0.9, }); -const smokeGeometry = new THREE.SphereGeometry(0.15, 6, 6); +// Puffs expand as they rise - scale reaches ~1.9 by end of life - so this 0.15 m +// sphere draws up to 0.57 m across, at the range you stand to repair the machine. +// Six segments read as a faceted lump at that size. One shared geometry through +// one InstancedMesh: +68 vertices once, not per particle. +const smokeGeometry = new THREE.SphereGeometry(0.15, 12, 8); const smokeMaterial = new THREE.MeshBasicMaterial({ color: '#6b7280', transparent: true, diff --git a/src/components/ui-new/widgets/PortraitCard.tsx b/src/components/ui-new/widgets/PortraitCard.tsx deleted file mode 100644 index 71c9d10..0000000 --- a/src/components/ui-new/widgets/PortraitCard.tsx +++ /dev/null @@ -1,118 +0,0 @@ -/** - * PortraitCard - Display portraits of BAS contributors - * - * Shows generative portraits of key thinkers behind the - * Bilateral Autonomy System. - */ - -import React, { useState, memo } from 'react'; -import { motion } from 'framer-motion'; -import { ExternalLink, User } from 'lucide-react'; -import type { PortraitConfig } from '../../../config/portraits'; - -interface PortraitCardProps { - portrait: PortraitConfig; - size?: 'small' | 'medium' | 'large'; - showDetails?: boolean; -} - -const SIZE_CONFIG = { - small: { container: 'w-12 h-12', text: 'text-[8px]', nameSize: 'text-[9px]' }, - medium: { container: 'w-20 h-20', text: 'text-[9px]', nameSize: 'text-[10px]' }, - large: { container: 'w-28 h-28', text: 'text-[10px]', nameSize: 'text-xs' }, -}; - -const CATEGORY_COLORS = { - wallace: 'border-green-500/50 bg-green-500/10', - mondragon: 'border-cyan-500/50 bg-cyan-500/10', - semler: 'border-amber-500/50 bg-amber-500/10', - bilateral: 'border-pink-500/50 bg-pink-500/10', - other: 'border-purple-500/50 bg-purple-500/10', -}; - -export const PortraitCard: React.FC = memo( - ({ portrait, size = 'medium', showDetails = true }) => { - const [imageError, setImageError] = useState(false); - const sizeConfig = SIZE_CONFIG[size]; - const categoryColor = CATEGORY_COLORS[portrait.category]; - - return ( - - {/* Portrait Image */} -
- {!imageError ? ( - {portrait.name} setImageError(true)} - /> - ) : ( - - )} -
- - {/* Name & Details */} - {showDetails && ( -
-
- {portrait.name} -
-
- {portrait.title} -
- {portrait.researchUrl && ( - - - Research - - )} -
- )} -
- ); - } -); - -PortraitCard.displayName = 'PortraitCard'; - -/** - * PortraitRow - Display multiple portraits in a row - */ -interface PortraitRowProps { - portraits: PortraitConfig[]; - size?: 'small' | 'medium' | 'large'; - title?: string; -} - -export const PortraitRow: React.FC = memo( - ({ portraits, size = 'small', title }) => { - return ( -
- {title && ( -
{title}
- )} -
- {portraits.map((portrait) => ( - - ))} -
-
- ); - } -); - -PortraitRow.displayName = 'PortraitRow'; - -export default PortraitCard; diff --git a/src/config/humanPresencePolicy.test.ts b/src/config/humanPresencePolicy.test.ts index fc02840..519a12e 100644 --- a/src/config/humanPresencePolicy.test.ts +++ b/src/config/humanPresencePolicy.test.ts @@ -89,4 +89,27 @@ describe('v0.40 uncrewed-site contract', () => { /speechSynthesis|SpeechSynthesis|ttsEnabled|speakAnnouncement|startWorkerVoices|startRadioChatter|playRadioDispatch|WorkerModel|WORKER_ASSET_PATHS|WORKER_VARIANTS/ ); }); + + it('keeps current design guidance and source-only modules uncrewed', () => { + const prohibitedSourcePaths = [ + 'src/components/ui-new/widgets/PortraitCard.tsx', + 'src/config/portraits.ts', + 'scripts/blender/specs/worker-body.json', + ]; + expect( + prohibitedSourcePaths.filter((path) => existsSync(resolve(process.cwd(), path))) + ).toEqual([]); + + const blenderPrompt = source('scripts/blender/PROMPTS.md'); + expect(blenderPrompt).not.toMatch(/only the forklift and three workers/i); + + const vehicleStudies = JSON.parse( + source('scripts/blender/specs/forklift-vehicles.json') + ) as Array<{ name: string }>; + expect(vehicleStudies.map(({ name }) => name)).not.toEqual( + expect.arrayContaining([ + expect.stringMatching(/^(?:operator|worker|driver|personnel|avatar|human)/i), + ]) + ); + }); }); diff --git a/src/config/portraits.ts b/src/config/portraits.ts deleted file mode 100644 index bc70bec..0000000 --- a/src/config/portraits.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Portrait Configuration for BAS Educational Content - * - * Lists all people referenced in the Bilateral Autonomy System - * educational materials who need generative portraits. - * - * Portrait images should be placed in: public/portraits/ - * Format: WebP, 256x256px recommended - */ - -export interface PortraitConfig { - id: string; - name: string; - title: string; - affiliation: string; - contribution: string; - category: 'wallace' | 'mondragon' | 'semler' | 'bilateral' | 'other'; - imagePath: string; - researchUrl?: string; -} - -export const PORTRAITS: PortraitConfig[] = [ - // WALLACE STABILITY - { - id: 'rodrick-wallace', - name: 'Prof. Dr. Rodrick Wallace', - title: 'Research Scientist', - affiliation: 'New York State Psychiatric Institute / Columbia University', - contribution: - 'Rate Distortion Control Theory - mathematical foundations for cognitive system stability (ατ < 0.368)', - category: 'wallace', - imagePath: '/portraits/rodrick-wallace.webp', - researchUrl: 'https://www.researchgate.net/profile/Rodrick-Wallace', - }, - - // MONDRAGON PRINCIPLES - { - id: 'jose-maria-arizmendiarrieta', - name: 'Fr. José María Arizmendiarrieta', - title: 'Founder', - affiliation: 'Mondragon Cooperative Corporation', - contribution: - 'Founded Mondragon in 1956. Established cooperative principles: worker ownership, democratic governance, wage solidarity.', - category: 'mondragon', - imagePath: '/portraits/jose-maria-arizmendiarrieta.webp', - }, - - // SEMLER PRACTICES - { - id: 'ricardo-semler', - name: 'Ricardo Semler', - title: 'CEO & Author', - affiliation: 'Semco Partners', - contribution: - 'Pioneered radical workplace democracy at Semco. Self-set salaries, worker-elected managers, radical transparency.', - category: 'semler', - imagePath: '/portraits/ricardo-semler.webp', - }, - - // BUURTZORG / SELF-MANAGING TEAMS - { - id: 'jos-de-blok', - name: 'Jos de Blok', - title: 'Founder & CEO', - affiliation: 'Buurtzorg', - contribution: - 'Created Buurtzorg self-managing nursing teams model. 15,000+ nurses in teams of 12 with no managers. Proof that flat structures scale.', - category: 'semler', - imagePath: '/portraits/jos-de-blok.webp', - }, - - // SERVANT LEADERSHIP - { - id: 'robert-greenleaf', - name: 'Robert K. Greenleaf', - title: 'Founder of Modern Servant Leadership', - affiliation: 'AT&T (former) / Greenleaf Center', - contribution: - 'Developed servant leadership philosophy. Leaders serve first, lead second. Influenced BAS AI-as-servant model.', - category: 'bilateral', - imagePath: '/portraits/robert-greenleaf.webp', - }, - - // ADDITIONAL THINKERS - { - id: 'aristotle', - name: 'Aristotle', - title: 'Philosopher', - affiliation: 'Ancient Greece', - contribution: - 'Eudaimonia (flourishing) framework. Virtue ethics. Character through practice. Foundation for flourishing dimensions.', - category: 'other', - imagePath: '/portraits/aristotle.webp', - }, - - // QUALITY MANAGEMENT - { - id: 'w-edwards-deming', - name: 'W. Edwards Deming', - title: 'Statistician & Management Consultant', - affiliation: 'NYU Stern School of Business', - contribution: - 'System of Profound Knowledge. 14 Points for Management. PDCA cycle. Transformed Japanese manufacturing quality. "A bad system will beat a good person every time."', - category: 'other', - imagePath: '/portraits/w-edwards-deming.webp', - researchUrl: 'https://deming.org/', - }, - - // COMMONS GOVERNANCE - { - id: 'elinor-ostrom', - name: 'Elinor Ostrom', - title: 'Nobel Laureate in Economics (2009)', - affiliation: 'Indiana University', - contribution: - 'Governing the Commons. Proved communities can self-manage shared resources without privatization or state control. 8 design principles for sustainable commons.', - category: 'other', - imagePath: '/portraits/elinor-ostrom.webp', - researchUrl: 'https://en.wikipedia.org/wiki/Elinor_Ostrom', - }, -]; - -/** - * Get portrait by ID - */ -export function getPortrait(id: string): PortraitConfig | undefined { - return PORTRAITS.find((p) => p.id === id); -} - -/** - * Get portraits by category - */ -export function getPortraitsByCategory(category: PortraitConfig['category']): PortraitConfig[] { - return PORTRAITS.filter((p) => p.category === category); -} - -/** - * Portrait sources (all from Wikimedia Commons): - * - * - Rodrick Wallace: https://commons.wikimedia.org/wiki/File:Rodrick_Wallace.jpg - * - José María Arizmendiarrieta: https://commons.wikimedia.org/wiki/File:Jose_Maria_Arizmendiarrieta.jpg - * - Jos de Blok: https://commons.wikimedia.org/wiki/File:Jos_de_blok-1508879511.jpg - * - Aristotle: https://en.wikipedia.org/wiki/File:Aristotle_Altemps_Inv8575.jpg - * - W. Edwards Deming: https://commons.wikimedia.org/wiki/File:W._Edwards_Deming.jpg (FDA, public domain) - * - Elinor Ostrom: https://commons.wikimedia.org/wiki/File:Nobel_Prize_2009-Press_Conference_KVA-30.jpg (CC BY-SA) - * - * NOT AVAILABLE on Wikimedia (no free images): - * - Ricardo Semler - * - Robert K. Greenleaf - */