Viewer must be editor-agnostic — controlled from outside via props and children.
Applies to: packages/viewer/**.
@pascal-app/viewer is a standalone 3D canvas library. It must never know about editor-specific features, UI state, or tools. This keeps it usable in the read-only /viewer/[id] route and in any future embedding context.
The viewer is controlled from outside. It exposes control points (props, callbacks, children). It never reaches into
apps/editor.
// ❌ Never import from the editor app
import { useEditor } from '@/store/use-editor'
import { ToolManager } from '@/components/tools/tool-manager'
// ❌ Never reference editor-specific concepts
if (isEditorMode) { … }The editor mounts the viewer and passes what it needs:
// apps/editor/components/editor-canvas.tsx ✅
import { Viewer } from '@pascal-app/viewer'
import { ToolManager } from '../tools/tool-manager'
import { useEditor } from '../../store/use-editor'
export function EditorCanvas() {
const { selection } = useViewer()
return (
<Viewer
theme="light"
onSelect={(id) => useViewer.getState().setSelection(id)}
onExport={handleExport}
>
{/* Editor injects tools as children — viewer renders them inside the canvas */}
<ToolManager />
</Viewer>
)
}The viewer accepts children and renders them inside the R3F canvas. This is the extension point for tools, overlays, and editor-specific systems.
The viewer store contains only presentation state:
selection— which nodes are highlightedcameraMode— perspective / orthographiclevelMode— stacked / exploded / solo / manualwallMode— up / cutaway / downtheme— light / dark- Display toggles:
showScans,showGuides,showGrid
If a piece of state is only meaningful inside the editor (e.g. active tool, phase, edit mode) — it belongs in useEditor, not useViewer.
When an editor feature needs to live "inside" the canvas but must not pollute the viewer package, inject it as a child:
// ✅ Editor-specific overlay injected as child
<Viewer>
<SelectionBoxOverlay /> {/* editor only */}
<SnapIndicator /> {/* editor only */}
<ToolManager /> {/* editor only */}
</Viewer>This pattern lets the viewer stay ignorant of these components while they still have access to the R3F context.
- Does this feature make sense in the read-only viewer route?
- Does it reference
useEditor, tool state, or phase/mode? - Could it be passed in as a prop or child instead?
If any answer is "editor-specific", keep it in apps/editor and inject it via children or props.