init
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
---
|
||||
name: reveal-3d
|
||||
description: "Integrates a local Cognite Reveal 3D CAD viewer bundle into Flows apps by copying app-local source code. Use when adding 3D viewer, 3D visualization, Reveal, CAD model, RevealProvider, RevealCanvas, Reveal3DResources, FDM 3D mapping, asset 3D model, model browser, or Cognite 3D content to a Flows application."
|
||||
metadata:
|
||||
argument-hint: "[FDM instance variable name or description, e.g. 'asset' or 'selectedEquipment']"
|
||||
---
|
||||
|
||||
# Reveal 3D Viewer
|
||||
|
||||
Add a Cognite Reveal 3D viewer to a Flows app by copying the bundled source into the target app. Renders CAD models from CDF, with support for model browsing, direct model/revision IDs, or FDM-linked assets.
|
||||
|
||||
FDM instance to visualize: **$ARGUMENTS**
|
||||
|
||||
## Use This When
|
||||
|
||||
The user wants to embed an interactive Cognite Reveal viewer for CDF 3D/CAD content in a Flows app.
|
||||
|
||||
Do **not** use this skill for static diagrams, graph visualizations, or unrelated custom Three.js scenes.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- The app uses React + TypeScript and is wrapped in `@cognite/dune` auth (Flows auth).
|
||||
- The app has a `QueryClientProvider` from `@tanstack/react-query`.
|
||||
- The CDF project has 3D models, or the user has supplied direct model/revision IDs.
|
||||
- For FDM-linked 3D, the instance must be linked through Core DM (`CogniteVisualizable.object3D` -> `CogniteCADNode`).
|
||||
|
||||
## Integration Workflow
|
||||
|
||||
Follow these steps in order. Adapt paths to the target app's conventions instead of inventing new ones.
|
||||
|
||||
1. **Inspect the target app.** Read `package.json`, `vite.config.ts`, `src/main.tsx`, and the app's folder/alias conventions.
|
||||
2. **Install missing dependencies** with the app's package manager. See [Dependencies](#dependencies). Reuse existing pinned React, Flows, SDK, and React Query versions.
|
||||
3. **Copy the bundle into the app.** Copy every file from `skills/reveal-3d/code/reveal/` into an app-local feature folder, typically:
|
||||
|
||||
```text
|
||||
src/features/reveal-3d/
|
||||
```
|
||||
|
||||
4. **Import from the local folder**, never from the skill directory or the old external package. With a typical `@/*` alias:
|
||||
|
||||
```tsx
|
||||
import { CacheProvider, RevealKeepAlive, RevealProvider } from '@/features/reveal-3d';
|
||||
```
|
||||
|
||||
5. **Configure Vite and `main.tsx`.** Read [vite-config.md](references/vite-config.md) and apply the process polyfill, manual `process`/`util`/`assert` aliases, `three` alias, dedupe settings, and `worker.format: 'es'`.
|
||||
6. **Choose the implementation pattern.** Use Pattern B (model browser or direct model ID) unless you already have a `DMInstanceRef` and confirmed Core DM 3D linkage. For full examples, read [implementation.md](references/implementation.md).
|
||||
7. **Keep provider placement stable.** `CacheProvider` and `RevealKeepAlive` are always mounted at page/app level. `RevealProvider` is conditional, only when a model is selected or linked.
|
||||
8. **Run typecheck and build** (`tsc --noEmit`, `pnpm build`, etc.) and fix any copied-import or dependency issues.
|
||||
|
||||
## Minimal Example
|
||||
|
||||
```tsx
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { CogniteClient } from '@cognite/sdk';
|
||||
import {
|
||||
CacheProvider,
|
||||
Reveal3DResources,
|
||||
RevealCanvas,
|
||||
RevealKeepAlive,
|
||||
RevealProvider,
|
||||
type AddCadResourceOptions,
|
||||
} from '@/features/reveal-3d';
|
||||
|
||||
type SelectedModel = { modelId: number; revisionId: number };
|
||||
|
||||
function ViewerContent({ modelId, revisionId }: SelectedModel) {
|
||||
const resources = useMemo<AddCadResourceOptions[]>(
|
||||
() => [{ modelId, revisionId }],
|
||||
[modelId, revisionId]
|
||||
);
|
||||
const onLoaded = useCallback(() => {}, []);
|
||||
|
||||
return (
|
||||
<RevealCanvas>
|
||||
<Reveal3DResources resources={resources} onModelsLoaded={onLoaded} />
|
||||
</RevealCanvas>
|
||||
);
|
||||
}
|
||||
|
||||
export function ViewerPage({
|
||||
sdk,
|
||||
selected,
|
||||
}: {
|
||||
sdk: CogniteClient;
|
||||
selected: SelectedModel | null;
|
||||
}) {
|
||||
const memoizedSdk = useMemo(() => sdk, [sdk.project]);
|
||||
|
||||
return (
|
||||
<CacheProvider>
|
||||
<RevealKeepAlive>
|
||||
<div style={{ width: '100%', height: '70vh', position: 'relative' }}>
|
||||
{selected && (
|
||||
<RevealProvider sdk={memoizedSdk}>
|
||||
<ViewerContent
|
||||
modelId={selected.modelId}
|
||||
revisionId={selected.revisionId}
|
||||
/>
|
||||
</RevealProvider>
|
||||
)}
|
||||
</div>
|
||||
</RevealKeepAlive>
|
||||
</CacheProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
Suggested versions are starting points. If the target app already pins compatible versions, defer to the app.
|
||||
|
||||
| Package | Suggested version | Purpose |
|
||||
|---------|-------------------|---------|
|
||||
| `react` / `react-dom` | app version | UI framework |
|
||||
| `@cognite/dune` | app version | Authenticated SDK via `useDune()` |
|
||||
| `@cognite/reveal` | `^4.30.0` | Reveal viewer runtime |
|
||||
| `@cognite/sdk` | `^10.0.0` | CDF API client |
|
||||
| `@tanstack/react-query` | `^5.90.21` | Reveal/FDM data fetching hooks |
|
||||
| `three` | `^0.180.0` | Three.js singleton used by Reveal |
|
||||
| `process`, `util`, `assert` | latest | Browser polyfills for Reveal dependencies |
|
||||
| `ajv` | `^8` | Avoids older transitive AJV resolution in monorepos |
|
||||
| `@types/three` | latest dev dep | TypeScript types |
|
||||
|
||||
Example install (pnpm; adapt to the app's package manager):
|
||||
|
||||
```bash
|
||||
pnpm add @cognite/reveal @cognite/sdk @tanstack/react-query three process util assert ajv
|
||||
pnpm add -D @types/three
|
||||
```
|
||||
|
||||
After install, check `@cognite/reveal`'s `three` peer requirement and align `three` if needed.
|
||||
|
||||
Do **not** install `vite-plugin-node-polyfills`; use the explicit Vite aliases in [vite-config.md](references/vite-config.md).
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- `ViewerContent` contains only `RevealCanvas` and `Reveal3DResources`; no providers.
|
||||
- `resources` passed to `Reveal3DResources` must be memoized with `useMemo`.
|
||||
- `onModelsLoaded`, `onSelect`, and similar callbacks must be memoized with `useCallback`.
|
||||
- The SDK passed to `RevealProvider` must be memoized with `useMemo` keyed on `client.project`.
|
||||
- `RevealCanvas` fills its parent; the parent must have an explicit height.
|
||||
- Lazy-load canvas-heavy viewer content with `React.lazy` + `Suspense` when adding a route/page.
|
||||
|
||||
## Advanced Reference
|
||||
|
||||
For the copied bundle API and exports, read `code/README.md`.
|
||||
|
||||
For model browser and FDM-linked implementations, read `references/implementation.md`.
|
||||
|
||||
For Vite, worker, polyfill, and troubleshooting details, read `references/vite-config.md`.
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] All files from `skills/reveal-3d/code/reveal/` were copied into an app-local feature folder.
|
||||
- [ ] Imports point to the app-local folder (e.g. `@/features/reveal-3d`).
|
||||
- [ ] The app does not import Reveal helpers from the old external package.
|
||||
- [ ] Required dependencies are present in `package.json`.
|
||||
- [ ] `main.tsx` starts with the `process` polyfill before other imports.
|
||||
- [ ] `vite.config.ts` uses manual aliases, dedupe, `three` singleton alias, and `worker.format: 'es'`.
|
||||
- [ ] `CacheProvider` and `RevealKeepAlive` are always mounted; `RevealProvider` is conditional when model selection is conditional.
|
||||
- [ ] The viewer container has an explicit height.
|
||||
- [ ] Typecheck and build pass.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Reveal 3D Code Bundle
|
||||
|
||||
This bundle is copied from the Reveal source tree.
|
||||
|
||||
Copy the contents of `code/reveal/` into an app-local feature folder, typically:
|
||||
|
||||
```text
|
||||
src/features/reveal-3d/
|
||||
```
|
||||
|
||||
Then import from the app-local folder:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
CacheProvider,
|
||||
Reveal3DResources,
|
||||
RevealCanvas,
|
||||
RevealKeepAlive,
|
||||
RevealProvider,
|
||||
} from '@/features/reveal-3d';
|
||||
```
|
||||
|
||||
Do not import from `skills/reveal-3d/code/reveal` in the target app.
|
||||
|
||||
## Dependencies
|
||||
|
||||
The copied code expects these app dependencies:
|
||||
|
||||
- `@cognite/reveal`
|
||||
- `@cognite/sdk`
|
||||
- `@tanstack/react-query`
|
||||
- `react`
|
||||
- `react-dom`
|
||||
- `three`
|
||||
|
||||
The Vite setup for Reveal also needs `process`, `util`, `assert`, and `ajv`.
|
||||
Install `@types/three` as a dev dependency for TypeScript apps.
|
||||
|
||||
## Public Exports
|
||||
|
||||
The public API is exported from `index.ts`, including:
|
||||
|
||||
- Components: `RevealProvider`, `RevealCanvas`, `Reveal3DResources`, `RevealKeepAlive`
|
||||
- Providers/hooks: `CacheProvider`, `useReveal`, `useModelsForInstanceQuery`, `useFdmAssetMappings`
|
||||
- Types: `AddCadResourceOptions`, `TaggedAddResourceOptions`, `ViewerOptions`
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { Node3D } from '@cognite/sdk';
|
||||
import type { DMInstanceRef } from '@cognite/reveal';
|
||||
import { chunk, executeParallel } from '../utils/executeParallel';
|
||||
|
||||
const ASSET_MAPPING_CHUNK_SIZE = 1000;
|
||||
|
||||
/**
|
||||
* Multi-level asset mapping cache with split-chunk strategy.
|
||||
*
|
||||
* Three-way indexing:
|
||||
* - By model+revision: Fast lookup for all mappings in a model
|
||||
* - By asset instance (space:externalId): Fast lookup for all nodes belonging to an asset
|
||||
* - By node ID: Fast lookup for individual node metadata
|
||||
*/
|
||||
export class AssetMappingCache {
|
||||
private byModelCache = new Map<string, Map<string, Node3D[]>>();
|
||||
private byAssetCache = new Map<string, Node3D[]>();
|
||||
private byNodeCache = new Map<string, Node3D>();
|
||||
|
||||
async getOrFetch(
|
||||
modelId: number,
|
||||
revisionId: number,
|
||||
assetInstances: DMInstanceRef[],
|
||||
fetchFn: (
|
||||
modelId: number,
|
||||
revisionId: number,
|
||||
instances: DMInstanceRef[]
|
||||
) => Promise<Map<string, Node3D[]>>
|
||||
): Promise<Map<string, Node3D[]>> {
|
||||
const modelKey = this.createModelKey(modelId, revisionId);
|
||||
|
||||
const cachedModel = this.byModelCache.get(modelKey);
|
||||
if (cachedModel) {
|
||||
const { cached, uncached } = this.splitCachedAndMissing(
|
||||
assetInstances,
|
||||
cachedModel
|
||||
);
|
||||
|
||||
if (uncached.length === 0) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const fetched = await this.fetchAndCache(
|
||||
modelId,
|
||||
revisionId,
|
||||
uncached,
|
||||
fetchFn
|
||||
);
|
||||
|
||||
return this.mergeMappings(cached, fetched);
|
||||
}
|
||||
|
||||
return this.fetchAndCache(modelId, revisionId, assetInstances, fetchFn);
|
||||
}
|
||||
|
||||
getCachedAssetMapping(instance: DMInstanceRef): Node3D[] | undefined {
|
||||
return this.byAssetCache.get(this.createAssetKey(instance));
|
||||
}
|
||||
|
||||
getCachedNode(modelId: number, revisionId: number, treeIndex: number): Node3D | undefined {
|
||||
const key = this.createNodeKey(modelId, revisionId, treeIndex);
|
||||
return this.byNodeCache.get(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.byModelCache.clear();
|
||||
this.byAssetCache.clear();
|
||||
this.byNodeCache.clear();
|
||||
}
|
||||
|
||||
clearModel(modelId: number, revisionId: number): void {
|
||||
const modelKey = this.createModelKey(modelId, revisionId);
|
||||
this.byModelCache.delete(modelKey);
|
||||
}
|
||||
|
||||
private createAssetKey(instance: DMInstanceRef): string {
|
||||
return `${instance.space}:${instance.externalId}`;
|
||||
}
|
||||
|
||||
private createModelKey(modelId: number, revisionId: number): string {
|
||||
return `${modelId}/${revisionId}`;
|
||||
}
|
||||
|
||||
private createNodeKey(modelId: number, revisionId: number, treeIndex: number): string {
|
||||
return `${modelId}/${revisionId}/${treeIndex}`;
|
||||
}
|
||||
|
||||
private splitCachedAndMissing(
|
||||
assetInstances: DMInstanceRef[],
|
||||
cachedModel: Map<string, Node3D[]>
|
||||
): {
|
||||
cached: Map<string, Node3D[]>;
|
||||
uncached: DMInstanceRef[];
|
||||
} {
|
||||
const cached = new Map<string, Node3D[]>();
|
||||
const uncached: DMInstanceRef[] = [];
|
||||
|
||||
for (const instance of assetInstances) {
|
||||
const assetKey = this.createAssetKey(instance);
|
||||
const cachedNodes = cachedModel.get(assetKey);
|
||||
if (cachedNodes) {
|
||||
cached.set(assetKey, cachedNodes);
|
||||
} else {
|
||||
uncached.push(instance);
|
||||
}
|
||||
}
|
||||
|
||||
return { cached, uncached };
|
||||
}
|
||||
|
||||
private async fetchAndCache(
|
||||
modelId: number,
|
||||
revisionId: number,
|
||||
assetInstances: DMInstanceRef[],
|
||||
fetchFn: (
|
||||
modelId: number,
|
||||
revisionId: number,
|
||||
instances: DMInstanceRef[]
|
||||
) => Promise<Map<string, Node3D[]>>
|
||||
): Promise<Map<string, Node3D[]>> {
|
||||
const chunks = chunk(assetInstances, ASSET_MAPPING_CHUNK_SIZE);
|
||||
|
||||
const results = await executeParallel(
|
||||
chunks.map((chunkInstances) => async () => {
|
||||
return fetchFn(modelId, revisionId, chunkInstances);
|
||||
}),
|
||||
3
|
||||
);
|
||||
|
||||
const merged = new Map<string, Node3D[]>();
|
||||
for (const result of results) {
|
||||
if (result) {
|
||||
for (const [assetKey, nodes] of result.entries()) {
|
||||
merged.set(assetKey, nodes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.indexMappings(modelId, revisionId, merged);
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private indexMappings(
|
||||
modelId: number,
|
||||
revisionId: number,
|
||||
mappings: Map<string, Node3D[]>
|
||||
): void {
|
||||
const modelKey = this.createModelKey(modelId, revisionId);
|
||||
|
||||
let modelCache = this.byModelCache.get(modelKey);
|
||||
if (!modelCache) {
|
||||
modelCache = new Map();
|
||||
this.byModelCache.set(modelKey, modelCache);
|
||||
}
|
||||
|
||||
for (const [assetKey, nodes] of mappings.entries()) {
|
||||
modelCache.set(assetKey, nodes);
|
||||
this.byAssetCache.set(assetKey, nodes);
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.treeIndex !== undefined) {
|
||||
const nodeKey = this.createNodeKey(modelId, revisionId, node.treeIndex);
|
||||
this.byNodeCache.set(nodeKey, node);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private mergeMappings(
|
||||
map1: Map<string, Node3D[]>,
|
||||
map2: Map<string, Node3D[]>
|
||||
): Map<string, Node3D[]> {
|
||||
const merged = new Map(map1);
|
||||
for (const [key, value] of map2.entries()) {
|
||||
merged.set(key, value);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createContext, useContext, useMemo } from 'react';
|
||||
import { AssetMappingCache } from './AssetMappingCache';
|
||||
|
||||
interface CacheContextValue {
|
||||
assetMappingCache: AssetMappingCache;
|
||||
}
|
||||
|
||||
const CacheContext = createContext<CacheContextValue | null>(null);
|
||||
|
||||
export function useCacheContext() {
|
||||
const context = useContext(CacheContext);
|
||||
if (!context) {
|
||||
throw new Error('useCacheContext must be used within CacheProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useOptionalCacheContext() {
|
||||
return useContext(CacheContext);
|
||||
}
|
||||
|
||||
interface CacheProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides shared cache instances to the component tree.
|
||||
* Wrap your app or 3D viewer area with this to enable cross-navigation caching
|
||||
* (70-90% reduction in API calls on subsequent visits).
|
||||
*/
|
||||
export function CacheProvider({ children }: CacheProviderProps) {
|
||||
const cacheValue = useMemo(() => ({
|
||||
assetMappingCache: new AssetMappingCache(),
|
||||
}), []);
|
||||
|
||||
return (
|
||||
<CacheContext.Provider value={cacheValue}>
|
||||
{children}
|
||||
</CacheContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import {
|
||||
type CogniteCadModel,
|
||||
TreeIndexNodeCollection,
|
||||
NumericRange,
|
||||
} from '@cognite/reveal';
|
||||
import { useReveal } from '../hooks/useReveal';
|
||||
import type { AddCadResourceOptions, InstanceStylingGroup } from '../types';
|
||||
import { useFdmAssetMappings } from '../hooks/useFdmMappings';
|
||||
|
||||
interface Reveal3DResourcesProps {
|
||||
resources: AddCadResourceOptions[];
|
||||
instanceStyling?: InstanceStylingGroup[];
|
||||
onModelsLoaded?: () => void;
|
||||
}
|
||||
|
||||
export function Reveal3DResources({
|
||||
resources,
|
||||
instanceStyling = [],
|
||||
onModelsLoaded,
|
||||
}: Reveal3DResourcesProps) {
|
||||
const viewer = useReveal();
|
||||
const loadedModelsRef = useRef<CogniteCadModel[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewer || resources.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cancelledRef = { current: false };
|
||||
|
||||
const loadModels = async () => {
|
||||
const modelPromises = resources.map(async (resource) => {
|
||||
if (cancelledRef.current) return null;
|
||||
|
||||
try {
|
||||
const existing = viewer.models.find(
|
||||
(m) =>
|
||||
m.modelId === resource.modelId &&
|
||||
m.revisionId === resource.revisionId
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
return existing as CogniteCadModel;
|
||||
}
|
||||
|
||||
const addModelOptions: {
|
||||
modelId: number;
|
||||
revisionId: number;
|
||||
geometryFilter?: typeof resource.geometryFilter;
|
||||
} = {
|
||||
modelId: resource.modelId,
|
||||
revisionId: resource.revisionId,
|
||||
};
|
||||
if (resource.geometryFilter) {
|
||||
addModelOptions.geometryFilter = resource.geometryFilter;
|
||||
}
|
||||
const model = await viewer.addCadModel(addModelOptions);
|
||||
|
||||
if (cancelledRef.current) {
|
||||
viewer.removeModel(model);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (resource.styling?.default) {
|
||||
const { renderGhosted, renderInFront } = resource.styling.default;
|
||||
if (renderGhosted !== undefined) {
|
||||
model.setDefaultNodeAppearance({
|
||||
renderGhosted,
|
||||
renderInFront: renderInFront ?? false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return model;
|
||||
} catch (error) {
|
||||
console.error('Error loading CAD model:', error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const loadedModels = (await Promise.all(modelPromises)).filter(
|
||||
(model): model is CogniteCadModel => model !== null
|
||||
);
|
||||
|
||||
if (!cancelledRef.current) {
|
||||
loadedModelsRef.current = loadedModels;
|
||||
|
||||
if (loadedModels.length > 0) {
|
||||
viewer.fitCameraToModels(loadedModels, 0);
|
||||
}
|
||||
|
||||
if (onModelsLoaded) {
|
||||
onModelsLoaded();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadModels();
|
||||
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
const modelsToRemove = loadedModelsRef.current;
|
||||
for (const model of modelsToRemove) {
|
||||
try {
|
||||
viewer.removeModel(model);
|
||||
} catch (error) {
|
||||
console.error('Error removing model:', error);
|
||||
}
|
||||
}
|
||||
loadedModelsRef.current = [];
|
||||
};
|
||||
}, [viewer, resources, onModelsLoaded]);
|
||||
|
||||
useApplyInstanceStyling(viewer, loadedModelsRef.current, instanceStyling);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function useApplyInstanceStyling(
|
||||
viewer: ReturnType<typeof useReveal>,
|
||||
loadedModels: CogniteCadModel[],
|
||||
instanceStyling: InstanceStylingGroup[]
|
||||
) {
|
||||
const appliedCollectionsRef = useRef<
|
||||
Map<CogniteCadModel, TreeIndexNodeCollection[]>
|
||||
>(new Map());
|
||||
|
||||
const fdmInstances =
|
||||
instanceStyling.flatMap((group) => group.fdmAssetExternalIds || []) || [];
|
||||
|
||||
const modelOptions = loadedModels.map((model) => ({
|
||||
type: 'cad' as const,
|
||||
modelId: model.modelId,
|
||||
revisionId: model.revisionId,
|
||||
}));
|
||||
|
||||
const { data: assetMappings } = useFdmAssetMappings(
|
||||
fdmInstances,
|
||||
modelOptions
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewer || loadedModels.length === 0) return;
|
||||
|
||||
const hasFdmInstances = instanceStyling.some(
|
||||
(g) => g.fdmAssetExternalIds && g.fdmAssetExternalIds.length > 0
|
||||
);
|
||||
|
||||
if (!hasFdmInstances) {
|
||||
for (const [model, collections] of appliedCollectionsRef.current.entries()) {
|
||||
for (const collection of collections) {
|
||||
try { model.unassignStyledNodeCollection(collection); } catch { /* already removed */ }
|
||||
}
|
||||
}
|
||||
appliedCollectionsRef.current.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!assetMappings) return;
|
||||
|
||||
for (const [model, collections] of appliedCollectionsRef.current.entries()) {
|
||||
for (const collection of collections) {
|
||||
try { model.unassignStyledNodeCollection(collection); } catch { /* already removed */ }
|
||||
}
|
||||
}
|
||||
appliedCollectionsRef.current.clear();
|
||||
|
||||
for (const stylingGroup of instanceStyling) {
|
||||
if (!stylingGroup.fdmAssetExternalIds || !stylingGroup.style.cad) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const appearance = stylingGroup.style.cad;
|
||||
|
||||
for (const model of loadedModels) {
|
||||
const modelMapping = assetMappings.find(
|
||||
(m) =>
|
||||
m.modelId === model.modelId && m.revisionId === model.revisionId
|
||||
);
|
||||
|
||||
if (!modelMapping) continue;
|
||||
|
||||
const nodeCollection = new TreeIndexNodeCollection();
|
||||
const indexSet = nodeCollection.getIndexSet();
|
||||
|
||||
for (const instance of stylingGroup.fdmAssetExternalIds) {
|
||||
const nodes = modelMapping.mappings.get(`${instance.space}:${instance.externalId}`);
|
||||
if (!nodes) continue;
|
||||
|
||||
for (const node of nodes) {
|
||||
if (
|
||||
node.treeIndex !== undefined &&
|
||||
node.subtreeSize !== undefined
|
||||
) {
|
||||
const range = new NumericRange(node.treeIndex, node.subtreeSize);
|
||||
indexSet.addRange(range);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (indexSet.count > 0) {
|
||||
nodeCollection.updateSet(indexSet);
|
||||
model.assignStyledNodeCollection(nodeCollection, appearance);
|
||||
|
||||
const existing = appliedCollectionsRef.current.get(model) ?? [];
|
||||
existing.push(nodeCollection);
|
||||
appliedCollectionsRef.current.set(model, existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [viewer, loadedModels, instanceStyling, assetMappings]);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { type ReactNode, type ReactElement, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useRevealContext } from '../hooks/useRevealContext';
|
||||
|
||||
export function RevealCanvas({
|
||||
children,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
}): ReactElement {
|
||||
const { viewer } = useRevealContext();
|
||||
const parentElement = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (parentElement.current !== null) {
|
||||
parentElement.current.appendChild(viewer.domElement);
|
||||
}
|
||||
}, [viewer]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ width: '100%', height: '100%', overflow: 'hidden' }}
|
||||
ref={parentElement}
|
||||
>
|
||||
{createPortal(children, viewer.domElement)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useRef, useEffect, createContext, useContext, useCallback } from 'react';
|
||||
import type { Cognite3DViewer } from '@cognite/reveal';
|
||||
import type { CogniteClient } from '@cognite/sdk';
|
||||
|
||||
interface RevealKeepAliveContextValue {
|
||||
getOrCreateViewer: (
|
||||
sdk: CogniteClient,
|
||||
createViewer: () => Cognite3DViewer
|
||||
) => Cognite3DViewer;
|
||||
isMounted: () => boolean;
|
||||
}
|
||||
|
||||
const RevealKeepAliveContext = createContext<RevealKeepAliveContextValue | null>(
|
||||
null
|
||||
);
|
||||
|
||||
export function useRevealKeepAlive() {
|
||||
const context = useContext(RevealKeepAliveContext);
|
||||
if (!context) {
|
||||
throw new Error('useRevealKeepAlive must be used within RevealKeepAlive');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns null when not inside a RevealKeepAlive provider.
|
||||
* Used by RevealProvider to conditionally reuse a kept-alive viewer.
|
||||
*/
|
||||
export function useOptionalRevealKeepAlive(): RevealKeepAliveContextValue | null {
|
||||
return useContext(RevealKeepAliveContext);
|
||||
}
|
||||
|
||||
interface RevealKeepAliveProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the Cognite3DViewer instance alive across component unmounts,
|
||||
* eliminating viewer reinitialization when navigating between assets (~2-3s saving).
|
||||
*
|
||||
* The viewer persists in a ref that survives child unmount/remount cycles.
|
||||
* Models are managed separately (added/removed as needed).
|
||||
* Disposal is deferred to survive React StrictMode's mount→unmount→remount cycle.
|
||||
*/
|
||||
export function RevealKeepAlive({ children }: RevealKeepAliveProps) {
|
||||
const viewerRef = useRef<Cognite3DViewer | null>(null);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
const getOrCreateViewer = useCallback(
|
||||
(_sdk: CogniteClient, createViewer: () => Cognite3DViewer) => {
|
||||
if (!viewerRef.current) {
|
||||
viewerRef.current = createViewer();
|
||||
}
|
||||
return viewerRef.current;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const isMounted = useCallback(() => {
|
||||
return mountedRef.current;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (viewerRef.current) {
|
||||
viewerRef.current.dispose();
|
||||
viewerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<RevealKeepAliveContext.Provider value={{ getOrCreateViewer, isMounted }}>
|
||||
{children}
|
||||
</RevealKeepAliveContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useRef, useCallback, type ReactNode, useMemo } from 'react';
|
||||
import type { InstanceStylingGroup } from '../types';
|
||||
import {
|
||||
InstanceStylingContext,
|
||||
type InstanceStylingController,
|
||||
} from './instanceStylingContext';
|
||||
|
||||
interface InstanceStylingProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider for centralized instance styling management.
|
||||
* Components can register/unregister styling groups and listen for changes.
|
||||
*/
|
||||
export function InstanceStylingProvider({
|
||||
children,
|
||||
}: InstanceStylingProviderProps) {
|
||||
const stylingGroupsRef = useRef<Map<string, InstanceStylingGroup>>(new Map());
|
||||
const listenersRef = useRef<Set<() => void>>(new Set());
|
||||
const nextIdRef = useRef(0);
|
||||
|
||||
const getStylingGroups = useCallback(() => {
|
||||
return Array.from(stylingGroupsRef.current.values());
|
||||
}, []);
|
||||
|
||||
const addEventListener = useCallback((callback: () => void): void => {
|
||||
listenersRef.current.add(callback);
|
||||
// Call once immediately to initialize with current state
|
||||
callback();
|
||||
}, []);
|
||||
|
||||
const removeEventListener = useCallback((callback: () => void): void => {
|
||||
listenersRef.current.delete(callback);
|
||||
}, []);
|
||||
|
||||
const notifyListeners = useCallback(() => {
|
||||
listenersRef.current.forEach((listener) => listener());
|
||||
}, []);
|
||||
|
||||
const registerStylingGroup = useCallback(
|
||||
(group: InstanceStylingGroup): string => {
|
||||
const id = `styling-group-${nextIdRef.current++}`;
|
||||
stylingGroupsRef.current.set(id, group);
|
||||
notifyListeners();
|
||||
return id;
|
||||
},
|
||||
[notifyListeners]
|
||||
);
|
||||
|
||||
const unregisterStylingGroup = useCallback(
|
||||
(id: string): void => {
|
||||
const deleted = stylingGroupsRef.current.delete(id);
|
||||
if (deleted) {
|
||||
notifyListeners();
|
||||
}
|
||||
},
|
||||
[notifyListeners]
|
||||
);
|
||||
|
||||
const controller: InstanceStylingController = useMemo(
|
||||
() => ({
|
||||
getStylingGroups,
|
||||
addEventListener,
|
||||
removeEventListener,
|
||||
registerStylingGroup,
|
||||
unregisterStylingGroup,
|
||||
}),
|
||||
[
|
||||
getStylingGroups,
|
||||
addEventListener,
|
||||
removeEventListener,
|
||||
registerStylingGroup,
|
||||
unregisterStylingGroup,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<InstanceStylingContext.Provider value={controller}>
|
||||
{children}
|
||||
</InstanceStylingContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Cognite3DViewer } from '@cognite/reveal';
|
||||
import type { RevealContextProps } from '../types';
|
||||
import { RevealContext } from './revealContext';
|
||||
import { InstanceStylingProvider } from './InstanceStylingProvider';
|
||||
import { useOptionalRevealKeepAlive } from '../components/RevealKeepAlive';
|
||||
import { RevealSettingsController } from '../settings/RevealSettingsController';
|
||||
|
||||
export function RevealProvider({
|
||||
children,
|
||||
sdk,
|
||||
color,
|
||||
viewerOptions,
|
||||
}: RevealContextProps) {
|
||||
const keepAlive = useOptionalRevealKeepAlive();
|
||||
const keepAliveRef = useRef(keepAlive);
|
||||
keepAliveRef.current = keepAlive;
|
||||
|
||||
const [viewerData] = useState(() => {
|
||||
const createViewer = () =>
|
||||
new Cognite3DViewer({
|
||||
sdk,
|
||||
useFlexibleCameraManager: true,
|
||||
...viewerOptions,
|
||||
});
|
||||
|
||||
const viewer = keepAlive
|
||||
? keepAlive.getOrCreateViewer(sdk, createViewer)
|
||||
: createViewer();
|
||||
|
||||
if (color) {
|
||||
viewer.setBackgroundColor({ color, alpha: 1 });
|
||||
}
|
||||
|
||||
return { viewer, sdk };
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new RevealSettingsController('medium');
|
||||
controller.applyToViewer(viewerData.viewer);
|
||||
return () => controller.dispose();
|
||||
}, [viewerData.viewer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (color) {
|
||||
viewerData.viewer.setBackgroundColor({ color, alpha: 1 });
|
||||
}
|
||||
}, [color, viewerData.viewer]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!keepAliveRef.current) {
|
||||
viewerData.viewer.dispose();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<RevealContext.Provider value={viewerData}>
|
||||
<InstanceStylingProvider>{children}</InstanceStylingProvider>
|
||||
</RevealContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createContext } from 'react';
|
||||
import type { InstanceStylingGroup } from '../types';
|
||||
|
||||
export interface InstanceStylingController {
|
||||
getStylingGroups: () => InstanceStylingGroup[];
|
||||
addEventListener: (callback: () => void) => void;
|
||||
removeEventListener: (callback: () => void) => void;
|
||||
registerStylingGroup: (group: InstanceStylingGroup) => string;
|
||||
unregisterStylingGroup: (id: string) => void;
|
||||
}
|
||||
|
||||
export const InstanceStylingContext = createContext<
|
||||
InstanceStylingController | undefined
|
||||
>(undefined);
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createContext } from 'react';
|
||||
import type { Cognite3DViewer } from '@cognite/reveal';
|
||||
import type { CogniteClient } from '@cognite/sdk';
|
||||
|
||||
export interface RevealContextValue {
|
||||
viewer: Cognite3DViewer;
|
||||
sdk: CogniteClient;
|
||||
}
|
||||
|
||||
export const RevealContext = createContext<RevealContextValue | undefined>(
|
||||
undefined
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { DMInstanceRef } from '@cognite/reveal';
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
useInstancesWithBoundingBoxes,
|
||||
type InstancesWithBoxesAndOriginalInstance,
|
||||
} from './useInstancesWithBoundingBoxes';
|
||||
import { useFindRelated3dInstances } from './useFindRelated3dInstances';
|
||||
|
||||
export const useInstancesWithBounds = (
|
||||
inputInstances: DMInstanceRef[],
|
||||
originalInstance: DMInstanceRef
|
||||
): InstancesWithBoxesAndOriginalInstance | undefined => {
|
||||
const instancesWithBounds = useInstancesWithBoundingBoxes(inputInstances);
|
||||
|
||||
return useMemo<InstancesWithBoxesAndOriginalInstance | undefined>(() => {
|
||||
if (inputInstances.length === 0 || instancesWithBounds.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return { instancesWithBoxes: [...instancesWithBounds], originalInstance };
|
||||
}, [instancesWithBounds, inputInstances.length, originalInstance]);
|
||||
};
|
||||
|
||||
export function use3dDataForSelectedInstance(
|
||||
instance: DMInstanceRef
|
||||
): InstancesWithBoxesAndOriginalInstance | undefined {
|
||||
const threeDRelatedSelection = useFindRelated3dInstances(instance);
|
||||
const selectedInstancesWithBoundsAndCorrespondingInstance =
|
||||
useInstancesWithBounds(threeDRelatedSelection, instance);
|
||||
return selectedInstancesWithBoundsAndCorrespondingInstance;
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { DMInstanceRef } from '@cognite/reveal';
|
||||
import type { Node3D, CogniteClient } from '@cognite/sdk';
|
||||
import { useRevealContext } from './useRevealContext';
|
||||
import type { ThreeDModelFdmMappings, CadModelOptions } from '../types';
|
||||
import {
|
||||
ASSET_VIEW,
|
||||
COGNITE_3D_OBJECT_VIEW,
|
||||
COGNITE_CAD_NODE_VIEW,
|
||||
} from '../utils/views';
|
||||
import { unwrapProperties } from '../utils/data-mapper';
|
||||
import type { CDFNode } from '../utils/cdf-types';
|
||||
|
||||
interface DmsUniqueIdentifier {
|
||||
space: string;
|
||||
externalId: string;
|
||||
}
|
||||
|
||||
interface CogniteAssetProperties {
|
||||
space: string;
|
||||
externalId: string;
|
||||
object3D: DmsUniqueIdentifier;
|
||||
}
|
||||
|
||||
interface CogniteCADNodeProperties {
|
||||
space: string;
|
||||
externalId: string;
|
||||
object3D: DmsUniqueIdentifier;
|
||||
model3D: DmsUniqueIdentifier;
|
||||
revisions: DmsUniqueIdentifier[];
|
||||
treeIndexes: number[];
|
||||
subTreeSizes: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches FDM-to-CAD mappings using Core DM connections.
|
||||
* This queries the data model for CAD nodes connected to assets via object3D references.
|
||||
*/
|
||||
export function useFdmAssetMappings(
|
||||
instances: DMInstanceRef[],
|
||||
models: CadModelOptions[]
|
||||
) {
|
||||
const { sdk } = useRevealContext();
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
'fdm-cad-connections',
|
||||
instances.map((i) => `${i.space}:${i.externalId}`).join(','),
|
||||
models.map((m) => `${m.modelId}:${m.revisionId}`).join(','),
|
||||
],
|
||||
queryFn: async (): Promise<ThreeDModelFdmMappings[]> => {
|
||||
if (instances.length === 0 || models.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Query DMS for CAD connections
|
||||
// This traverses: Assets → object3D → CAD nodes
|
||||
const queryResult = await sdk.instances.query({
|
||||
with: {
|
||||
// Start from the input instances (assets)
|
||||
assets: {
|
||||
nodes: {
|
||||
filter: {
|
||||
and: [
|
||||
{
|
||||
in: {
|
||||
property: ['node', 'space'],
|
||||
values: [
|
||||
...new Set(instances.map((inst) => inst.space)),
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
in: {
|
||||
property: ['node', 'externalId'],
|
||||
values: instances.map((inst) => inst.externalId),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
// Navigate to object3D (Cognite3DObject)
|
||||
object_3ds: {
|
||||
nodes: {
|
||||
from: 'assets',
|
||||
through: {
|
||||
view: { type: 'view', ...ASSET_VIEW },
|
||||
identifier: 'object3D',
|
||||
},
|
||||
direction: 'outwards',
|
||||
filter: {
|
||||
hasData: [{ type: 'view', ...COGNITE_3D_OBJECT_VIEW }],
|
||||
},
|
||||
},
|
||||
},
|
||||
// Navigate back to CAD nodes that reference this object3D
|
||||
cad_nodes: {
|
||||
nodes: {
|
||||
from: 'object_3ds',
|
||||
through: {
|
||||
view: { type: 'view', ...COGNITE_CAD_NODE_VIEW },
|
||||
identifier: 'object3D',
|
||||
},
|
||||
direction: 'inwards',
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
assets: {
|
||||
sources: [
|
||||
{
|
||||
source: { type: 'view', ...ASSET_VIEW },
|
||||
properties: ['object3D'],
|
||||
},
|
||||
],
|
||||
},
|
||||
cad_nodes: {
|
||||
sources: [
|
||||
{
|
||||
source: { type: 'view', ...COGNITE_CAD_NODE_VIEW },
|
||||
properties: [
|
||||
'object3D',
|
||||
'model3D',
|
||||
'revisions',
|
||||
'treeIndexes',
|
||||
'subTreeSizes',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Step 2: Build mappings per model/revision
|
||||
const mappingsByModel = new Map<string, Map<string, Node3D[]>>();
|
||||
|
||||
const cadNodes = queryResult.items.cad_nodes || [];
|
||||
|
||||
// Group CAD nodes by which instances reference them
|
||||
const object3DToAssets = new Map<string, DMInstanceRef[]>();
|
||||
for (const asset of queryResult.items.assets || []) {
|
||||
const props = unwrapProperties<CogniteAssetProperties>(
|
||||
asset as CDFNode,
|
||||
ASSET_VIEW
|
||||
);
|
||||
if (props?.object3D) {
|
||||
const key = `${props.object3D.space}/${props.object3D.externalId}`;
|
||||
const existing = object3DToAssets.get(key) || [];
|
||||
existing.push({ space: asset.space, externalId: asset.externalId });
|
||||
object3DToAssets.set(key, existing);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all (modelId, revisionId, treeIndex) tuples for batch fetching
|
||||
interface NodeRequest {
|
||||
modelId: number;
|
||||
revisionId: number;
|
||||
treeIndex: number;
|
||||
assetInstances: DMInstanceRef[];
|
||||
}
|
||||
|
||||
const nodeRequests: NodeRequest[] = [];
|
||||
|
||||
// Process CAD nodes to build Node3D mappings
|
||||
for (const cadNode of cadNodes) {
|
||||
const props = unwrapProperties<CogniteCADNodeProperties>(
|
||||
cadNode as CDFNode,
|
||||
COGNITE_CAD_NODE_VIEW
|
||||
);
|
||||
if (!props) continue;
|
||||
|
||||
const { model3D, revisions, treeIndexes, object3D } = props;
|
||||
if (!model3D || !revisions || !treeIndexes) continue;
|
||||
|
||||
// Find which assets reference this CAD node
|
||||
const object3DKey = `${object3D.space}/${object3D.externalId}`;
|
||||
const relatedAssets = object3DToAssets.get(object3DKey);
|
||||
if (!relatedAssets) continue;
|
||||
|
||||
// Extract modelId and match with requested models
|
||||
const modelId = extractModelId(model3D.externalId);
|
||||
|
||||
// For each revision/treeIndex pair
|
||||
for (let i = 0; i < revisions.length; i++) {
|
||||
const revision = revisions[i];
|
||||
const treeIndex = treeIndexes[i];
|
||||
const revisionId = extractRevisionId(revision.externalId);
|
||||
|
||||
// Check if this model/revision is in our requested list
|
||||
const matchingModel = models.find(
|
||||
(m) => m.modelId === modelId && m.revisionId === revisionId
|
||||
);
|
||||
if (!matchingModel) continue;
|
||||
|
||||
nodeRequests.push({
|
||||
modelId,
|
||||
revisionId,
|
||||
treeIndex,
|
||||
assetInstances: relatedAssets,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Batch fetch nodes by revision
|
||||
const nodesByRevision = new Map<string, NodeRequest[]>();
|
||||
for (const req of nodeRequests) {
|
||||
const key = `${req.modelId}/${req.revisionId}`;
|
||||
const existing = nodesByRevision.get(key) || [];
|
||||
existing.push(req);
|
||||
nodesByRevision.set(key, existing);
|
||||
}
|
||||
|
||||
// Fetch all nodes in parallel per revision
|
||||
const revisionFetchPromises = Array.from(nodesByRevision.entries()).map(
|
||||
async ([revisionKey, requests]) => {
|
||||
const [modelId, revisionId] = revisionKey.split('/').map(Number);
|
||||
const treeIndexes = requests.map((r) => r.treeIndex);
|
||||
|
||||
const nodes = await fetchNodesByTreeIndex(
|
||||
sdk,
|
||||
modelId,
|
||||
revisionId,
|
||||
treeIndexes
|
||||
);
|
||||
|
||||
return { revisionKey, nodes, requests };
|
||||
}
|
||||
);
|
||||
|
||||
const allRevisionData = await Promise.all(revisionFetchPromises);
|
||||
|
||||
for (const { revisionKey, nodes, requests } of allRevisionData) {
|
||||
const treeIndexToNode = new Map(
|
||||
nodes.map((node) => [node.treeIndex, node])
|
||||
);
|
||||
|
||||
const modelMappings =
|
||||
mappingsByModel.get(revisionKey) ?? new Map<string, Node3D[]>();
|
||||
mappingsByModel.set(revisionKey, modelMappings);
|
||||
|
||||
for (const req of requests) {
|
||||
const node3D = treeIndexToNode.get(req.treeIndex);
|
||||
if (!node3D) continue;
|
||||
|
||||
for (const instance of req.assetInstances) {
|
||||
const instanceKey = `${instance.space}:${instance.externalId}`;
|
||||
const arr = modelMappings.get(instanceKey) ?? [];
|
||||
arr.push(node3D);
|
||||
modelMappings.set(instanceKey, arr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to result format
|
||||
const results: ThreeDModelFdmMappings[] = [];
|
||||
for (const model of models) {
|
||||
const modelKey = `${model.modelId}/${model.revisionId}`;
|
||||
results.push({
|
||||
modelId: model.modelId,
|
||||
revisionId: model.revisionId,
|
||||
mappings: mappingsByModel.get(modelKey) ?? new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('Error fetching FDM CAD connections:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
enabled: !!sdk && instances.length > 0 && models.length > 0,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to extract numeric modelId from externalId like "model_123_space"
|
||||
function extractModelId(externalId: string): number {
|
||||
const match = externalId.match(/model_(\d+)/);
|
||||
return match ? parseInt(match[1], 10) : -1;
|
||||
}
|
||||
|
||||
// Helper to extract numeric revisionId from externalId like "model_123_revision_456_space"
|
||||
function extractRevisionId(externalId: string): number {
|
||||
const match = externalId.match(/revision_(\d+)/);
|
||||
return match ? parseInt(match[1], 10) : -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch 3D nodes by their tree indices using the optimized internal IDs endpoint.
|
||||
* This is much more efficient than fetching all nodes and filtering.
|
||||
*/
|
||||
async function fetchNodesByTreeIndex(
|
||||
sdk: CogniteClient,
|
||||
modelId: number,
|
||||
revisionId: number,
|
||||
treeIndexes: number[]
|
||||
): Promise<Node3D[]> {
|
||||
if (treeIndexes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Deduplicate tree indices
|
||||
const uniqueTreeIndexes = Array.from(new Set(treeIndexes));
|
||||
|
||||
// Step 1: Convert tree indices to internal node IDs
|
||||
const nodeIdResponse = await sdk.post<{ items: number[] }>(
|
||||
`/api/v1/projects/${sdk.project}/3d/models/${modelId}/revisions/${revisionId}/nodes/internalids/bytreeindices`,
|
||||
{
|
||||
data: {
|
||||
items: uniqueTreeIndexes,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const nodeIds = nodeIdResponse.data.items;
|
||||
|
||||
if (nodeIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Step 2: Retrieve full node details by internal IDs
|
||||
const nodes = await sdk.revisions3D.retrieve3DNodes(
|
||||
modelId,
|
||||
revisionId,
|
||||
nodeIds.map((id) => ({ id }))
|
||||
);
|
||||
|
||||
return nodes;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
use3dRelatedDirectConnections,
|
||||
use3dRelatedEdgeConnections,
|
||||
} from './useRelatedInstances';
|
||||
import type { DMInstanceRef } from '@cognite/reveal';
|
||||
|
||||
export const useFindRelated3dInstances = (
|
||||
instance: DMInstanceRef
|
||||
): DMInstanceRef[] => {
|
||||
const edgeRelationData = use3dRelatedEdgeConnections(instance);
|
||||
const directRelationData = use3dRelatedDirectConnections(instance);
|
||||
|
||||
return useMemo<DMInstanceRef[]>(() => {
|
||||
const edgeDirectRelationData = [
|
||||
...(edgeRelationData.data ?? []),
|
||||
...(directRelationData.data ?? []),
|
||||
];
|
||||
return [instance, ...edgeDirectRelationData];
|
||||
}, [instance, edgeRelationData.data, directRelationData.data]);
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import { DefaultCameraManager, type DMInstanceRef } from '@cognite/reveal';
|
||||
import { useReveal } from './useReveal';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { Box3, Vector3 } from 'three';
|
||||
import { use3dDataForSelectedInstance } from './use3dDataForSelectedInstance';
|
||||
import type { InstanceWithBoundingBox } from './useInstancesWithBoundingBoxes';
|
||||
|
||||
/**
|
||||
* Calculate an angled camera position for a bounding box
|
||||
* @param box - The bounding box to frame
|
||||
* @returns Camera position and target vectors
|
||||
*/
|
||||
function calculateAngledCameraPosition(box: Box3): {
|
||||
position: Vector3;
|
||||
target: Vector3;
|
||||
} {
|
||||
// Get bounding box center and size
|
||||
const center = new Vector3();
|
||||
box.getCenter(center);
|
||||
|
||||
const size = new Vector3();
|
||||
box.getSize(size);
|
||||
|
||||
// Calculate the maximum dimension to determine camera distance
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
const fov = 60; // Field of view in degrees
|
||||
const cameraDistance = (maxDim / (2 * Math.tan((fov * Math.PI) / 360))) * 1.5;
|
||||
|
||||
// Position camera at 45-degree angle (above and to the side)
|
||||
// Using spherical coordinates: 45° elevation, 45° azimuth
|
||||
const angle = Math.PI / 4; // 45 degrees
|
||||
const cameraPosition = new Vector3(
|
||||
center.x + cameraDistance * Math.cos(angle) * Math.cos(angle),
|
||||
center.y + cameraDistance * Math.sin(angle),
|
||||
center.z + cameraDistance * Math.cos(angle) * Math.sin(angle)
|
||||
);
|
||||
|
||||
return {
|
||||
position: cameraPosition,
|
||||
target: center,
|
||||
};
|
||||
}
|
||||
|
||||
const useFocusCameraWithInstanceBox = (
|
||||
instancesWithBoundingBox: InstanceWithBoundingBox[]
|
||||
) => {
|
||||
const viewer = useReveal();
|
||||
|
||||
useEffect(() => {
|
||||
if (viewer.cameraManager instanceof DefaultCameraManager) {
|
||||
viewer.cameraManager.setCameraControlsOptions({
|
||||
mouseWheelAction: 'zoomToCursor',
|
||||
});
|
||||
}
|
||||
}, [viewer.cameraManager]);
|
||||
|
||||
return useCallback(() => {
|
||||
if (instancesWithBoundingBox.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const box = instancesWithBoundingBox.reduce(
|
||||
(unionBox, instance) => unionBox.union(instance.boundingBox),
|
||||
new Box3()
|
||||
);
|
||||
|
||||
if (!box.isEmpty()) {
|
||||
const cameraState = calculateAngledCameraPosition(box);
|
||||
viewer.cameraManager.setCameraState(cameraState);
|
||||
}
|
||||
}, [instancesWithBoundingBox, viewer]);
|
||||
};
|
||||
|
||||
export const useFocusCamera = (instance: DMInstanceRef) => {
|
||||
const selectedInstanceData = use3dDataForSelectedInstance(instance);
|
||||
return useFocusCameraWithInstanceBox(
|
||||
selectedInstanceData?.instancesWithBoxes ?? []
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRenderTarget } from './useRenderTarget';
|
||||
import type { FdmAssetStylingGroup, InstanceStylingGroup } from '../types';
|
||||
import { DefaultNodeAppearance, type DMInstanceRef } from '@cognite/reveal';
|
||||
import { use3dDataForSelectedInstance } from './use3dDataForSelectedInstance';
|
||||
import type { InstanceWithBoundingBox } from './useInstancesWithBoundingBoxes';
|
||||
|
||||
const useCentralizedInstanceStyling = (): InstanceStylingGroup[] => {
|
||||
const [instanceStylingGroups, setInstanceStylingGroups] = useState<
|
||||
InstanceStylingGroup[]
|
||||
>([]);
|
||||
const instanceStylingController = useRenderTarget().instanceStylingController;
|
||||
|
||||
useEffect(() => {
|
||||
const onStylingChange = () => {
|
||||
setInstanceStylingGroups([
|
||||
...instanceStylingController.getStylingGroups(),
|
||||
]);
|
||||
};
|
||||
|
||||
instanceStylingController.addEventListener(onStylingChange);
|
||||
return () => {
|
||||
instanceStylingController.removeEventListener(onStylingChange);
|
||||
};
|
||||
}, [instanceStylingController]);
|
||||
|
||||
return instanceStylingGroups;
|
||||
};
|
||||
|
||||
const getInstanceStyling = (
|
||||
instances: InstanceWithBoundingBox[]
|
||||
): FdmAssetStylingGroup[] =>
|
||||
instances.length === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
fdmAssetExternalIds: instances.map(({ instance }) => instance),
|
||||
style: {
|
||||
cad: DefaultNodeAppearance.Highlighted,
|
||||
pointcloud: DefaultNodeAppearance.Highlighted,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const useInstanceStyling = (instance: DMInstanceRef) => {
|
||||
const selectedInstancesAndOriginalInstance =
|
||||
use3dDataForSelectedInstance(instance);
|
||||
|
||||
const centralizedInstanceStyling = useCentralizedInstanceStyling();
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
...centralizedInstanceStyling,
|
||||
...getInstanceStyling(
|
||||
selectedInstancesAndOriginalInstance?.instancesWithBoxes ?? []
|
||||
),
|
||||
],
|
||||
[
|
||||
selectedInstancesAndOriginalInstance?.instancesWithBoxes,
|
||||
centralizedInstanceStyling,
|
||||
]
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { DMInstanceRef } from '@cognite/reveal';
|
||||
import { use3dModels } from './useModels';
|
||||
import { useFdmAssetMappings } from './useFdmMappings';
|
||||
import type { CadModelOptions, ThreeDModelFdmMappings, CogniteModel } from '../types';
|
||||
import type { Node3D } from '@cognite/sdk';
|
||||
import { useMemo } from 'react';
|
||||
import { Box3 } from 'three';
|
||||
|
||||
export type InstanceWithBoundingBox = {
|
||||
instance: DMInstanceRef;
|
||||
boundingBox: Box3;
|
||||
};
|
||||
|
||||
export type InstancesWithBoxesAndOriginalInstance = {
|
||||
instancesWithBoxes: InstanceWithBoundingBox[];
|
||||
originalInstance: DMInstanceRef;
|
||||
};
|
||||
|
||||
export type NodesWithModelInfo = {
|
||||
nodes: Node3D[];
|
||||
instance: DMInstanceRef;
|
||||
modelId: number;
|
||||
revisionId: number;
|
||||
};
|
||||
|
||||
const combineNodeBoundingBoxes = (nodes: Node3D[]): Box3 =>
|
||||
nodes.reduce(
|
||||
(currentBox, nextNode) =>
|
||||
currentBox.union(
|
||||
nextNode.boundingBox !== undefined
|
||||
? new Box3().setFromArray([
|
||||
...nextNode.boundingBox.min,
|
||||
...nextNode.boundingBox.max,
|
||||
])
|
||||
: new Box3()
|
||||
),
|
||||
new Box3()
|
||||
);
|
||||
|
||||
const getFdmDataWithBoundingBoxes = (
|
||||
modelsWithRelevantNodes: NodesWithModelInfo[],
|
||||
models: CogniteModel[]
|
||||
): InstanceWithBoundingBox[] => {
|
||||
const cdfCoordinateBoundingBoxes = modelsWithRelevantNodes.map(
|
||||
(nodesWithModel) => combineNodeBoundingBoxes(nodesWithModel.nodes)
|
||||
);
|
||||
|
||||
const selectedNodeCadModels = modelsWithRelevantNodes.map((nodeModelData) =>
|
||||
models.find(
|
||||
({ modelId, revisionId }) =>
|
||||
modelId === nodeModelData.modelId &&
|
||||
revisionId === nodeModelData.revisionId
|
||||
)
|
||||
);
|
||||
|
||||
if (
|
||||
selectedNodeCadModels.length === 0 ||
|
||||
cdfCoordinateBoundingBoxes.length === 0
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const viewerCoordinateBoundingBoxes = selectedNodeCadModels
|
||||
.map((model, ind) =>
|
||||
model?.mapBoxFromCdfToModelCoordinates(cdfCoordinateBoundingBoxes[ind])
|
||||
)
|
||||
.filter((val) => val !== undefined);
|
||||
|
||||
return viewerCoordinateBoundingBoxes.map((boundingBox, ind) => ({
|
||||
instance: modelsWithRelevantNodes[ind].instance!,
|
||||
boundingBox,
|
||||
}));
|
||||
};
|
||||
|
||||
export function getNodesFromModelsFdmMappings(
|
||||
instances: DMInstanceRef[],
|
||||
mappings?: ThreeDModelFdmMappings[]
|
||||
): NodesWithModelInfo[] {
|
||||
const nodesWithModelIds = mappings?.flatMap((modelMappings) =>
|
||||
instances.reduce((infoArray, instance) => {
|
||||
const nodes = modelMappings.mappings.get(`${instance.space}:${instance.externalId}`);
|
||||
if (nodes === undefined) {
|
||||
return infoArray;
|
||||
}
|
||||
infoArray.push({
|
||||
instance,
|
||||
modelId: modelMappings.modelId,
|
||||
revisionId: modelMappings.revisionId,
|
||||
nodes,
|
||||
});
|
||||
return infoArray;
|
||||
}, new Array<NodesWithModelInfo>())
|
||||
);
|
||||
return nodesWithModelIds ?? [];
|
||||
}
|
||||
|
||||
const getBoundingBoxInstancesFromFdmAndModelMappings = (
|
||||
instances: DMInstanceRef[],
|
||||
modelMappings: ThreeDModelFdmMappings[] | undefined,
|
||||
models: CogniteModel[]
|
||||
): InstanceWithBoundingBox[] => {
|
||||
const modelsWithRelevantNodes = getNodesFromModelsFdmMappings(
|
||||
instances,
|
||||
modelMappings
|
||||
);
|
||||
if (modelsWithRelevantNodes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return getFdmDataWithBoundingBoxes(modelsWithRelevantNodes, models);
|
||||
};
|
||||
|
||||
export const useInstancesWithBoundingBoxes = (
|
||||
inputInstances: DMInstanceRef[]
|
||||
) => {
|
||||
const models = use3dModels();
|
||||
const { data: modelNodeMappings } = useFdmAssetMappings(
|
||||
inputInstances,
|
||||
models as CadModelOptions[]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
getBoundingBoxInstancesFromFdmAndModelMappings(
|
||||
inputInstances,
|
||||
modelNodeMappings,
|
||||
models
|
||||
),
|
||||
[modelNodeMappings, inputInstances, models]
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { DMInstanceRef } from '@cognite/reveal';
|
||||
import { useRevealContext } from './useRevealContext';
|
||||
import type { TaggedAddResourceOptions } from '../types';
|
||||
import { useReveal } from './useReveal';
|
||||
import {
|
||||
COGNITE_VISUALIZABLE_VIEW,
|
||||
COGNITE_CAD_NODE_VIEW,
|
||||
} from '../utils/views';
|
||||
import { unwrapProperties } from '../utils/data-mapper';
|
||||
import type { CDFNode } from '../utils/cdf-types';
|
||||
|
||||
interface CogniteCADNodeProperties {
|
||||
space: string;
|
||||
externalId: string;
|
||||
model3D: { externalId: string; space: string };
|
||||
revisions: Array<{ externalId: string; space: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts numeric ID from CDF external ID format (e.g., "cog_3d_model_12345" -> 12345)
|
||||
*/
|
||||
function extractNumericId(externalId: string): number | undefined {
|
||||
const lastUnderscoreIndex = externalId.lastIndexOf('_');
|
||||
if (lastUnderscoreIndex === -1) return undefined;
|
||||
|
||||
const numericPart = externalId.substring(lastUnderscoreIndex + 1);
|
||||
const id = parseInt(numericPart, 10);
|
||||
return isNaN(id) ? undefined : id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches 3D CAD models associated with an FDM instance via the Cognite Core Data Model.
|
||||
* Traverses: Asset -> object3D (CogniteVisualizable) -> CogniteCADNode -> models/revisions
|
||||
*/
|
||||
export function useModelsForInstanceQuery(instance: DMInstanceRef) {
|
||||
const { sdk } = useRevealContext();
|
||||
|
||||
const result = useQuery({
|
||||
queryKey: ['3d-models-for-instance', instance.space, instance.externalId],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await sdk.instances.query({
|
||||
with: {
|
||||
asset: {
|
||||
nodes: {
|
||||
filter: {
|
||||
and: [
|
||||
{
|
||||
equals: {
|
||||
property: ['node', 'externalId'],
|
||||
value: instance.externalId,
|
||||
},
|
||||
},
|
||||
{
|
||||
equals: {
|
||||
property: ['node', 'space'],
|
||||
value: instance.space,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
object_3ds: {
|
||||
nodes: {
|
||||
from: 'asset',
|
||||
through: {
|
||||
view: { type: 'view', ...COGNITE_VISUALIZABLE_VIEW },
|
||||
identifier: 'object3D',
|
||||
},
|
||||
direction: 'outwards',
|
||||
},
|
||||
},
|
||||
cad_nodes: {
|
||||
nodes: {
|
||||
from: 'object_3ds',
|
||||
through: {
|
||||
view: { type: 'view', ...COGNITE_CAD_NODE_VIEW },
|
||||
identifier: 'object3D',
|
||||
},
|
||||
direction: 'inwards',
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
cad_nodes: {
|
||||
sources: [
|
||||
{
|
||||
source: { type: 'view', ...COGNITE_CAD_NODE_VIEW },
|
||||
properties: ['model3D', 'revisions'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const models: TaggedAddResourceOptions[] = [];
|
||||
const seenModels = new Set<string>();
|
||||
|
||||
// Extract model/revision info from CAD nodes
|
||||
const cadNodes = response.items?.cad_nodes || [];
|
||||
|
||||
for (const node of cadNodes) {
|
||||
const props = unwrapProperties<CogniteCADNodeProperties>(
|
||||
node as CDFNode,
|
||||
COGNITE_CAD_NODE_VIEW
|
||||
);
|
||||
if (!props?.model3D || !Array.isArray(props.revisions)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const modelId = extractNumericId(props.model3D.externalId);
|
||||
if (!modelId) continue;
|
||||
|
||||
// Process each revision
|
||||
for (const revision of props.revisions) {
|
||||
const revisionId = extractNumericId(revision.externalId);
|
||||
if (!revisionId) continue;
|
||||
|
||||
const modelKey = `${modelId}:${revisionId}`;
|
||||
if (seenModels.has(modelKey)) continue;
|
||||
|
||||
seenModels.add(modelKey);
|
||||
models.push({ type: 'cad', addOptions: { modelId, revisionId } });
|
||||
}
|
||||
}
|
||||
|
||||
return models;
|
||||
} catch (error) {
|
||||
console.error('[useModelsForInstanceQuery] Error:', error);
|
||||
return [] as TaggedAddResourceOptions[];
|
||||
}
|
||||
},
|
||||
enabled: !!sdk,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all loaded 3D models in the viewer.
|
||||
* Models are only present after Reveal3DResources has loaded them.
|
||||
*
|
||||
* Note: This returns the viewer.models array directly without polling.
|
||||
* Components should be structured so that model loading triggers re-renders naturally.
|
||||
*/
|
||||
export function use3dModels() {
|
||||
const viewer = useReveal();
|
||||
return viewer.models || [];
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { DMInstanceRef } from '@cognite/reveal';
|
||||
import type { Node3D, CogniteClient } from '@cognite/sdk';
|
||||
import { useRevealContext } from './useRevealContext';
|
||||
import type { ThreeDModelFdmMappings } from '../types';
|
||||
import {
|
||||
ASSET_VIEW,
|
||||
COGNITE_3D_OBJECT_VIEW,
|
||||
COGNITE_CAD_NODE_VIEW,
|
||||
} from '../utils/views';
|
||||
import { unwrapProperties } from '../utils/data-mapper';
|
||||
import type { CDFNode } from '../utils/cdf-types';
|
||||
import { executeParallel, chunk } from '../utils/executeParallel';
|
||||
|
||||
const TREE_INDEX_CHUNK_SIZE = 1000;
|
||||
const NODE_ID_CHUNK_SIZE = 100;
|
||||
|
||||
interface ModelRef {
|
||||
modelId: number;
|
||||
revisionId: number;
|
||||
}
|
||||
|
||||
interface DmsUniqueIdentifier {
|
||||
space: string;
|
||||
externalId: string;
|
||||
}
|
||||
|
||||
interface CogniteAssetProperties {
|
||||
space: string;
|
||||
externalId: string;
|
||||
object3D: DmsUniqueIdentifier;
|
||||
}
|
||||
|
||||
interface CogniteCADNodeProperties {
|
||||
space: string;
|
||||
externalId: string;
|
||||
object3D: DmsUniqueIdentifier;
|
||||
model3D: DmsUniqueIdentifier;
|
||||
revisions: DmsUniqueIdentifier[];
|
||||
treeIndexes: number[];
|
||||
subTreeSizes: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch FDM asset mappings using model IDs from the query result,
|
||||
* NOT waiting for models to be loaded into the viewer.
|
||||
*
|
||||
* This eliminates 1-3 seconds from the critical path by running mapping fetch
|
||||
* in parallel with model loading instead of sequentially after.
|
||||
*/
|
||||
export function usePrefetchedFdmMappings(
|
||||
instances: DMInstanceRef[],
|
||||
modelRefs: ModelRef[]
|
||||
) {
|
||||
const { sdk } = useRevealContext();
|
||||
|
||||
const instancesKey = useMemo(
|
||||
() => instances.map((i) => `${i.space}:${i.externalId}`).join(','),
|
||||
[instances]
|
||||
);
|
||||
const modelRefsKey = useMemo(
|
||||
() => modelRefs.map((m) => `${m.modelId}:${m.revisionId}`).join(','),
|
||||
[modelRefs]
|
||||
);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['fdm-cad-connections-prefetched', instancesKey, modelRefsKey],
|
||||
queryFn: async (): Promise<ThreeDModelFdmMappings[]> => {
|
||||
if (instances.length === 0 || modelRefs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const queryResult = await sdk.instances.query({
|
||||
with: {
|
||||
assets: {
|
||||
nodes: {
|
||||
filter: {
|
||||
and: [
|
||||
{
|
||||
in: {
|
||||
property: ['node', 'space'],
|
||||
values: [
|
||||
...new Set(instances.map((inst) => inst.space)),
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
in: {
|
||||
property: ['node', 'externalId'],
|
||||
values: instances.map((inst) => inst.externalId),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
object_3ds: {
|
||||
nodes: {
|
||||
from: 'assets',
|
||||
through: {
|
||||
view: { type: 'view', ...ASSET_VIEW },
|
||||
identifier: 'object3D',
|
||||
},
|
||||
direction: 'outwards',
|
||||
filter: {
|
||||
hasData: [{ type: 'view', ...COGNITE_3D_OBJECT_VIEW }],
|
||||
},
|
||||
},
|
||||
},
|
||||
cad_nodes: {
|
||||
nodes: {
|
||||
from: 'object_3ds',
|
||||
through: {
|
||||
view: { type: 'view', ...COGNITE_CAD_NODE_VIEW },
|
||||
identifier: 'object3D',
|
||||
},
|
||||
direction: 'inwards',
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
assets: {
|
||||
sources: [
|
||||
{
|
||||
source: { type: 'view', ...ASSET_VIEW },
|
||||
properties: ['object3D'],
|
||||
},
|
||||
],
|
||||
},
|
||||
cad_nodes: {
|
||||
sources: [
|
||||
{
|
||||
source: { type: 'view', ...COGNITE_CAD_NODE_VIEW },
|
||||
properties: [
|
||||
'object3D',
|
||||
'model3D',
|
||||
'revisions',
|
||||
'treeIndexes',
|
||||
'subTreeSizes',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mappingsByModel = new Map<string, Map<string, Node3D[]>>();
|
||||
const cadNodes = queryResult.items.cad_nodes || [];
|
||||
|
||||
const object3DToAssets = new Map<string, DMInstanceRef[]>();
|
||||
for (const asset of queryResult.items.assets || []) {
|
||||
const props = unwrapProperties<CogniteAssetProperties>(
|
||||
asset as CDFNode,
|
||||
ASSET_VIEW
|
||||
);
|
||||
if (props?.object3D) {
|
||||
const key = `${props.object3D.space}/${props.object3D.externalId}`;
|
||||
const existing = object3DToAssets.get(key) || [];
|
||||
existing.push({ space: asset.space, externalId: asset.externalId });
|
||||
object3DToAssets.set(key, existing);
|
||||
}
|
||||
}
|
||||
|
||||
interface NodeRequest {
|
||||
modelId: number;
|
||||
revisionId: number;
|
||||
treeIndex: number;
|
||||
assetInstances: DMInstanceRef[];
|
||||
}
|
||||
|
||||
const nodeRequests: NodeRequest[] = [];
|
||||
|
||||
for (const cadNode of cadNodes) {
|
||||
const props = unwrapProperties<CogniteCADNodeProperties>(
|
||||
cadNode as CDFNode,
|
||||
COGNITE_CAD_NODE_VIEW
|
||||
);
|
||||
if (!props) continue;
|
||||
|
||||
const { model3D, revisions, treeIndexes, object3D } = props;
|
||||
if (!model3D || !revisions || !treeIndexes) continue;
|
||||
|
||||
const object3DKey = `${object3D.space}/${object3D.externalId}`;
|
||||
const relatedAssets = object3DToAssets.get(object3DKey);
|
||||
if (!relatedAssets) continue;
|
||||
|
||||
const modelId = extractModelId(model3D.externalId);
|
||||
|
||||
for (let i = 0; i < revisions.length; i++) {
|
||||
const revision = revisions[i];
|
||||
const treeIndex = treeIndexes[i];
|
||||
const revisionId = extractRevisionId(revision.externalId);
|
||||
|
||||
const matchingModel = modelRefs.find(
|
||||
(m) => m.modelId === modelId && m.revisionId === revisionId
|
||||
);
|
||||
if (!matchingModel) continue;
|
||||
|
||||
nodeRequests.push({
|
||||
modelId,
|
||||
revisionId,
|
||||
treeIndex,
|
||||
assetInstances: relatedAssets,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const nodesByRevision = new Map<string, NodeRequest[]>();
|
||||
for (const req of nodeRequests) {
|
||||
const key = `${req.modelId}/${req.revisionId}`;
|
||||
const existing = nodesByRevision.get(key) || [];
|
||||
existing.push(req);
|
||||
nodesByRevision.set(key, existing);
|
||||
}
|
||||
|
||||
const revisionFetchPromises = Array.from(nodesByRevision.entries()).map(
|
||||
async ([revisionKey, requests]) => {
|
||||
const [modelId, revisionId] = revisionKey.split('/').map(Number);
|
||||
const treeIndexes = requests.map((r) => r.treeIndex);
|
||||
|
||||
const nodes = await fetchNodesByTreeIndex(
|
||||
sdk,
|
||||
modelId,
|
||||
revisionId,
|
||||
treeIndexes
|
||||
);
|
||||
|
||||
return { revisionKey, nodes, requests };
|
||||
}
|
||||
);
|
||||
|
||||
const allRevisionData = await Promise.all(revisionFetchPromises);
|
||||
|
||||
for (const { revisionKey, nodes, requests } of allRevisionData) {
|
||||
const treeIndexToNode = new Map(
|
||||
nodes.map((node) => [node.treeIndex, node])
|
||||
);
|
||||
|
||||
const modelMappings =
|
||||
mappingsByModel.get(revisionKey) ?? new Map<string, Node3D[]>();
|
||||
mappingsByModel.set(revisionKey, modelMappings);
|
||||
|
||||
for (const req of requests) {
|
||||
const node3D = treeIndexToNode.get(req.treeIndex);
|
||||
if (!node3D) continue;
|
||||
|
||||
for (const instance of req.assetInstances) {
|
||||
const instanceKey = `${instance.space}:${instance.externalId}`;
|
||||
const arr = modelMappings.get(instanceKey) ?? [];
|
||||
arr.push(node3D);
|
||||
modelMappings.set(instanceKey, arr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const results: ThreeDModelFdmMappings[] = [];
|
||||
for (const modelRef of modelRefs) {
|
||||
const modelKey = `${modelRef.modelId}/${modelRef.revisionId}`;
|
||||
results.push({
|
||||
modelId: modelRef.modelId,
|
||||
revisionId: modelRef.revisionId,
|
||||
mappings: mappingsByModel.get(modelKey) ?? new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('Error prefetching FDM CAD connections:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
enabled: !!sdk && instances.length > 0 && modelRefs.length > 0,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
function extractModelId(externalId: string): number {
|
||||
const match = externalId.match(/model_(\d+)/);
|
||||
return match ? parseInt(match[1], 10) : -1;
|
||||
}
|
||||
|
||||
function extractRevisionId(externalId: string): number {
|
||||
const match = externalId.match(/revision_(\d+)/);
|
||||
return match ? parseInt(match[1], 10) : -1;
|
||||
}
|
||||
|
||||
async function fetchNodesByTreeIndex(
|
||||
sdk: CogniteClient,
|
||||
modelId: number,
|
||||
revisionId: number,
|
||||
treeIndexes: number[]
|
||||
): Promise<Node3D[]> {
|
||||
if (treeIndexes.length === 0) return [];
|
||||
|
||||
const uniqueTreeIndexes = Array.from(new Set(treeIndexes));
|
||||
const treeIndexChunks = chunk(uniqueTreeIndexes, TREE_INDEX_CHUNK_SIZE);
|
||||
|
||||
const results = await executeParallel(
|
||||
treeIndexChunks.map((indexChunk) => async () => {
|
||||
const nodeIdResponse = await sdk.post<{ items: number[] }>(
|
||||
`/api/v1/projects/${sdk.project}/3d/models/${modelId}/revisions/${revisionId}/nodes/internalids/bytreeindices`,
|
||||
{
|
||||
data: { items: indexChunk },
|
||||
}
|
||||
);
|
||||
|
||||
const nodeIds = nodeIdResponse.data.items;
|
||||
if (nodeIds.length === 0) return [];
|
||||
|
||||
const nodeIdChunks = chunk(nodeIds, NODE_ID_CHUNK_SIZE);
|
||||
const nodeResults = await executeParallel(
|
||||
nodeIdChunks.map((idChunk) => async () => {
|
||||
const nodes = await sdk.revisions3D.retrieve3DNodes(
|
||||
modelId,
|
||||
revisionId,
|
||||
idChunk.map((id) => ({ id }))
|
||||
);
|
||||
return Array.isArray(nodes) ? nodes : [];
|
||||
}),
|
||||
3
|
||||
);
|
||||
|
||||
return nodeResults.flat().filter((node): node is Node3D => node !== undefined);
|
||||
}),
|
||||
3
|
||||
);
|
||||
|
||||
return results.flat().filter((node): node is Node3D => node !== undefined);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { DMInstanceRef } from '@cognite/reveal';
|
||||
import type { ViewDefinition, ViewReference } from '@cognite/sdk';
|
||||
import { useRevealContext } from './useRevealContext';
|
||||
import {
|
||||
COGNITE_VISUALIZABLE_VIEW,
|
||||
COGNITE_3D_OBJECT_VIEW,
|
||||
} from '../utils/views';
|
||||
|
||||
type DmsUniqueIdentifier = {
|
||||
space: string;
|
||||
externalId: string;
|
||||
};
|
||||
|
||||
export function use3dRelatedEdgeConnections(instance: DMInstanceRef) {
|
||||
const { sdk } = useRevealContext();
|
||||
return useQuery({
|
||||
queryKey: ['3d-related-edges', instance.space, instance.externalId],
|
||||
queryFn: async (): Promise<DMInstanceRef[]> => {
|
||||
try {
|
||||
// Query for nodes connected via edges that have 3D data
|
||||
const response = await sdk.instances.query({
|
||||
with: {
|
||||
start_instance: {
|
||||
nodes: {
|
||||
filter: {
|
||||
and: [
|
||||
{
|
||||
equals: {
|
||||
property: ['node', 'externalId'],
|
||||
value: instance.externalId,
|
||||
},
|
||||
},
|
||||
{
|
||||
equals: {
|
||||
property: ['node', 'space'],
|
||||
value: instance.space,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
limit: 1,
|
||||
},
|
||||
start_to_object_edges: {
|
||||
edges: {
|
||||
from: 'start_instance',
|
||||
maxDistance: 1,
|
||||
direction: 'outwards',
|
||||
},
|
||||
limit: 1000,
|
||||
},
|
||||
objects_connected_with_3d: {
|
||||
nodes: {
|
||||
from: 'start_to_object_edges',
|
||||
chainTo: 'destination',
|
||||
filter: {
|
||||
exists: {
|
||||
property: [
|
||||
COGNITE_VISUALIZABLE_VIEW.space,
|
||||
COGNITE_VISUALIZABLE_VIEW.externalId,
|
||||
'object3D',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
object_3ds: {
|
||||
nodes: {
|
||||
from: 'objects_connected_with_3d',
|
||||
through: {
|
||||
view: { type: 'view', ...COGNITE_VISUALIZABLE_VIEW },
|
||||
identifier: 'object3D',
|
||||
},
|
||||
},
|
||||
limit: 1000,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
start_instance: {},
|
||||
start_to_object_edges: {},
|
||||
objects_connected_with_3d: {},
|
||||
object_3ds: {},
|
||||
},
|
||||
});
|
||||
|
||||
// Return the instances that are connected to 3D objects
|
||||
return (
|
||||
response.items?.objects_connected_with_3d?.map((node) => ({
|
||||
space: node.space,
|
||||
externalId: node.externalId,
|
||||
})) ?? []
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error fetching related edge connections:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
enabled: !!sdk,
|
||||
});
|
||||
}
|
||||
|
||||
export function use3dRelatedDirectConnections(instance: DMInstanceRef) {
|
||||
const { sdk } = useRevealContext();
|
||||
return useQuery({
|
||||
queryKey: ['3d-related-direct', instance.space, instance.externalId],
|
||||
queryFn: async (): Promise<DMInstanceRef[]> => {
|
||||
try {
|
||||
// Step 1: Inspect the instance to find its views
|
||||
const views = await sdk.instances.inspect({
|
||||
inspectionOperations: { involvedViews: {} },
|
||||
items: [
|
||||
{
|
||||
instanceType: 'node',
|
||||
externalId: instance.externalId,
|
||||
space: instance.space,
|
||||
},
|
||||
],
|
||||
});
|
||||
const view = views.items[0]?.inspectionResults?.involvedViews?.[0];
|
||||
|
||||
// Step 2: Get the instance content with its views
|
||||
const instanceResponse = await sdk.instances.retrieve({
|
||||
items: [
|
||||
{
|
||||
instanceType: 'node',
|
||||
externalId: instance.externalId,
|
||||
space: instance.space,
|
||||
},
|
||||
],
|
||||
sources: view ? [{ source: view }] : undefined,
|
||||
});
|
||||
|
||||
const instanceContent = instanceResponse.items[0];
|
||||
if (!instanceContent?.properties) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Step 3: Extract all direct relation properties
|
||||
const directlyRelatedObjects = Object.values(
|
||||
instanceContent.properties
|
||||
).flatMap((spaceScope) => {
|
||||
if (typeof spaceScope !== 'object' || !spaceScope) return [];
|
||||
return Object.values(spaceScope).flatMap((fieldValues) => {
|
||||
if (typeof fieldValues !== 'object' || !fieldValues) return [];
|
||||
return Object.values(fieldValues).filter(
|
||||
(value): value is DmsUniqueIdentifier =>
|
||||
typeof value === 'object' &&
|
||||
'externalId' in value &&
|
||||
'space' in value &&
|
||||
typeof value.externalId === 'string' &&
|
||||
typeof value.space === 'string'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
if (directlyRelatedObjects.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Step 4: Inspect all related objects to get their views
|
||||
const relatedObjectInspectionsResult = await sdk.instances.inspect({
|
||||
inspectionOperations: { involvedViews: {} },
|
||||
items: directlyRelatedObjects.map((fdmId) => ({
|
||||
...fdmId,
|
||||
instanceType: 'node',
|
||||
})),
|
||||
});
|
||||
|
||||
const relatedObjectsViewLists =
|
||||
relatedObjectInspectionsResult.items.map(
|
||||
(item) => item.inspectionResults?.involvedViews ?? []
|
||||
);
|
||||
|
||||
// Step 5: Create a mapping of object index to views
|
||||
const relatedObjectViewsWithObjectIndex = relatedObjectsViewLists
|
||||
.map((viewList, idx) => viewList.map((view) => [idx, view] as const))
|
||||
.flat();
|
||||
|
||||
// Step 6: Deduplicate views and fetch their definitions
|
||||
const [deduplicatedViews, viewToDeduplicatedIndexMap] =
|
||||
createDeduplicatedViewToIndexMap(relatedObjectViewsWithObjectIndex);
|
||||
|
||||
const viewProps = await sdk.views.retrieve(
|
||||
deduplicatedViews.map((view) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { type, ...viewWithoutType } = view;
|
||||
return viewWithoutType;
|
||||
}),
|
||||
{ includeInheritedProperties: true }
|
||||
);
|
||||
|
||||
// Step 7: Filter to only 3D-related views
|
||||
const threeDRelatedViews = relatedObjectViewsWithObjectIndex.filter(
|
||||
([, view]) => {
|
||||
const viewResultIndex = viewToDeduplicatedIndexMap.get(
|
||||
createViewKey(view)
|
||||
);
|
||||
if (viewResultIndex === undefined) return false;
|
||||
|
||||
const propsForView = viewProps.items[viewResultIndex];
|
||||
return is3dView(propsForView);
|
||||
}
|
||||
);
|
||||
|
||||
// Step 8: Return the 3D-related instances
|
||||
return threeDRelatedViews.map(([idx]) => directlyRelatedObjects[idx]);
|
||||
} catch (error) {
|
||||
console.error('Error fetching related direct connections:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
enabled: !!sdk,
|
||||
});
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
type ViewKey = `${string}/${string}/${string}`;
|
||||
|
||||
function createViewKey(source: ViewReference): ViewKey {
|
||||
return `${source.externalId}/${source.space}/${source.version}`;
|
||||
}
|
||||
|
||||
function createDeduplicatedViewToIndexMap(
|
||||
viewsWithObjectIndex: Array<readonly [number, ViewReference]>
|
||||
): [Array<ViewReference>, Map<ViewKey, number>] {
|
||||
const deduplicatedViews: Array<ViewReference> = [];
|
||||
const viewToDeduplicatedIndexMap = new Map<ViewKey, number>();
|
||||
viewsWithObjectIndex.forEach(([, view]) => {
|
||||
const viewKey = createViewKey(view);
|
||||
if (!viewToDeduplicatedIndexMap.has(viewKey)) {
|
||||
viewToDeduplicatedIndexMap.set(viewKey, deduplicatedViews.length);
|
||||
deduplicatedViews.push(view);
|
||||
}
|
||||
});
|
||||
return [deduplicatedViews, viewToDeduplicatedIndexMap];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a view is 3D-related by checking if it implements Cognite3DObject
|
||||
*/
|
||||
function is3dView(view: ViewDefinition): boolean {
|
||||
return (view.implements ?? []).some(
|
||||
(type) =>
|
||||
type.externalId === COGNITE_3D_OBJECT_VIEW.externalId &&
|
||||
type.space === COGNITE_3D_OBJECT_VIEW.space &&
|
||||
type.version === COGNITE_3D_OBJECT_VIEW.version
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { Cognite3DViewer } from '@cognite/reveal';
|
||||
|
||||
/**
|
||||
* Automatically removes models from the viewer that are no longer referenced
|
||||
* by the current component tree. Prevents memory accumulation when using
|
||||
* RevealKeepAlive for viewer persistence (50-70% memory reduction on navigation).
|
||||
*/
|
||||
export function useRemoveNonReferencedModels(
|
||||
viewer: Cognite3DViewer | null,
|
||||
activeModelKeys: Set<string>
|
||||
) {
|
||||
const prevKeysRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewer) return;
|
||||
|
||||
const removed = [...prevKeysRef.current].filter((k) => !activeModelKeys.has(k));
|
||||
|
||||
for (const key of removed) {
|
||||
const [modelIdStr, revisionIdStr] = key.split('-');
|
||||
const modelId = parseInt(modelIdStr, 10);
|
||||
const revisionId = parseInt(revisionIdStr, 10);
|
||||
|
||||
if (isNaN(modelId) || isNaN(revisionId)) continue;
|
||||
|
||||
const model = viewer.models.find(
|
||||
(m) => m.modelId === modelId && m.revisionId === revisionId
|
||||
);
|
||||
|
||||
if (model) {
|
||||
try {
|
||||
viewer.removeModel(model);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[useRemoveNonReferencedModels] Error removing model ${key}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prevKeysRef.current = new Set(activeModelKeys);
|
||||
}, [viewer, activeModelKeys]);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useContext } from 'react';
|
||||
import {
|
||||
InstanceStylingContext,
|
||||
type InstanceStylingController,
|
||||
} from '../context/instanceStylingContext';
|
||||
|
||||
export interface RenderTarget {
|
||||
instanceStylingController: InstanceStylingController;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access the render target which includes the instance styling controller.
|
||||
* This allows components to read and react to centralized styling state.
|
||||
*
|
||||
* The controller provides methods to:
|
||||
* - registerStylingGroup(group): Register a new styling group and get its ID
|
||||
* - unregisterStylingGroup(id): Remove a styling group by ID
|
||||
* - getStylingGroups(): Get all current styling groups
|
||||
* - addEventListener(callback): Subscribe to styling changes
|
||||
* - removeEventListener(callback): Unsubscribe from styling changes
|
||||
*/
|
||||
export function useRenderTarget(): RenderTarget {
|
||||
const stylingContext = useContext(InstanceStylingContext);
|
||||
if (!stylingContext) {
|
||||
throw new Error(
|
||||
'useRenderTarget must be used within an InstanceStylingProvider'
|
||||
);
|
||||
}
|
||||
return { instanceStylingController: stylingContext };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Cognite3DViewer } from '@cognite/reveal';
|
||||
import { useRevealContext } from './useRevealContext';
|
||||
|
||||
/**
|
||||
* Hook to access the Reveal viewer instance.
|
||||
* Must be used within a RevealProvider.
|
||||
*/
|
||||
export function useReveal(): Cognite3DViewer {
|
||||
const { viewer } = useRevealContext();
|
||||
return viewer;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useContext } from 'react';
|
||||
import {
|
||||
RevealContext,
|
||||
type RevealContextValue,
|
||||
} from '../context/revealContext';
|
||||
|
||||
export function useRevealContext(): RevealContextValue {
|
||||
const context = useContext(RevealContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useRevealContext must be used within a RevealProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Context
|
||||
export { RevealProvider } from './context/RevealProvider';
|
||||
export { InstanceStylingProvider } from './context/InstanceStylingProvider';
|
||||
export { useRevealContext } from './hooks/useRevealContext';
|
||||
export type { InstanceStylingController } from './context/instanceStylingContext';
|
||||
export { CacheProvider, useCacheContext, useOptionalCacheContext } from './cache/CacheProvider';
|
||||
|
||||
// Hooks
|
||||
export { useReveal } from './hooks/useReveal';
|
||||
export { useModelsForInstanceQuery, use3dModels } from './hooks/useModels';
|
||||
export { useFdmAssetMappings } from './hooks/useFdmMappings';
|
||||
export { usePrefetchedFdmMappings } from './hooks/usePrefetchedFdmMappings';
|
||||
export {
|
||||
use3dRelatedEdgeConnections,
|
||||
use3dRelatedDirectConnections,
|
||||
} from './hooks/useRelatedInstances';
|
||||
export { useRenderTarget, type RenderTarget } from './hooks/useRenderTarget';
|
||||
export {
|
||||
use3dDataForSelectedInstance,
|
||||
useInstancesWithBounds,
|
||||
} from './hooks/use3dDataForSelectedInstance';
|
||||
export { useFindRelated3dInstances } from './hooks/useFindRelated3dInstances';
|
||||
export { useFocusCamera } from './hooks/useFocusCamera';
|
||||
export { useInstanceStyling } from './hooks/useInstanceStyling';
|
||||
export {
|
||||
useInstancesWithBoundingBoxes,
|
||||
getNodesFromModelsFdmMappings,
|
||||
type InstanceWithBoundingBox,
|
||||
type InstancesWithBoxesAndOriginalInstance,
|
||||
type NodesWithModelInfo,
|
||||
} from './hooks/useInstancesWithBoundingBoxes';
|
||||
export { useRemoveNonReferencedModels } from './hooks/useRemoveNonReferencedModels';
|
||||
|
||||
// Components
|
||||
export { RevealCanvas } from './components/RevealCanvas';
|
||||
export { Reveal3DResources } from './components/Reveal3DResources';
|
||||
export {
|
||||
RevealKeepAlive,
|
||||
useRevealKeepAlive,
|
||||
useOptionalRevealKeepAlive,
|
||||
} from './components/RevealKeepAlive';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
ViewerOptions,
|
||||
AddCadResourceOptions,
|
||||
CadModelOptions,
|
||||
TaggedAddResourceOptions,
|
||||
ThreeDModelFdmMappings,
|
||||
FdmAssetStylingGroup,
|
||||
InstanceStylingGroup,
|
||||
RevealContextProps,
|
||||
CogniteModel,
|
||||
} from './types';
|
||||
@@ -0,0 +1,55 @@
|
||||
export interface QualitySettings {
|
||||
cadBudget: {
|
||||
maximumRenderCost: number;
|
||||
highDetailProximityThreshold: number;
|
||||
};
|
||||
pointCloudBudget: {
|
||||
numberOfPoints: number;
|
||||
};
|
||||
resolutionOptions: {
|
||||
maxRenderResolution: number;
|
||||
movingCameraResolutionFactor: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const QUALITY_PRESETS: Record<'low' | 'medium' | 'high', QualitySettings> = {
|
||||
low: {
|
||||
cadBudget: {
|
||||
maximumRenderCost: 5_000_000,
|
||||
highDetailProximityThreshold: 0,
|
||||
},
|
||||
pointCloudBudget: {
|
||||
numberOfPoints: 1_000_000,
|
||||
},
|
||||
resolutionOptions: {
|
||||
maxRenderResolution: 0.7e6,
|
||||
movingCameraResolutionFactor: 0.3,
|
||||
},
|
||||
},
|
||||
medium: {
|
||||
cadBudget: {
|
||||
maximumRenderCost: 15_000_000,
|
||||
highDetailProximityThreshold: 0,
|
||||
},
|
||||
pointCloudBudget: {
|
||||
numberOfPoints: 3_000_000,
|
||||
},
|
||||
resolutionOptions: {
|
||||
maxRenderResolution: 1.4e6,
|
||||
movingCameraResolutionFactor: 0.5,
|
||||
},
|
||||
},
|
||||
high: {
|
||||
cadBudget: {
|
||||
maximumRenderCost: 45_000_000,
|
||||
highDetailProximityThreshold: 10,
|
||||
},
|
||||
pointCloudBudget: {
|
||||
numberOfPoints: 12_000_000,
|
||||
},
|
||||
resolutionOptions: {
|
||||
maxRenderResolution: Infinity,
|
||||
movingCameraResolutionFactor: 1.0,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { Cognite3DViewer } from '@cognite/reveal';
|
||||
import { QUALITY_PRESETS, type QualitySettings } from './QualityPresets';
|
||||
|
||||
const CAMERA_IDLE_DEBOUNCE_MS = 200;
|
||||
|
||||
/**
|
||||
* Centralized quality settings controller for the Reveal viewer.
|
||||
*
|
||||
* Manages CAD/point-cloud budgets, resolution caps, and dynamic resolution
|
||||
* scaling during camera movement. All API calls are wrapped in try/catch
|
||||
* so the controller never crashes viewer initialization if a method is
|
||||
* unavailable on a particular Reveal version.
|
||||
*/
|
||||
export class RevealSettingsController {
|
||||
private settings: QualitySettings;
|
||||
private viewer: Cognite3DViewer | null = null;
|
||||
private moveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private cameraChangeHandler: (() => void) | null = null;
|
||||
|
||||
constructor(quality: 'low' | 'medium' | 'high' = 'medium') {
|
||||
this.settings = QUALITY_PRESETS[quality];
|
||||
}
|
||||
|
||||
applyToViewer(viewer: Cognite3DViewer): void {
|
||||
this.removeEventListeners();
|
||||
this.viewer = viewer;
|
||||
|
||||
try {
|
||||
if ('cadBudget' in viewer) {
|
||||
viewer.cadBudget = this.settings.cadBudget;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[RevealSettingsController] Failed to set cadBudget:', e);
|
||||
}
|
||||
|
||||
try {
|
||||
if ('pointCloudBudget' in viewer) {
|
||||
viewer.pointCloudBudget = this.settings.pointCloudBudget;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[RevealSettingsController] Failed to set pointCloudBudget:', e);
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof viewer.setResolutionOptions === 'function') {
|
||||
viewer.setResolutionOptions({
|
||||
maxRenderResolution: this.settings.resolutionOptions.maxRenderResolution,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[RevealSettingsController] Failed to set resolution:', e);
|
||||
}
|
||||
|
||||
this.setupDynamicResolution(viewer);
|
||||
}
|
||||
|
||||
setQuality(quality: 'low' | 'medium' | 'high'): void {
|
||||
this.settings = QUALITY_PRESETS[quality];
|
||||
if (this.viewer) {
|
||||
this.applyToViewer(this.viewer);
|
||||
}
|
||||
}
|
||||
|
||||
getSettings(): QualitySettings {
|
||||
return { ...this.settings };
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.moveTimeout) {
|
||||
clearTimeout(this.moveTimeout);
|
||||
this.moveTimeout = null;
|
||||
}
|
||||
this.removeEventListeners();
|
||||
}
|
||||
|
||||
private removeEventListeners(): void {
|
||||
if (this.viewer && this.cameraChangeHandler) {
|
||||
try {
|
||||
this.viewer.off('cameraChange', this.cameraChangeHandler);
|
||||
} catch {
|
||||
// Ignore if viewer is already disposed
|
||||
}
|
||||
this.cameraChangeHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
private setupDynamicResolution(viewer: Cognite3DViewer): void {
|
||||
if (typeof viewer.setResolutionOptions !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cameraChangeHandler = () => {
|
||||
try {
|
||||
viewer.setResolutionOptions({
|
||||
maxRenderResolution:
|
||||
this.settings.resolutionOptions.maxRenderResolution *
|
||||
this.settings.resolutionOptions.movingCameraResolutionFactor,
|
||||
});
|
||||
|
||||
if (this.moveTimeout) {
|
||||
clearTimeout(this.moveTimeout);
|
||||
}
|
||||
|
||||
this.moveTimeout = setTimeout(() => {
|
||||
try {
|
||||
viewer.setResolutionOptions({
|
||||
maxRenderResolution: this.settings.resolutionOptions.maxRenderResolution,
|
||||
});
|
||||
} catch {
|
||||
// Viewer may have been disposed
|
||||
}
|
||||
}, CAMERA_IDLE_DEBOUNCE_MS);
|
||||
} catch {
|
||||
// Viewer may have been disposed
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
viewer.on('cameraChange', this.cameraChangeHandler);
|
||||
} catch (e) {
|
||||
console.warn('[RevealSettingsController] Failed to add cameraChange listener:', e);
|
||||
this.cameraChangeHandler = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { CogniteClient, Node3D } from '@cognite/sdk';
|
||||
import type {
|
||||
CogniteCadModel,
|
||||
CognitePointCloudModel,
|
||||
DMInstanceRef,
|
||||
NodeAppearance,
|
||||
Cognite3DViewerOptions,
|
||||
} from '@cognite/reveal';
|
||||
import type * as THREE from 'three';
|
||||
|
||||
// Viewer Options - subset of Cognite3DViewerOptions
|
||||
export type ViewerOptions = Pick<
|
||||
Cognite3DViewerOptions,
|
||||
'loadingIndicatorStyle' | 'antiAliasingHint' | 'ssaoQualityHint'
|
||||
>;
|
||||
|
||||
// Geometry filter for partial model loading (matches Reveal SDK's GeometryFilter)
|
||||
export interface RevealGeometryFilter {
|
||||
boundingBox: THREE.Box3;
|
||||
isBoundingBoxInModelCoordinates?: boolean;
|
||||
}
|
||||
|
||||
// CAD Model Options
|
||||
export interface AddCadResourceOptions {
|
||||
modelId: number;
|
||||
revisionId: number;
|
||||
styling?: {
|
||||
default?: {
|
||||
renderGhosted?: boolean;
|
||||
renderInFront?: boolean;
|
||||
};
|
||||
};
|
||||
geometryFilter?: RevealGeometryFilter;
|
||||
}
|
||||
|
||||
export interface CadModelOptions {
|
||||
type: 'cad';
|
||||
modelId: number;
|
||||
revisionId: number;
|
||||
}
|
||||
|
||||
// Tagged Resource Options
|
||||
export type TaggedAddResourceOptions =
|
||||
| {
|
||||
type: 'cad';
|
||||
addOptions: AddCadResourceOptions;
|
||||
}
|
||||
| {
|
||||
type: 'pointcloud';
|
||||
addOptions: {
|
||||
modelId: number;
|
||||
revisionId: number;
|
||||
};
|
||||
};
|
||||
|
||||
// FDM Mappings
|
||||
export interface ThreeDModelFdmMappings {
|
||||
modelId: number;
|
||||
revisionId: number;
|
||||
mappings: Map<string, Node3D[]>; // instanceKey (space:externalId) -> array of 3D nodes
|
||||
}
|
||||
|
||||
// Styling
|
||||
export interface FdmAssetStylingGroup {
|
||||
fdmAssetExternalIds: DMInstanceRef[];
|
||||
style: {
|
||||
cad?: NodeAppearance;
|
||||
pointcloud?: NodeAppearance;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InstanceStylingGroup {
|
||||
fdmAssetExternalIds?: DMInstanceRef[];
|
||||
style: {
|
||||
cad?: NodeAppearance;
|
||||
pointcloud?: NodeAppearance;
|
||||
};
|
||||
}
|
||||
|
||||
// Reveal Context Props
|
||||
export interface RevealContextProps {
|
||||
children: React.ReactNode;
|
||||
sdk: CogniteClient;
|
||||
color?: THREE.Color;
|
||||
viewerOptions?: ViewerOptions;
|
||||
useCoreDm?: boolean;
|
||||
}
|
||||
|
||||
// Model with type info
|
||||
export type CogniteModel = CogniteCadModel | CognitePointCloudModel;
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Core CDF Data Modeling Types
|
||||
* These are the base types used across the application for working with CDF data models.
|
||||
* They represent the raw structure of data returned from CDF APIs.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Reference to a view in the data model
|
||||
*/
|
||||
export type ViewId = {
|
||||
space: string;
|
||||
externalId: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Base interface for CDF nodes representing the raw structure from CDF APIs.
|
||||
* CDF nests properties as: properties[space][view/version]
|
||||
*/
|
||||
export interface CDFNode {
|
||||
space: string;
|
||||
externalId: string;
|
||||
properties: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Data Mapper Utilities
|
||||
* Unwraps CDF's nested property structure to flat application types
|
||||
*/
|
||||
|
||||
import type { CDFNode, ViewId } from './cdf-types';
|
||||
|
||||
/**
|
||||
* Unwraps properties from a CDF node for a specific view.
|
||||
* CDF nests properties as: properties[space][view/version]
|
||||
* This function flattens them to: { space, externalId, ...properties }
|
||||
*
|
||||
* @example
|
||||
* const batch = unwrapProperties<Batch>(cdfBatch, BATCH_VIEW);
|
||||
* const material = unwrapProperties<Material>(cdfMaterial, MATERIAL_VIEW);
|
||||
*/
|
||||
export function unwrapProperties<
|
||||
T extends { space: string; externalId: string },
|
||||
>(node: CDFNode, view: ViewId): T {
|
||||
const props: Record<string, unknown> = {};
|
||||
|
||||
if (node.properties) {
|
||||
const spaceProps = node.properties[view.space] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (spaceProps) {
|
||||
const viewKey = `${view.externalId}/${view.version}`;
|
||||
const viewProps = spaceProps[viewKey] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (viewProps) {
|
||||
Object.assign(props, viewProps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
space: node.space,
|
||||
externalId: node.externalId,
|
||||
...props,
|
||||
} as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps an array of CDF nodes
|
||||
*
|
||||
* @example
|
||||
* const batches = unwrapPropertiesArray<Batch>(cdfBatches, BATCH_VIEW);
|
||||
*/
|
||||
export function unwrapPropertiesArray<
|
||||
T extends { space: string; externalId: string },
|
||||
>(nodes: CDFNode[], view: ViewId): T[] {
|
||||
return nodes.map((node) => unwrapProperties<T>(node, view));
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Execute async callbacks in parallel with a controlled concurrency limit.
|
||||
* Prevents overwhelming the API while maximizing throughput.
|
||||
*/
|
||||
export async function executeParallel<T>(
|
||||
callbacks: Array<() => Promise<T>>,
|
||||
maxParallel: number
|
||||
): Promise<Array<T | undefined>> {
|
||||
const results: Array<T | undefined> = new Array(callbacks.length);
|
||||
let nextIndex = 0;
|
||||
|
||||
async function runNext(): Promise<void> {
|
||||
const currentIndex = nextIndex++;
|
||||
if (currentIndex >= callbacks.length) return;
|
||||
|
||||
try {
|
||||
results[currentIndex] = await callbacks[currentIndex]();
|
||||
} catch (error) {
|
||||
console.error(`executeParallel: callback ${currentIndex} failed:`, error);
|
||||
results[currentIndex] = undefined;
|
||||
}
|
||||
|
||||
await runNext();
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(maxParallel, callbacks.length) }, () => runNext())
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an array into smaller arrays of the given size.
|
||||
*/
|
||||
export function chunk<T>(array: T[], size: number): T[][] {
|
||||
const chunks: T[][] = [];
|
||||
for (let i = 0; i < array.length; i += size) {
|
||||
chunks.push(array.slice(i, i + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Core Data Model view constants
|
||||
* These reference the standard Cognite Core Data Model views
|
||||
*/
|
||||
|
||||
import { ViewId } from './cdf-types';
|
||||
|
||||
/**
|
||||
* Core Data Model space constant
|
||||
*/
|
||||
export const CORE_DM_SPACE = 'cdf_cdm';
|
||||
|
||||
/**
|
||||
* Core Data Model version
|
||||
*/
|
||||
export const CORE_DM_VERSION = 'v1';
|
||||
|
||||
/**
|
||||
* View reference for CogniteAsset entities (from Core Data Model)
|
||||
*/
|
||||
export const ASSET_VIEW: ViewId = {
|
||||
space: CORE_DM_SPACE,
|
||||
externalId: 'CogniteAsset',
|
||||
version: CORE_DM_VERSION,
|
||||
};
|
||||
|
||||
/**
|
||||
* View reference for Cognite3DObject entities (from Core Data Model)
|
||||
*/
|
||||
export const COGNITE_3D_OBJECT_VIEW: ViewId = {
|
||||
space: CORE_DM_SPACE,
|
||||
externalId: 'Cognite3DObject',
|
||||
version: CORE_DM_VERSION,
|
||||
};
|
||||
|
||||
/**
|
||||
* View reference for CogniteCADNode entities (from Core Data Model)
|
||||
*/
|
||||
export const COGNITE_CAD_NODE_VIEW: ViewId = {
|
||||
space: CORE_DM_SPACE,
|
||||
externalId: 'CogniteCADNode',
|
||||
version: CORE_DM_VERSION,
|
||||
};
|
||||
|
||||
/**
|
||||
* View reference for CogniteVisualizable entities (from Core Data Model)
|
||||
*/
|
||||
export const COGNITE_VISUALIZABLE_VIEW: ViewId = {
|
||||
space: CORE_DM_SPACE,
|
||||
externalId: 'CogniteVisualizable',
|
||||
version: CORE_DM_VERSION,
|
||||
};
|
||||
|
||||
/**
|
||||
* View reference for Cognite3DModel entities (from Core Data Model)
|
||||
*/
|
||||
export const COGNITE_3D_MODEL_VIEW: ViewId = {
|
||||
space: CORE_DM_SPACE,
|
||||
externalId: 'Cognite3DModel',
|
||||
version: CORE_DM_VERSION,
|
||||
};
|
||||
|
||||
/**
|
||||
* View reference for CogniteCADRevision entities (from Core Data Model)
|
||||
*/
|
||||
export const COGNITE_CAD_REVISION_VIEW: ViewId = {
|
||||
space: CORE_DM_SPACE,
|
||||
externalId: 'CogniteCADRevision',
|
||||
version: CORE_DM_VERSION,
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
{
|
||||
"skill_name": "reveal-3d",
|
||||
"evals": [
|
||||
{
|
||||
"id": 1,
|
||||
"prompt": "I have a Dune app at apps/my-app. Add a 3D viewer page where I can browse and load CAD models from my CDF project. Add a button on the main page to reach it.",
|
||||
"expected_output": "A working 3D viewer page with the local Reveal feature bundle copied into the app, model browsing (sdk.models3D.list), Vite configured correctly for Reveal with manual util/assert/process package aliases (NOT vite-plugin-node-polyfills), process polyfill in main.tsx, DuneAuthProvider from @cognite/dune as auth provider, and all provider nesting correct.",
|
||||
"files": [],
|
||||
"assertions": [
|
||||
{
|
||||
"text": "@cognite/reveal, @cognite/sdk, @tanstack/react-query, three, process, util, assert, and ajv are in package.json dependencies",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "All files from skills/reveal-3d/code/reveal are copied into an app-local feature folder such as src/features/reveal-3d",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "Reveal components and hooks are imported from the app-local feature folder, e.g. @/features/reveal-3d",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "vite-plugin-node-polyfills is NOT present in package.json or vite.config.ts",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "vite.config.ts has manual resolve.alias entries for util ('util/'), assert ('assert/'), and process ('process/browser')",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "src/dune-fe-auth-shim.ts does NOT exist (no longer needed — the copied Reveal bundle reads sdk from RevealContext directly)",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "vite.config.ts resolve.alias does NOT contain a 'dune-fe-auth' entry",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "vite.config.ts has resolve.alias.three pointing to node_modules/three/build/three.module.js",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "vite.config.ts has worker: { format: 'es' }",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "vite.config.ts has resolve.dedupe including react, react-dom, and @tanstack/react-query",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "vite.config.ts has optimizeDeps.include with process, util, assert, three, @cognite/reveal, react, react-dom, and @tanstack/react-query",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "vite.config.ts does NOT exclude the copied src/features/reveal-3d bundle from optimizeDeps",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "main.tsx starts with import process from 'process'; window.process = process; before any other imports",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "main.tsx uses DuneAuthProvider from @cognite/dune as the auth provider (NOT CDFAuthenticationProvider)",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "CacheProvider and RevealKeepAlive are rendered at the page/app level, NOT inside a conditionally-rendered component",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "RevealProvider is rendered conditionally (only when a model is selected), inside the stable RevealKeepAlive tree",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "The canvas content component (RevealCanvas + Reveal3DResources) contains NO provider components",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "SDK passed to RevealProvider is memoized with useMemo keyed on sdk.project",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "resources prop for Reveal3DResources is memoized with useMemo",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "onModelsLoaded callback is memoized with useCallback",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "The onLoad prop passed to ModelBrowser is wrapped in useCallback (not an inline arrow function)",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "The viewer container div has an explicit height (e.g. height: '70vh' or h-[65vh])",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "The canvas content component is lazy-loaded with React.lazy and wrapped in Suspense",
|
||||
"type": "content"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"prompt": "I have an FDM asset with space cdf_idm and externalId pump_001. Add a 3D viewer to my asset detail page that shows the CAD model linked to this asset. The detail page is at src/AssetDetail.tsx.",
|
||||
"expected_output": "A viewer component using the app-local Reveal feature bundle and useModelsForInstanceQuery to discover linked 3D models, integrated into AssetDetail.tsx with CacheProvider+RevealKeepAlive at the page level and RevealProvider conditionally rendered.",
|
||||
"files": [],
|
||||
"assertions": [
|
||||
{
|
||||
"text": "useModelsForInstanceQuery is imported from the app-local Reveal feature folder, e.g. @/features/reveal-3d",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "A DMInstanceRef with space 'cdf_idm' and externalId 'pump_001' is passed to useModelsForInstanceQuery",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "CacheProvider and RevealKeepAlive are at the AssetDetail page level (not inside the conditional viewer area)",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "RevealProvider is conditionally rendered (only when models data exists)",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "resources is derived from useModelsForInstanceQuery result and memoized with useMemo",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "onModelsLoaded is wrapped in useCallback",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "SDK is memoized with useMemo keyed on sdk.project",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "The viewer container has an explicit height",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "main.tsx uses DuneAuthProvider from @cognite/dune as the auth provider (NOT CDFAuthenticationProvider)",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "vite.config.ts uses manual util/assert/process aliases and does NOT use vite-plugin-node-polyfills",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "src/dune-fe-auth-shim.ts does NOT exist and vite.config.ts resolve.alias has no 'dune-fe-auth' entry",
|
||||
"type": "content"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"prompt": "Add a full-screen 3D viewer that loads model ID 206509079235820 revision 576781257263693. Put it at a /viewer route.",
|
||||
"expected_output": "A full-screen viewer component using the app-local Reveal feature bundle with direct modelId/revisionId (Pattern B), accessible at /viewer, with correct provider nesting and Vite config.",
|
||||
"files": [],
|
||||
"assertions": [
|
||||
{
|
||||
"text": "Reveal3DResources receives a resources array containing modelId 206509079235820 and revisionId 576781257263693",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "The resources array is memoized with useMemo",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "onModelsLoaded is wrapped in useCallback",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "CacheProvider and RevealKeepAlive are at the viewer page level, not co-located with RevealProvider in the same conditional block",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "The viewer container is full-screen (height: 100vh or h-screen or equivalent)",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "A route is configured at /viewer",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "vite.config.ts has worker: { format: 'es' } and uses manual util/assert/process aliases (NOT vite-plugin-node-polyfills)",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "main.tsx uses DuneAuthProvider from @cognite/dune as the auth provider (NOT CDFAuthenticationProvider)",
|
||||
"type": "content"
|
||||
},
|
||||
{
|
||||
"text": "The canvas content is lazy-loaded with React.lazy and Suspense",
|
||||
"type": "content"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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