init
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
# Implementation Reference — Reveal 3D Viewer
|
||||
|
||||
Full copy-paste ready implementations. Copy `skills/reveal-3d/code/reveal/` into an app-local feature folder first, typically `src/features/reveal-3d/`, then import from that local folder. **Pattern B (model browser — auto-loads models) is the default** — use it unless the data has explicit FDM → CAD node linkage.
|
||||
|
||||
---
|
||||
|
||||
## Pattern B (default) — model browser, auto-discovers models via `sdk.models3D.list()`
|
||||
|
||||
Canvas-only component — no providers. `CacheProvider`, `RevealKeepAlive`, and `RevealProvider` live in App.tsx.
|
||||
|
||||
```tsx
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Reveal3DResources,
|
||||
RevealCanvas,
|
||||
type AddCadResourceOptions,
|
||||
} from '@/features/reveal-3d';
|
||||
|
||||
export interface ViewerContentProps {
|
||||
modelId: number;
|
||||
revisionId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas-only — no CacheProvider, RevealKeepAlive, or RevealProvider here.
|
||||
* All providers live in App.tsx so React StrictMode double-invoke completes
|
||||
* at startup before any model loading starts.
|
||||
*/
|
||||
export function ViewerContent({ modelId, revisionId }: ViewerContentProps) {
|
||||
// AddCadResourceOptions is just { modelId, revisionId } — no `type` field.
|
||||
// Do NOT use { type: 'cad', modelId, revisionId } — that is TaggedAddResourceOptions.
|
||||
const resources = useMemo<AddCadResourceOptions[]>(
|
||||
() => [{ modelId, revisionId }],
|
||||
[modelId, revisionId]
|
||||
);
|
||||
const onLoaded = useCallback(() => {}, []);
|
||||
|
||||
return (
|
||||
<RevealCanvas>
|
||||
<Reveal3DResources resources={resources} onModelsLoaded={onLoaded} />
|
||||
</RevealCanvas>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### src/App.tsx — providers + model browser
|
||||
|
||||
```tsx
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { useDune } from '@cognite/dune';
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||
import type { Model3D, Revision3D } from '@cognite/sdk';
|
||||
import {
|
||||
CacheProvider,
|
||||
RevealKeepAlive,
|
||||
RevealProvider,
|
||||
type ViewerOptions,
|
||||
} from '@/features/reveal-3d';
|
||||
|
||||
// Lazy-load canvas content — providers are eagerly imported
|
||||
const ViewerContent = lazy(() =>
|
||||
import('./components/ViewerContent').then((m) => ({ default: m.ViewerContent }))
|
||||
);
|
||||
|
||||
// Module-level constants — stable references, never recreated on re-render
|
||||
const BG = new THREE.Color(0x1a1a2e);
|
||||
const VIEWER_OPTIONS: ViewerOptions = {
|
||||
loadingIndicatorStyle: { placement: 'topRight', opacity: 0.1 },
|
||||
antiAliasingHint: 'msaa2+fxaa',
|
||||
ssaoQualityHint: 'medium',
|
||||
};
|
||||
|
||||
type SelectedModel = { modelId: number; revisionId: number };
|
||||
|
||||
// --- Model discovery hooks ---
|
||||
|
||||
function useModels(query?: string) {
|
||||
const { sdk } = useDune();
|
||||
return useInfiniteQuery({
|
||||
queryKey: ['3d-models', query],
|
||||
queryFn: ({ pageParam }: { pageParam?: string }) =>
|
||||
sdk.models3D.list({ limit: 1000, cursor: pageParam }) as Promise<{
|
||||
items: Model3D[];
|
||||
nextCursor?: string;
|
||||
}>,
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (page) => page.nextCursor,
|
||||
enabled: !!sdk,
|
||||
select: useCallback(
|
||||
(data: any) => ({
|
||||
...data,
|
||||
pages: data.pages.map((p: any) => ({
|
||||
...p,
|
||||
items: p.items
|
||||
.map((m: Model3D) => ({ ...m, name: m.name.trim() }))
|
||||
.filter((m: Model3D) =>
|
||||
query ? m.name.toLowerCase().includes(query.toLowerCase()) : true
|
||||
),
|
||||
})),
|
||||
}),
|
||||
[query]
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function useBestRevision(modelId?: number) {
|
||||
const { sdk } = useDune();
|
||||
return useQuery({
|
||||
queryKey: ['3d-revisions', modelId],
|
||||
queryFn: async () => {
|
||||
if (!modelId) return null;
|
||||
const all: Revision3D[] = await sdk.revisions3D
|
||||
.list(modelId)
|
||||
.autoPagingToArray({ limit: -1 });
|
||||
const published = all.filter((r) => r.published);
|
||||
const candidates = published.length ? published : all;
|
||||
return candidates.reduce((best, cur) =>
|
||||
best.createdTime > cur.createdTime ? best : cur
|
||||
) ?? null;
|
||||
},
|
||||
enabled: !!sdk && !!modelId,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Model browser ---
|
||||
//
|
||||
// RULE 1: onSelect MUST be wrapped in useCallback at the call site.
|
||||
// An inline arrow `(m) => setSelected(m)` creates a new reference every render.
|
||||
// The useEffect([..., onSelect]) below fires on every render → infinite loop.
|
||||
//
|
||||
// RULE 2: call onSelect from useEffect, NOT during render.
|
||||
// An if-block during render calling onSelect also causes infinite loops.
|
||||
|
||||
function ModelBrowser({ onSelect }: { onSelect: (m: SelectedModel) => void }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [pendingId, setPendingId] = useState<number>();
|
||||
const { data } = useModels(query);
|
||||
const { data: revision } = useBestRevision(pendingId);
|
||||
const models = data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (revision && pendingId) {
|
||||
onSelect({ modelId: pendingId, revisionId: revision.id });
|
||||
}
|
||||
}, [revision, pendingId, onSelect]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
placeholder="Search models…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
{models.map((m) => (
|
||||
<button key={m.id} onClick={() => setPendingId(m.id)}>
|
||||
{m.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- App ---
|
||||
|
||||
export default function App() {
|
||||
const { sdk: client, isLoading } = useDune();
|
||||
// Memoize on sdk.project — prevents RevealProvider from remounting on
|
||||
// unrelated sdk object reference changes
|
||||
const sdk = useMemo(() => client, [client.project]);
|
||||
|
||||
const [selected, setSelected] = useState<SelectedModel | null>(null);
|
||||
|
||||
// useCallback is mandatory — see ModelBrowser RULE 1 above
|
||||
const handleSelect = useCallback((m: SelectedModel) => {
|
||||
setSelected((prev) => (!prev || prev.modelId !== m.modelId ? m : prev));
|
||||
}, []);
|
||||
|
||||
if (isLoading) return <div>Connecting to CDF…</div>;
|
||||
|
||||
return (
|
||||
// CacheProvider + RevealKeepAlive always mounted → StrictMode double-invoke
|
||||
// completes at startup with no viewer to dispose.
|
||||
// RevealProvider conditionally mounts → finds stable RevealKeepAlive viewerRef.
|
||||
<CacheProvider>
|
||||
<RevealKeepAlive>
|
||||
<div style={{ display: 'flex', height: '100vh' }}>
|
||||
<aside style={{ width: 280, overflowY: 'auto' }}>
|
||||
<ModelBrowser onSelect={handleSelect} />
|
||||
</aside>
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
{selected && (
|
||||
<RevealProvider sdk={sdk} color={BG} viewerOptions={VIEWER_OPTIONS}>
|
||||
<Suspense fallback={<div>Loading viewer…</div>}>
|
||||
<ViewerContent
|
||||
modelId={selected.modelId}
|
||||
revisionId={selected.revisionId}
|
||||
/>
|
||||
</Suspense>
|
||||
</RevealProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</RevealKeepAlive>
|
||||
</CacheProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern A (fallback) — FDM auto-discover from an asset instance
|
||||
|
||||
Use only when you have a `DMInstanceRef` and the instance has `CogniteVisualizable.object3D → CogniteCADNode` linkage. Otherwise use Pattern B above.
|
||||
|
||||
### src/components/ViewerContent.tsx (FDM variant)
|
||||
|
||||
```tsx
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type { DMInstanceRef } from '@cognite/reveal';
|
||||
import {
|
||||
Reveal3DResources,
|
||||
RevealCanvas,
|
||||
useModelsForInstanceQuery,
|
||||
type AddCadResourceOptions,
|
||||
type TaggedAddResourceOptions,
|
||||
} from '@/features/reveal-3d';
|
||||
|
||||
function pickFirstCad(models: TaggedAddResourceOptions[]): AddCadResourceOptions | undefined {
|
||||
const m = models[0];
|
||||
return m?.type === 'cad'
|
||||
? { ...m.addOptions, styling: { default: { renderGhosted: true } } }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function ViewerContent({ instance }: { instance: DMInstanceRef }) {
|
||||
const { data: models, isLoading } = useModelsForInstanceQuery(instance);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const selected = useMemo(() => pickFirstCad(models ?? []), [models]);
|
||||
const resources = useMemo(() => (selected ? [selected] : []), [selected]);
|
||||
const onLoaded = useCallback(() => setLoaded(true), []);
|
||||
|
||||
if (isLoading) return <div>Loading 3D model…</div>;
|
||||
if (!resources.length) return <div>No 3D data linked to this instance.</div>;
|
||||
|
||||
return (
|
||||
<RevealCanvas>
|
||||
<Reveal3DResources resources={resources} onModelsLoaded={onLoaded} />
|
||||
</RevealCanvas>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### src/App.tsx (FDM variant)
|
||||
|
||||
Same `CacheProvider` / `RevealKeepAlive` / `RevealProvider` structure as Pattern B.
|
||||
Pass `instance: DMInstanceRef` to `ViewerContent` instead of `modelId` / `revisionId`.
|
||||
|
||||
```tsx
|
||||
import { useMemo, useState } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { useDune } from '@cognite/dune';
|
||||
import type { DMInstanceRef } from '@cognite/reveal';
|
||||
import {
|
||||
CacheProvider,
|
||||
RevealKeepAlive,
|
||||
RevealProvider,
|
||||
type ViewerOptions,
|
||||
} from '@/features/reveal-3d';
|
||||
import { ViewerContent } from './components/ViewerContent';
|
||||
|
||||
const BG = new THREE.Color(0x1a1a2e);
|
||||
const OPTS: ViewerOptions = {
|
||||
loadingIndicatorStyle: { placement: 'topRight', opacity: 0.1 },
|
||||
antiAliasingHint: 'msaa2+fxaa',
|
||||
ssaoQualityHint: 'medium',
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const { sdk: client, isLoading } = useDune();
|
||||
const sdk = useMemo(() => client, [client.project]);
|
||||
// Replace with however you receive the instance ref (prop, route param, selection, etc.)
|
||||
const [instance] = useState<DMInstanceRef | null>(null);
|
||||
|
||||
if (isLoading) return <div>Connecting to CDF…</div>;
|
||||
|
||||
return (
|
||||
<CacheProvider>
|
||||
<RevealKeepAlive>
|
||||
<div style={{ width: '100%', height: '100vh', position: 'relative' }}>
|
||||
{instance && sdk.project && (
|
||||
<RevealProvider sdk={sdk} color={BG} viewerOptions={OPTS}>
|
||||
<ViewerContent instance={instance} />
|
||||
</RevealProvider>
|
||||
)}
|
||||
</div>
|
||||
</RevealKeepAlive>
|
||||
</CacheProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,136 @@
|
||||
# Vite Configuration for @cognite/reveal in a Flows monorepo app
|
||||
|
||||
## src/main.tsx — process polyfill must be the very first two lines
|
||||
|
||||
Prepend these two lines before any other import:
|
||||
|
||||
```tsx
|
||||
import process from 'process';
|
||||
(window as unknown as Record<string, unknown>).process = process;
|
||||
|
||||
// all other existing imports below ↓
|
||||
```
|
||||
|
||||
The rest of `main.tsx` stays as-is. Order matters — the polyfill must run before any module that reads `process`.
|
||||
|
||||
---
|
||||
|
||||
## vite.config.ts — standalone config (not mergeConfig)
|
||||
|
||||
Flows apps use a standalone `vite.config.ts` (not a shared base config from the monorepo root).
|
||||
Replace the file entirely with the following:
|
||||
|
||||
```typescript
|
||||
import path from 'node:path';
|
||||
|
||||
import { fusionOpenPlugin } from '@cognite/dune/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
import mkcert from 'vite-plugin-mkcert';
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [react(), mkcert(), fusionOpenPlugin(), tailwindcss()],
|
||||
define: {
|
||||
// Some CJS deps use `global` instead of `globalThis`
|
||||
'process.env': {},
|
||||
'process.platform': JSON.stringify(''),
|
||||
'process.version': JSON.stringify(''),
|
||||
global: 'globalThis',
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
|
||||
// --- Node built-in polyfills ---
|
||||
// Use explicit package aliases, not vite-plugin-node-polyfills.
|
||||
// The plugin introduces transitive dep conflicts ("Could not resolve 'inherits'").
|
||||
// The `process`, `util`, and `assert` packages must be in app dependencies.
|
||||
util: 'util/',
|
||||
assert: 'assert/',
|
||||
process: 'process/browser',
|
||||
|
||||
// --- Single Three.js instance ---
|
||||
// @cognite/reveal bundles its own Three.js copy. Without this alias, the app
|
||||
// and Reveal load two separate copies → "Multiple instances of Three.js" warning
|
||||
// and broken rendering. Requires `three` as a direct app dependency.
|
||||
three: path.resolve(__dirname, 'node_modules/three/build/three.module.js'),
|
||||
},
|
||||
|
||||
// pnpm uses a virtual store with symlinks. Packages that symlink to different
|
||||
// paths can resolve to separate module instances even for the same package.
|
||||
// `dedupe` forces a single physical copy for all pre-bundles.
|
||||
// Missing `react`/`react-dom` here causes ReactCurrentDispatcher errors.
|
||||
// Missing `three` causes "Multiple instances" warnings.
|
||||
dedupe: ['react', 'react-dom', 'react/jsx-runtime', '@tanstack/react-query', 'three'],
|
||||
|
||||
conditions: ['import', 'module', 'browser', 'default'],
|
||||
},
|
||||
optimizeDeps: {
|
||||
// Do not exclude the copied Reveal feature bundle. Let Vite pre-bundle
|
||||
// React, React Query, Three.js, and Reveal dependencies as one graph.
|
||||
esbuildOptions: {
|
||||
define: { global: 'globalThis' },
|
||||
},
|
||||
include: [
|
||||
// Vite can't auto-discover bare polyfill imports (no source file imports them
|
||||
// directly). List them explicitly so esbuild pre-bundles them.
|
||||
'process',
|
||||
'util',
|
||||
'assert',
|
||||
// Heavy/complex deps — explicit listing speeds up cold starts
|
||||
'three',
|
||||
'@cognite/reveal',
|
||||
// React ecosystem — pre-bundling creates the CJS→ESM singleton all deps share.
|
||||
// If any of these are missing, a dep that imports React raw can get a second copy.
|
||||
'react',
|
||||
'react-dom',
|
||||
'@tanstack/react-query',
|
||||
],
|
||||
},
|
||||
server: {
|
||||
port: 3002,
|
||||
},
|
||||
worker: {
|
||||
// @cognite/reveal spawns ES module web workers. Without 'es' format they fail
|
||||
// silently — black screen with no console error.
|
||||
format: 'es',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Why each setting is needed
|
||||
|
||||
|
||||
| Setting | Reason |
|
||||
| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `util/`, `assert/`, `process/browser` aliases | Browser-compatible replacements for Node built-ins. Packages must be in `dependencies`. Do NOT use `vite-plugin-node-polyfills` — causes "Could not resolve 'inherits'" |
|
||||
| `process` polyfill in main.tsx first | `@cognite/reveal` deps call `process.env` at **runtime** (not build-time). The `define` replacements handle build-time; the window assignment handles runtime |
|
||||
| `define.global = 'globalThis'` | Some CJS deps use `global` instead of `globalThis` |
|
||||
| `resolve.alias.three` | Single Three.js instance — without this, Reveal's bundled copy and the app's copy conflict |
|
||||
| `resolve.dedupe` with react + react-dom + react/jsx-runtime | pnpm symlinks can create separate module instances. `dedupe` forces one copy. Critical — missing these causes `ReactCurrentDispatcher` errors |
|
||||
| `resolve.dedupe` with three | Ensures the copied feature bundle and Reveal share the same Three.js |
|
||||
| `optimizeDeps.include` for process/util/assert | No source file imports them, so Vite cannot auto-discover them for pre-bundling |
|
||||
| `optimizeDeps.include` for react + react-dom + @tanstack/react-query | Converts CJS → ESM and creates a single shared instance. All pre-bundled deps that import React get the same copy |
|
||||
| `worker.format: 'es'` | Reveal spawns ES module workers; Vite defaults to IIFE/UMD which breaks them |
|
||||
| `conditions: ['import', 'module', 'browser', 'default']` | Ensures browser ESM variants are preferred over CJS/Node variants |
|
||||
|
||||
|
||||
## Common mistakes that break the setup
|
||||
|
||||
|
||||
| Mistake | Symptom | Fix |
|
||||
| ---------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| Installing `three` without checking `@cognite/reveal`'s peer requirement | `unmet peer three@0.180.0: found 0.177.x` warning; potential rendering bugs | After install, compare versions; `pnpm add three@^<peer-version>` if mismatched |
|
||||
| Not adding `ajv` as a direct dependency | `unmet peer ajv@>=8: found 6.x` | `pnpm add ajv` (installs `^8`) in the app |
|
||||
| Copied Reveal feature folder in `optimizeDeps.exclude` | `ReactCurrentDispatcher` undefined or `No QueryClient set` | Remove from `exclude` and let Vite pre-bundle shared deps |
|
||||
| `vite-plugin-node-polyfills` instead of manual aliases | `Could not resolve "inherits"` on transitive deps | Remove the plugin; add `util`, `assert`, `process` to dependencies and use aliases |
|
||||
| `RevealKeepAlive` inside conditional component | `ObjectUnsubscribedError: object unsubscribed` at model load | Move `CacheProvider` + `RevealKeepAlive` to always-mounted app/page level |
|
||||
| Inline arrow as `onSelect`/`onLoad` prop | `Maximum update depth exceeded` | `useCallback` at call site; call `onSelect` from `useEffect` inside model browser, never from render |
|
||||
| Model browser calls `onSelect` during render (`if (revision) onSelect(...)`) | `Maximum update depth exceeded` | Move to `useEffect([revision, onSelect])` |
|
||||
| Missing `worker.format: 'es'` | Black screen, no error | Add `worker: { format: 'es' }` |
|
||||
| `react`/`react-dom` missing from `resolve.dedupe` | `ReactCurrentDispatcher` in pnpm monorepo | Add both to `dedupe` |
|
||||
| Container has no height | Canvas collapses to 0px, nothing renders | Add `height: '70vh'` (or flex/grid height) to the parent element |
|
||||
Reference in New Issue
Block a user