Frontend Development Guide
React application with TypeScript, Three.js for 3D rendering, PrimeReact UI components, and React Query for server state.
Quick Start
cd src/frontend
npm install
npm run dev # Start at http://localhost:5173
Environment: Set VITE_API_BASE_URL in the root .env
Project Structure
src/frontend/src/
├── features/ # Feature-based modules (primary code location)
│ ├── texture-set/ # Texture set management (44 files)
│ ├── model-viewer/ # 3D viewer and controls (38 files)
│ ├── models/ # Model list and upload
│ ├── recycled-files/ # Recycle bin functionality
│ ├── stage-editor/ # Stage/environment editing
│ ├── sprite/ # Sprite sheets
│ ├── pack/ # Asset packs (thin wrapper → ContainerViewer)
│ ├── project/ # Project management (thin wrapper → ContainerViewer)
│ ├── thumbnail/ # Thumbnail display
│ └── history/ # Upload history
├── shared/ # Shared components and types
│ ├── components/ # ContainerViewer, UploadableGrid
│ └── types/ # ContainerTypes (adapter pattern)
├── components/ # Shared UI components
├── hooks/ # Shared custom hooks
├── contexts/ # React Context providers
├── services/ # API clients
│ └── ApiClient.ts # Legacy facade (avoid importing from components)
├── stores/ # Zustand stores
└── utils/ # Helper functions
Server State (React Query)
- Query client config:
src/lib/react-query.ts(app-wideQueryClient) - App wiring:
src/main.tsx(ErrorBoundary+QueryClientProvider+ devtools) - Feature queries:
features/*/api/queries.tsexportsqueryOptions+useQuerywrappers - Mutations: prefer
useMutationin components andqueryClient.invalidateQueries()on success/settle - API calls: prefer feature-local modules like
features/pack/api/packApi.tsover importingservices/ApiClientin components - Axios base client:
src/lib/apiBase.tscentralizes request/response interceptors (defaultAcceptheader + normalizedApiClientErrorfor consistenterror.messagehandling) - Recent migration examples:
features/texture-set/components/TextureSetList.tsxnow uses React QueryuseMutationfor create/delete texture-set actions.features/thumbnail/hooks/useThumbnail.tsnow invalidates model queries on SignalR thumbnail/version events.features/stage-editor/components/StageList.tsxnow usesuseMutationfor stage creation instead of inline async try/catch writes.features/model-viewer/components/ModelInfo.tsxnow usesuseMutationfor tag/description saves and texture-set disassociation with model query invalidation.features/texture-set/components/TextureSetModelList.tsxnow usesuseMutationfor model linking and invalidates model list/detail queries.features/sounds/components/SoundCard.tsxnow usesgetFileUrl(...)consistently and restores the.sound-cardroot class (required by sound card styling and e2e selectors).features/texture-set/components/TextureSetList.tsxnow usesfeatures/texture-set/api/queries.ts(queryOptions+useTextureSetsQuery) for server state reads while keeping load-more UX.features/stage-editor/components/StageList.tsxnow usesfeatures/stage-editor/api/queries.ts(useStagesQuery) for reads and invalidation-based refresh after create.features/models/components/ModelVersionHistory.tsxnow usesfeatures/model-viewer/api/queries.ts(useModelVersionsQuery) for version list loading.components/tabs/Settings.tsxnow usesfeatures/settings/api/queries.ts(useSettingsQuery) for initial settings load.
Form Validation
- Preferred stack:
react-hook-form+zod+@hookform/resolvers - Shared schemas live in
src/shared/validation/formSchemas.ts - Keep form UX behavior unchanged when migrating (same submit/cancel flow, same toasts/messages)
- Current migrated examples:
features/texture-set/dialogs/CreateTextureSetDialog.tsxfeatures/texture-set/dialogs/SetHeader.tsxfeatures/sprite/components/SpriteList.tsx(category create/edit + sprite rename dialog controls)components/tabs/Settings.tsx(settings form fields)features/sounds/components/SoundList.tsx(category create/edit dialog)features/pack/components/PackList.tsx(create pack dialog)features/project/components/ProjectList.tsx(create project dialog)
Code Splitting
- Tab content in
components/layout/TabContent.tsxusesReact.lazy+Suspensefor heavy feature entries so the initial bundle is reduced. - Lazy-loaded tab views include:
ModelViewer,TextureSetList,TextureSetViewer,PackList,PackViewer,ProjectList,ProjectViewer,SpriteList,SoundList,StageList,StageEditor, andRecycledFilesList. - Fallback behavior remains tab-local via a lightweight loading state rendered inside the tab content area.
Features
Texture Sets
Purpose: Manage PBR texture collections that can be applied to 3D models via material-slot-based mapping. Each model version has material names (extracted by the asset processor from the 3D file), and texture sets are linked to specific material slots. Each model version can have independent default texture sets. Texture sets are distinguished by kind:
- Model-Specific (Baked) — default; textures baked for a specific model's UV layout.
- Universal (Tileable) — seamless material textures (e.g., "Brick Wall", "Wood Floor") that can tile and be shared across models.
Material-Slot Mapping (Key Types):
TextureMappingDtoinfeatures/model-viewer/types/index.ts:{ materialName: string, textureSetId: number, variantName: string }ModelVersionDtoincludes:materialNames: string[],textureMappings: TextureMappingDto[],textureSetIds: number[],variantNames: string[],mainVariantName: string | nullModelSummaryDtoinfeatures/texture-set/types/index.tsincludes:materialName: stringtextureSetApi.tsassociation functions accept optionalmaterialNameparameter- Empty string
materialName= "default/all materials" (backward compatibility) variantNamefield: identifies the preset/variant for the mapping. Empty string = Default preset.textureSetApi.tsassociation/disassociation functions accept optionalvariantNameparameter- Presets (Variants): Variant names are persisted in the
VariantNamestext[] column onModelVersion, independent of texture mappings. Empty presets (with no linked texture sets) survive. New presets are created viaPOST /model-versions/{versionId}/variantsand deleted viaDELETE /model-versions/{versionId}/variants/{variantName}. Variant names are also auto-registered when creating texture mappings with a new variant name.mainVariantNameis auto-set to the first named variant when not yet configured. MaterialsPanel.tsxalways shows the preset dropdown, with Add/Delete buttons on the same row, and "Set as Main" button/badge on a separate row below. Adding a preset calls the backend API directly (no more ephemerallocalPresetsstate). Deleting a preset calls the backend which removes the variant name and all its texture mappings.TextureSetAssociationDialog.tsxis a single-select dialog — only one texture set can be linked per material per variant. It filterstextureMappingsby bothmaterialNameandvariantNameto show only the texture set currently linked to the specific material being edited. The dialog header shows the material name for clarity.- Per-material texture rendering:
TexturedModel.tsxacceptsmaterialTextureSets: MaterialTextureSets(aRecord<string, TextureSetDto>mapping material names to texture sets). During mesh traversal, each mesh'smaterial.nameis matched against the map keys. A key of""acts as a wildcard for meshes with no specific mapping.ModelViewer.tsxbuilds this map fromtextureMappingsfiltered by the selected variant. Changing the variant dropdown updates the 3D preview in real-time. - Variant selection wiring:
ModelViewer.tsxmanagesselectedVariantstate (initialized tomainVariantName).onVariantChangehandler is passed toViewerSidePanel→MaterialsPanel. When the preset dropdown changes, the preview re-renders with the correct per-material textures.
Where to look:
| Layer | Location |
|---|---|
| Frontend components | features/texture-set/components/ (22 files) |
| Frontend dialogs | features/texture-set/dialogs/ (20 files) |
| Frontend hooks | features/texture-set/hooks/ |
| Frontend types | features/texture-set/types/index.ts (TextureSetKind, UvMappingMode enums, ModelSummaryDto.materialName) |
| Model viewer types | features/model-viewer/types/index.ts (TextureMappingDto, ModelVersionDto.materialNames/textureMappings) |
| Texture set API | features/texture-set/api/textureSetApi.ts (association with optional materialName) |
| Backend API | WebApi/Endpoints/TextureSetEndpoints.cs |
| Backend domain | Domain/Models/TextureSet.cs, Domain/Models/ModelVersionTextureSet.cs |
| E2E tests | tests/e2e/features/00-texture-sets/ |
Key behaviors (from E2E tests):
- Create texture sets by uploading images (auto-named from filename)
- Choose kind (Model-Specific or Universal) at creation time
- Filter list by kind using the tab bar (Model-Specific / Global Materials). Default tab is "Global Materials"
- Drag-and-drop texture sets between kind tabs to change their kind via API
- Custom tab buttons (not PrimeReact SelectButton) serve as drop targets for kind changes
- Universal sets show a "Universal (Tileable)" label in stats
- Universal sets have UV mapping mode toggle (Standard / Physical) in the 3D preview settings
- Physical mode shows a "Tile Size" slider (0.1–5m) that controls
uvScale(world-space size of one tile) - Standard mode shows Tile X / Tile Y sliders for direct repeat values
- UV mapping mode and scale auto-save after 1 second of debounce when adjusted
- Link texture sets to model versions (versions are independent)
- Set default texture per version (triggers thumbnail regeneration)
- Preview different textures in 3D viewer without setting as default
Effects of changes:
- Changing default texture set → triggers thumbnail worker job
- Linking/unlinking → updates ModelVersion material-slot associations (via
ModelVersionTextureSetjoin entity with composite PK:ModelVersionId, TextureSetId, MaterialName, VariantName) - Asset processor extracts material names from 3D files and saves them via
PUT /model-versions/{id}/material-names - Deleting texture set → affects all linked model versions
- Updating tiling scale / UV mapping → only allowed for Universal kind (API returns 400 for ModelSpecific)
- Adding texture to Universal set → auto-enqueues thumbnail generation (sphere preview)
- "Regenerate Thumbnail" button inside the 3D Preview tab (TexturePreviewPanel) for Universal sets. Passes live UV Scale and Geometry Type to the backend.
- "Regenerate Thumbnail" option in TextureSetGrid context menu (right-click) for Universal sets
- Changing kind to Universal → auto-enqueues thumbnail generation on the backend
EXR texture preview:
The TexturePreview component supports EXR file rendering. EXR files are detected by filename extension. For EXR files, the component first attempts to load a server-side preview via GET /files/{id}/preview (a lightweight PNG generated by the worker during texture set processing). If the preview is available, it is displayed directly. If no preview exists (404), it falls back to direct EXR parsing: the file is fetched as ArrayBuffer, decoded using Three.js's EXRLoader (three/examples/jsm/loaders/EXRLoader.js), and rendered to a <canvas> element with Reinhard tone mapping. Single-channel EXR extraction is also supported. Components that display textures (TextureCard, FilesTab, HeightCard) use TexturePreview instead of raw <img> tags to enable EXR/HDR support. HeightCard uses the same CSS classes (texture-card-with-preview/texture-preview-image) and overlay pattern as TextureCard for consistent preview layout.
Direct EXR fallback protection: files exceeding 10 MB (MAX_EXR_BYTES) are skipped in the fallback path (an error state is shown instead of attempting decode). However, server-side previews work for EXR files of any size. Decoded EXR images are downsampled to a maximum canvas size of 512×512 to avoid excessive memory usage. The fetch uses an AbortController so pending downloads are cancelled on component unmount.
File preview system:
Thumbnail previews are auto-generated on file upload for all ImageSharp-compatible image formats (PNG, JPEG, BMP, GIF, WebP) as well as EXR files (via Magick.NET with Reinhard tone mapping). For texture files, 4 thumbnails are generated: RGB (composite), R (red channel), G (green channel), and B (blue channel). For sprite files, 1 RGB thumbnail is generated. Thumbnails are 256px (longest dimension, aspect ratio preserved) and saved as PNG at {uploadRoot}/previews/{sha256Hash}.png (RGB) and {uploadRoot}/previews/{sha256Hash}_{channel}.png (per-channel). Deduplicated files share the same preview since the path is hash-based.
The backend serves previews via GET /files/{id}/preview?channel=rgb|r|g|b (default: rgb). The worker can also upload previews for exotic formats (TGA) via POST /files/{id}/preview/upload.
All frontend preview surfaces use getFilePreviewUrl(fileId, channel?) from modelApi.ts instead of serving raw files. Components updated: TextureCard, FilesTab, TextureSetGrid, HeightCard, SpriteList, TexturePreview. The TexturePreview component is a simple <img> wrapper that applies channel-specific URL parameters based on the sourceChannel prop.
Generation is handled by FileThumbnailGenerator (Infrastructure layer, registered as singleton). It uses IFilePreviewService for storage and path resolution. The MIME type "image/*" (used by the domain for generic textures) is treated as a supported type. EXR files are detected by their 4-byte magic number (0x76 0x2F 0x31 0x01) and loaded via Magick.NET (ImageMagick) with Reinhard tone mapping matching the worker's toneMapReinhard function. Standard formats are loaded via ImageSharp. TGA files gracefully fail at the ImageSharp load step and are caught.
3D texture preview shapes (TexturedGeometry.tsx):
The texture-set viewer renders a 3D preview of applied textures on selectable geometry shapes: box, sphere, cylinder, or torus. The cylinder uses openEnded: false (caps visible).
Key rendering features:
- Vertex welding: All primitives are passed through
mergeVertices()(fromBufferGeometryUtils) before rendering. This welds shared vertices so displacement mapping doesn't tear the mesh at edges/seams. - Icosahedron for sphere: The sphere uses
IcosahedronGeometry(radius, 5)instead ofSphereGeometryto provide uniform vertex distribution and eliminate pole-pinching artifacts. - Simple UV scaling: The
uvScalevalue from the database is used directly astexture.repeat.set(scale, scale). No complex physical tiling calculations. - Fixed geometry sizes: All primitive geometries use hardcoded unit sizes (e.g., box 2×2×2, sphere radius 1.2). The
<Stage>/<Bounds>component auto-frames the object, so dynamic physical sizes are unnecessary and would cause camera jumps. - Simplified Global Material controls: For Universal (Global Material) texture sets, the PreviewSettings panel hides Scale, Rotation Speed, and per-geometry parameter sliders. Only Geometry Type, UV Scale, and Wireframe are shown.
- Texture Quality selector: The
TextureSetViewerheader (not PreviewInfo) includes a "Texture Quality" dropdown, visible only for Universal texture sets. The first option shows the actual original resolution (detected viaImage.naturalWidth/Heightprobe) e.g. "4096 px" instead of a generic label. Proxy sizes larger/equal to the original are hidden. Unavailable sizes are styled with opacity and "(N/A)" label, with a Generate icon button that triggers proxy generation viaregenerateTextureSetThumbnailwithproxySize. The dropdown state is owned byTextureSetViewerand passed as a prop toTexturePreviewPanel→TexturedGeometry. - Split-channel texture handling:
TexturedGeometry.tsx'sbuildTextureUrlsincludessourceChannelinTextureUrlInfofor split-channel textures (R/G/B/A). At original quality, channel extraction is done client-side viaextractChannelFromBitmap()(canvas-based). When using proxies, channel extraction was already done server-side during proxy generation. Texture fetches are deduplicated by URL — multiple texture types sharing the same source file (e.g., AO/Roughness/Metallic from a packed ARM map) fetch the file only once, with each type extracting its specific channel. - Proxy size badges: Displayed in three locations: (1)
TexturesTable("Textures" tab in TextureSetDetailDialog) — per-texture row badges, (2)FilesTab("Files" tab in TextureSetViewer) — aggregated per-file badges after the "Used as" section, (3)TextureSetGrid— small green badges in the top-right corner of card thumbnails showing available proxy sizes. All use PrimeReactTagcomponents withseverity="success"for available sizes. - Context menu proxy generation:
TextureSetGridright-click context menu includes a "Generate Proxies" submenu (visible for Universal sets) with 256/512/1024/2048 px options, each triggeringregenerateTextureSetThumbnailwith the correspondingproxySize. - IBL lighting: Only the "city" environment HDR file is bundled locally (
public/hdri/potsdamer_platz_1k.hdr, ~1.5 MB). All other 9 presets are fetched on demand from the drei assets CDN and cached via the Cache API (modelibr-hdricache) for offline use. TheuseEnvironmentPresetshook (inhooks/useEnvironmentPresets.ts) manages online/offline detection, cache status, and async HDR URL resolution. TheenvironmentPresets.tsutility providesresolveHdrUrl()which returns a local path for city, a blob URL from cache for cached presets, or fetches+caches from CDN for online presets — falling back to city when offline and uncached. InViewerMenubar.tsx, unavailable presets are disabled with "(offline)" label when the browser is offline. The<Stage>component usesenvironment={null}since lighting comes from the separate<Environment>component.
The component loads textures (standard + EXR) asynchronously, sets correct color spaces (sRGB for color textures, linear for data textures), and applies RepeatWrapping.
Thumbnail generation for Universal sets:
Universal (Global Material) texture sets get auto-generated preview thumbnails. When textures are added to a Universal set, the backend auto-enqueues a thumbnail job. The asset processor's TextureSetProcessor renders the textures on the configured geometry type (sphere by default, but also supports box, cylinder, torus), generates a single static image (30° angle, 15° elevation), and uploads the result. The frontend TextureSetGrid prefers the generated thumbnail URL over the albedo texture for Universal sets that have a thumbnailPath. The "Regenerate Thumbnail" button lives inside the TexturePreviewPanel (the Preview tab's 3D canvas area) and passes the current live uvScale and geometryType to the backend, so the generated thumbnail matches what the user sees on screen.
Model Viewer
Purpose: Display 3D models with texture preview, version switching, and viewer controls.
Where to look:
| Layer | Location |
|---|---|
| Main viewer | features/model-viewer/components/ModelViewer.tsx (26KB - main orchestrator) |
| Canvas error boundary | features/model-viewer/components/CanvasErrorBoundary.tsx — catches React 19 Error #310 (cross-component state update during R3F render) and auto-retries up to 3 times |
| 3D rendering | features/model-viewer/components/Model.tsx, TexturedModel.tsx |
| Version strip | features/model-viewer/components/VersionStrip.tsx |
| Viewer settings | features/model-viewer/components/ViewerSettings.tsx |
| Model info panel | features/model-viewer/components/ModelInfo.tsx |
| Model hierarchy | features/model-viewer/components/ModelHierarchy.tsx |
| UV map display | features/model-viewer/components/UVMapWindow.tsx |
| Texture selector | features/model-viewer/components/TextureSetSelectorWindow.tsx |
| Backend API | WebApi/Endpoints/ModelEndpoints.cs, ModelVersionEndpoints.cs |
| E2E tests | tests/e2e/features/01-model-viewer/ |
Key behaviors (from E2E tests):
- Render models in 3D canvas with controls visible
- Control buttons: Add Version, Viewer Settings, Model Info, Texture Sets, Model Hierarchy, Thumbnail Details, UV Map
- Version dropdown with thumbnail previews
- Switching versions updates viewer and file info
- Drag-and-drop file onto viewer (including
.blend) opensFileUploadModal— user chooses "add to current version" or "create new version"..blendfiles no longer bypass this modal..glbextraction via the asset-processor only fires when "create new version" is chosen (triggersModelUploadedEvent).
Effects of changes:
- Viewer changes → affects thumbnail generation (if settings differ)
- Version switching → loads different 3D file and associated textures
- Texture selection → updates 3D preview in real-time
Model List
Purpose: Display library of 3D models with thumbnails, search, upload, and navigation to viewer.
Where to look:
| Layer | Location |
|---|---|
| Main list component | features/models/components/ModelList.tsx |
| Grid component | features/models/components/ModelGrid/ModelGrid.tsx |
| Grid types & props | features/models/components/ModelGrid/types.ts |
| Grid hook | features/models/components/ModelGrid/useModelGrid.ts |
| Filters bar | features/models/components/ModelGrid/ModelsFilters.tsx |
| Card width control | features/models/components/ModelGrid/CardWidthButton.tsx |
| Context menu | features/models/components/ModelGrid/ModelContextMenu.tsx |
| Header/controls | features/models/components/ModelListHeader.tsx |
| Version history | features/models/components/ModelVersionHistory.tsx |
| Empty/error states | features/models/components/EmptyState.tsx, ErrorState.tsx |
| Upload progress | features/models/components/UploadProgress.tsx |
| Backend API | WebApi/Endpoints/ModelEndpoints.cs |
| E2E tests | tests/e2e/features/00-texture-sets/01-setup.feature (model creation) |
Key behaviors:
- Display model cards with thumbnails
- Upload via drag-and-drop or file picker
- Click model card to open in viewer
- Show upload progress and status
- Context menu for model actions (delete, add to pack, add to project)
- ModelGrid is a reusable standalone component accepting
projectId,packId,textureSetIdprops - Filters are additive (pack + project can be combined)
- When a prop is provided, its corresponding filter is pre-selected and disabled
- Card width controlled via icon button with OverlayPanel slider
Effects of changes:
- Upload → creates thumbnail job, fires SignalR notification
- Delete → soft deletes to recycled files
- Click → opens ModelViewer in new tab
Recycled Files
Purpose: Soft delete and restore models, versions, texture sets, sprites, sounds, and individual files. Protects shared files from permanent deletion.
Where to look:
| Layer | Location |
|---|---|
| Frontend | features/recycled-files/components/ |
| Backend API | WebApi/Endpoints/RecycledFilesEndpoints.cs |
| Backend API | WebApi/Endpoints/FilesEndpoints.cs (DELETE /files/{id} for soft-delete) |
| E2E tests | tests/e2e/features/04-recycled-files/ (7 feature files) |
Key behaviors (from E2E tests):
- Recycled items disappear from main grids
- Recycled items appear in Recycle Bin (sections: Models, Model Versions, Texture Sets, Files, Sprites, Sounds)
- Thumbnails and previews display for all recycled item types (backend serves files/thumbnails regardless of soft-delete status)
- Recycled files list auto-refreshes on every mount (
staleTime: 0) — no manual Refresh button - Restore moves items back to main grids
- Permanent delete removes from database and disk
- Shared file protection: cannot permanently delete files used elsewhere
- Re-uploading a file whose hash matches a recycled file cleans up the recycled record
File deletion flow:
- Files tab (texture set viewer): "Delete" button below preview soft-deletes individual files → moves to Recycled Files
- Texture Types tab: × icon unlinks texture from type (file remains)
- Recycled Files: "Delete Forever" hard-deletes from both disk and database
Effects of changes:
- Soft delete → sets
IsDeleted=true, removes from main queries - Restore → clears
IsDeleted, item reappears - Permanent delete → cascading deletion respects shared files
Upload Window
Purpose: Show upload progress for model files with batch support and history.
Where to look:
| Layer | Location |
|---|---|
| Upload progress | components/UploadProgressWindow.tsx (or similar) |
| Batch upload | Uses batchId parameter on uploads |
| History | features/history/ |
| Backend API | WebApi/Endpoints/ModelEndpoints.cs, BatchUploadEndpoints.cs |
| E2E tests | tests/e2e/features/03-upload-window/ |
Key behaviors (from E2E tests):
- Shows filename and extension during upload
- "Open in Tab" button navigates to model viewer
- Clicking "Open in Tab" twice activates existing tab (no duplicates)
- "Clear Completed" removes finished uploads from window
- Batch uploads group multiple files
Effects of changes:
- Upload → creates Model, triggers thumbnail generation
- Batch upload → groups uploads with shared batchId
- Open in Tab → creates or activates model viewer tab
Dock System
Purpose: Tab-based panel management with URL state persistence. Enables shareable deep links to specific views.
Where to look:
| Layer | Location |
|---|---|
| Tab context | contexts/TabContext.tsx |
| Tab hook | hooks/useTabContext.tsx |
| Tab serialization | utils/tabSerialization.ts |
| Layout components | components/layout/DockPanel.tsx, SplitterLayout.tsx |
| URL state | Uses nuqs library |
| E2E tests | tests/e2e/features/02-dock-system/ |
Key behaviors (from E2E tests):
- Tabs persist in URL (e.g.,
?leftTabs=modelList,model-1&activeLeft=model-1) - Opening model adds tab to URL automatically
- Duplicate tabs are deduplicated on URL load
- URL deduplication removes repeated tab IDs
Effects of changes:
- Tab changes → URL updates (enables browser back/forward)
- URL changes → tabs update (enables bookmarks/sharing)
- Tab close → removes from URL
Packs & Projects (ContainerViewer)
Purpose: Manage asset containers (Packs and Projects) that group Models, Texture Sets, Sprites, and Sounds. Both views share identical behavior via a shared ContainerViewer component.
Architecture: Strategy/Adapter pattern using ContainerAdapter interface.
Where to look:
| Layer | Location |
|---|---|
| Shared viewer | shared/components/ContainerViewer.tsx (~1500 lines - main logic) |
| Shared types | shared/types/ContainerTypes.ts (ContainerDto, ContainerAdapter) |
| Shared CSS | shared/components/ContainerViewer.css (container-* prefix) |
| Pack wrapper | features/pack/components/PackViewer.tsx (~55 lines - thin adapter) |
| Project wrapper | features/project/components/ProjectViewer.tsx (~55 lines - thin adapter) |
| Backend API | WebApi/Endpoints/PackEndpoints.cs, ProjectEndpoints.cs |
Key behaviors:
- Pack/Project viewers are thin wrappers that provide a
ContainerAdaptertoContainerViewer ContainerAdaptermaps container-specific API methods (e.g.,ApiClient.addModelToPackvsApiClient.addModelToProject)- All UI logic, state management, dialogs, and grid rendering live in the shared
ContainerViewer - Add/remove models, texture sets, sprites, sounds via dialog pickers
- Drag-and-drop file upload for textures, sprites, and sounds directly into container
- Context menus for item actions (remove from container)
- Collapsible sections with click-to-toggle headers and count badges
- Pagination with "Load More" pattern for each asset section (models, textures, sprites, sounds)
- Drag-and-drop upload works even on collapsed section headers
- Title displays "Pack: {name}" or "Project: {name}" format
- Header shows counts for all asset types including sounds
Effects of changes:
- Changes to
ContainerViewer.tsxaffect BOTH Pack and Project views - To add container-specific behavior, extend the
ContainerAdapterinterface - CSS uses
container-prefix (notpack-orproject-) - Pagination uses
ApiClient.*Paginated()methods directly (not through adapter)
Thumbnails
Purpose: Display model thumbnails with real-time generation status via SignalR.
Where to look:
| Layer | Location |
|---|---|
| Frontend components | features/thumbnail/ |
| Thumbnail display | features/model-viewer/components/ThumbnailWindow.tsx |
| Model list display | features/models/components/ModelGrid/ModelGrid.tsx (thumbnail cards) |
| Backend API | WebApi/Endpoints/ThumbnailEndpoints.cs (487 lines - many endpoints) |
| Worker service | src/thumbnail-worker/ (see docs/WORKER.md) |
| SignalR updates | Worker sends status via SignalR hub |
Key behaviors:
- Auto-generated on model upload
- Status: NotGenerated, Pending, Processing, Ready, Failed
- SignalR broadcasts status changes in real-time via
AllModelsGroup ThumbnailSignalRServiceis a singleton —connect()awaits pending connections to avoid StrictMode racesuseThumbnailSignalR([])inApp.tsxensures connection at app startup- Auto-reconnect re-joins
AllModelsGroupautomatically useThumbnailhook independently subscribes to SignalR for cache-busted thumbnail refresh- Custom thumbnails can be uploaded
Effects of changes:
- Changing default texture → triggers thumbnail regeneration
- Version upload → new thumbnail job queued
- Worker failure → status stays at "Failed"
API Integration
ALWAYS use ApiClient - never use fetch directly:
import apiClient from "../../services/ApiClient";
const models = await apiClient.getModels();
const textureSet = await apiClient.getTextureSet(id);
Pagination
All list endpoints support server-side pagination via "Load More" pattern. Use paginated methods for list views:
// Paginated methods (bypass cache, used in list components)
const result = await apiClient.getModelsPaginated(page, pageSize); // → PaginatedResponse<Model>
const result = await apiClient.getSoundsPaginated({ page, pageSize }); // → { sounds, totalCount, page, pageSize, totalPages }
const result = await apiClient.getSpritesPaginated({ page, pageSize });
const result = await apiClient.getTextureSetsPaginated({ page, pageSize });
List components use PaginationState from types/index.ts to track page, totalPages, totalCount, and hasMore. The loadMore parameter controls whether results append to the existing list (true) or replace it (false / default). Category filtering for Sounds/Sprites is applied client-side after fetching paginated data.
State Management
| Type | When to use | Example |
|---|---|---|
| Zustand | Navigation, tabs, persistence | useNavigationStore |
| Zustand | Cached API data | useApiCacheStore |
| Zustand | Panel sizes, card widths | usePanelStore, cardWidthStore |
| Context | Cross-component (dock layout) | DockContext |
| React Query | Server state, entity data | useQuery, useMutation |
| Local state | Component-specific | useState |
Navigation Store (stores/navigationStore.ts)
The primary store for tab and window management. Replaces the former URL-based state (useQueryState/nuqs) and TabContext.
- Multi-window aware: Each browser tab gets a UUID in
sessionStorage, stored as a key inactiveWindows: Record<windowId, WindowState> - WindowState:
{ tabs: Tab[], activeTabId, activeRightTabId, splitterSize, lastActiveAt } - Independent panel active tabs: Left and right panels track their active tab independently via
activeTabId(left) andactiveRightTabId(right). Clicking a tab in one panel does not affect the other panel's active tab.SplitterLayoutderives per-panel active tabs from these two fields. - Persisted to localStorage via Zustand
persistmiddleware (survives F5) - Cross-window sync:
BroadcastChannel('modelibr_navigation')for tab moves, window close events - Session recovery:
recentlyClosedTabs(max 10) andrecentlyClosedWindows(max 5) stored in the global store - Stale window GC: Windows inactive >24h are automatically pruned on init
- Tab creation: Always use
createTab(type, id?, name?)factory — populatesparams,internalUiState, and legacy accessors - Per-tab UI state:
tab.internalUiStatepersists sub-tab indices, scroll positions, etc. viauseTabUiState(tabId, key, default)hook
Key hooks
| Hook | File | Purpose |
|---|---|---|
useWindowInit | hooks/useWindowInit.ts | Initialize window in store, setup BroadcastChannel, pagehide broadcast (no store mutation on unload) |
useDeepLinkHandler | hooks/useDeepLinkHandler.ts | Parse URL deep links (/view/model/123), open tab, clean URL |
useTabUiState | hooks/useTabUiState.ts | Generic [value, setter] for persistent per-tab UI state |
useSessionRecovery | hooks/useSessionRecovery.ts | Access recently closed windows and restore them |
Design Philosophy
- Named exports only: All components use named exports (
export function ComponentName/export const ComponentName). Noexport defaultexceptApp.tsxandmain.tsx. Barrel files (index.ts) re-export withexport { Name } from './File'. - Single responsibility: One component, one job
- Direct API calls: No unnecessary abstractions
- Local state default: Lift only when needed
- Don't create hooks unless reused 3+ times
Technology Stack
- React 18+, TypeScript, Vite
- Three.js + React Three Fiber + Drei
- PrimeReact UI components
- Zustand for navigation/tab state (persisted to localStorage)
- React Query for server state
Testing
npm test # Run tests
npm run storybook # Component docs at http://localhost:6006
Related Docs
- Backend API:
docs/BACKEND_API.md - Worker:
docs/WORKER.md - E2E Tests:
tests/e2e/README.md