--- name: bridging-2d-3d-rendering description: Bridging 2D canvas and 3D spatial rendering in StickerNest. Use when working with coordinate conversion, parallel DOM/WebGL architecture, widget rendering across modes, Html vs pure 3D decisions, or mode-aware components. Covers spatialCoordinates utilities, SpatialCanvas, SpatialWidgetContainer, and XR session detection. --- # Bridging 2D Canvas and 3D Spatial Rendering StickerNest uses a **parallel rendering architecture** where 2D DOM canvas and 3D WebGL scene coexist. Understanding this bridge is critical for building features that work across desktop, VR, and AR. ## Core Architecture ``` ┌─────────────────────────────────────────────────────────────┐ │ CanvasPage.tsx │ │ ┌─────────────────────┐ ┌─────────────────────────────┐ │ │ │ CanvasRenderer │ │ SpatialCanvas │ │ │ │ (DOM/2D) │ │ (WebGL/3D) │ │ │ │ │ │ │ │ │ │ - HTML elements │ │ - Three.js scene │ │ │ │ - CSS positioning │ │ - 3D meshes & materials │ │ │ │ - React components │ │ - WebXR sessions │ │ │ │ │ │ │ │ │ │ visible: desktop │ │ visible: vr/ar │ │ │ └─────────────────────┘ └─────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────────┐│ │ │ Shared State (Zustand stores) ││ │ │ - useCanvasStore (widgets, stickers, positions) ││ │ │ - useSpatialModeStore (desktop/vr/ar mode) ││ │ └─────────────────────────────────────────────────────────┘│ └─────────────────────────────────────────────────────────────┘ ``` **Key principle**: Both renderers read from the same state. Position a widget in 2D, and it appears in the correct 3D location automatically. ## Spatial Modes ```typescript import { useActiveSpatialMode, useIsDesktopMode } from '@/state/useSpatialModeStore'; type SpatialMode = 'desktop' | 'vr' | 'ar'; // In components: const spatialMode = useActiveSpatialMode(); const isDesktopMode = spatialMode === 'desktop'; ``` | Mode | Renderer | Use Case | |------|----------|----------| | `desktop` | CanvasRenderer (DOM) | Traditional 2D editing | | `vr` | SpatialCanvas (WebGL) | Immersive VR headset | | `ar` | SpatialCanvas (WebGL) | AR passthrough | ## Coordinate Conversion The bridge between 2D and 3D is **coordinate conversion**. Use these utilities from `src/utils/spatialCoordinates.ts`: ### Constants ```typescript import { PIXELS_PER_METER, // 100 - conversion factor DEFAULT_WIDGET_Z, // -2 meters (in front of user) DEFAULT_EYE_HEIGHT, // 1.6 meters (standing user) } from '@/utils/spatialCoordinates'; ``` ### 2D to 3D Conversion ```typescript import { toSpatialPosition, toSpatialSize, toSpatialRotation } from '@/utils/spatialCoordinates'; // Position: pixels → meters const pos3D = toSpatialPosition({ x: 500, y: 300 }); // Returns: [5, 1.1, -2] (x in meters, y adjusted for eye height, z = default depth) // Size: pixels → meters const size3D = toSpatialSize({ width: 200, height: 150 }); // Returns: { width: 2, height: 1.5 } // Rotation: degrees → radians (around Z axis) const rot3D = toSpatialRotation(45); // Returns: [0, 0, -0.785] (Euler angles) ``` ### 3D to 2D Conversion ```typescript import { toDOMPosition, toDOMSize } from '@/utils/spatialCoordinates'; // Position: meters → pixels const pos2D = toDOMPosition([5, 1.1, -2]); // Returns: { x: 500, y: 300 } // Size: meters → pixels const size2D = toDOMSize({ width: 2, height: 1.5 }); // Returns: { width: 200, height: 150 } ``` ### Full Transform ```typescript import { toSpatialTransform } from '@/utils/spatialCoordinates'; const transform = toSpatialTransform({ x: 500, y: 300, width: 200, height: 150, rotation: 45, scale: 1, z: -3, // optional custom depth }); // Returns: { position: [x,y,z], rotation: [rx,ry,rz], scale: [s,s,s] } ``` ### Y-Axis Inversion **Critical**: DOM Y grows downward, 3D Y grows upward. The conversion handles this: ``` DOM: (0,0) ──────► X 3D: Y ▲ │ │ │ │ ▼ Y └──────► X ``` ## Widget Rendering Across Modes ### The Html Component Problem The `` component from `@react-three/drei` renders DOM content in 3D space. **BUT** it creates DOM overlays that break immersive WebXR: ```tsx // This breaks immersive VR! DOM overlays appear as flat screen
Widget content
``` ### XR Session Detection Always check if in an XR session before rendering Html: ```tsx import { useXR } from '@react-three/xr'; function SpatialWidget({ widget }) { // Detect active XR session const session = useXR((state) => state.session); const isPresenting = !!session; return ( {/* 3D panel mesh - always renders */} {/* Html content - ONLY when NOT in XR session */} {!isPresenting && ( )} {/* 3D placeholder - ONLY when IN XR session */} {isPresenting && ( {widget.name} )} ); } ``` ### Decision Tree: Html vs Pure 3D ``` Is this for XR (VR/AR)? ├─ YES: Use pure Three.js │ - with materials │ - from drei for labels │ - Textures for images │ - NO components │ └─ NO (desktop/preview): Can use Html - for complex React UI - iframes for widget sandboxing - Full CSS styling ``` ## Mode-Aware Component Pattern ```tsx import { useActiveSpatialMode } from '@/state/useSpatialModeStore'; import { useXR } from '@react-three/xr'; function ModeAwareWidget({ widget }) { const spatialMode = useActiveSpatialMode(); const session = useXR((state) => state.session); const isXRActive = !!session; // Desktop mode: use CanvasRenderer (not this component) // This component only runs in SpatialCanvas (vr/ar modes) if (isXRActive) { // TRUE XR: Pure WebGL only return ; } // Preview mode (vr/ar without XR session): Can use Html return ; } ``` ## Parallel Rendering in CanvasPage ```tsx // src/pages/CanvasPage.tsx structure function CanvasPage() { const spatialMode = useActiveSpatialMode(); const isDesktopMode = spatialMode === 'desktop'; return ( <> {/* DOM Renderer - visible only in desktop mode */} {isDesktopMode && ( )} {/* WebGL/XR Renderer - visible in VR/AR modes */} ); } ``` ## Common Patterns ### Pattern: Conditional Widget Content ```tsx function WidgetPanel({ widget, isPresenting }) { const size3D = toSpatialSize({ width: widget.width, height: widget.height }); return ( {/* Base panel - always visible */} {/* Content layer */} {isPresenting ? ( // XR mode: 3D placeholder {widget.name || 'Widget'} {widget.widgetDefId} ) : ( // Preview mode: Full HTML content )} ); } ``` ### Pattern: Syncing 3D Changes Back to 2D When users move widgets in VR, update the 2D canvas state: ```tsx function handleWidgetMove(widgetId: string, newPos3D: [number, number, number]) { // Convert 3D position back to 2D const pos2D = toDOMPosition(newPos3D); // Update the shared canvas store useCanvasStore.getState().updateWidget(widgetId, { x: pos2D.x, y: pos2D.y, }); } ``` ### Pattern: Resolution Scaling for VR HTML content can look pixelated in VR. Use resolution scaling: ```tsx const VR_RESOLUTION_SCALE = 2.5; function getWidgetResolutionScale(width: number, height: number): number { const maxDimension = Math.max(width, height); if (maxDimension <= 600) return VR_RESOLUTION_SCALE; // Scale down for large widgets to save memory return Math.max(1.5, VR_RESOLUTION_SCALE * (600 / maxDimension)); } // Render at higher resolution, scale down visually const scaledWidth = widget.width * resolutionScale; const scaledHeight = widget.height * resolutionScale; const inverseScale = 1 / resolutionScale; {/* Content renders at higher resolution */} ``` ## Reference Files | File | Purpose | |------|---------| | `src/utils/spatialCoordinates.ts` | All coordinate conversion utilities | | `src/state/useSpatialModeStore.ts` | Spatial mode state (desktop/vr/ar) | | `src/pages/CanvasPage.tsx` | Parallel renderer orchestration | | `src/components/spatial/SpatialCanvas.tsx` | WebGL/Three.js canvas | | `src/components/spatial/SpatialScene.tsx` | 3D scene composition | | `src/components/spatial/SpatialWidgetContainer.tsx` | Widget rendering in 3D | | `src/components/canvas/CanvasRenderer.tsx` | DOM-based 2D rendering | ## Troubleshooting ### Issue: VR shows flat screen instead of immersive 3D **Cause**: `` components rendering in XR session **Fix**: Check `isPresenting` and skip Html when true ### Issue: Widget positions don't match between 2D and 3D **Cause**: Missing Y-axis inversion or eye height offset **Fix**: Use `toSpatialPosition()` / `toDOMPosition()` consistently ### Issue: Widgets look pixelated in VR **Cause**: Default resolution too low for VR displays **Fix**: Apply `VR_RESOLUTION_SCALE` multiplier to Html content ### Issue: Widget interactions don't work in VR **Cause**: Pointer events not reaching 3D meshes **Fix**: Ensure `pointerEvents: 'auto'` on Html or use `onClick` on meshes