diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..fd813dd3f --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,26 @@ +{ + "permissions": { + "allow": [ + "Bash(npm run:*)", + "Skill(review-react)", + "Skill(review-react:*)", + "Bash(python3 reorganize_css.py)", + "Bash(find d:DriveVimvim-websrc -type f \\\\\\(-name *.css -o -name *.scss \\\\\\))", + "Bash(npx tsc:*)", + "Bash(npm ls:*)", + "Bash(npm view:*)", + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\(''''homepage'''',''''no homepage''''\\)\\)\")", + "Bash(npm search:*)", + "Bash(python3 -c \":*)", + "Bash(npm install:*)", + "Bash(npm uninstall:*)", + "Bash(npm outdated:*)", + "Bash(node -e \"const vf = require\\(''vim-format''\\); console.log\\(''Import OK''\\); console.log\\(''Exports:'', Object.keys\\(vf\\).slice\\(0, 10\\).join\\('', ''\\), ''...''\\)\")", + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(''''main:'''', d.get\\(''''main''''\\)\\); print\\(''''module:'''', d.get\\(''''module''''\\)\\); print\\(''''types:'''', d.get\\(''''types''''\\)\\); print\\(''''exports:'''', json.dumps\\(d.get\\(''''exports'''',''''none''''\\), indent=2\\)\\)\")", + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(''''type:'''', d.get\\(''''type''''\\)\\); print\\(''''module:'''', d.get\\(''''module''''\\)\\); print\\(''''main:'''', d.get\\(''''main''''\\)\\); print\\(''''types:'''', d.get\\(''''types''''\\)\\)\")" + ], + "additionalDirectories": [ + "d:\\Drive\\Vim\\vim-web\\.claude\\skills" + ] + } +} diff --git a/.claude/skills/css/skill.md b/.claude/skills/css/skill.md new file mode 100644 index 000000000..f4fcdd1e4 --- /dev/null +++ b/.claude/skills/css/skill.md @@ -0,0 +1,284 @@ +--- +name: css +description: CSS best practices reference for writing and reviewing CSS/Tailwind. Covers general patterns, AI-friendliness, modern features, performance, accessibility, and this project's conventions. Use when writing, reviewing, or refactoring CSS. +allowed-tools: Read, Grep, Glob, Edit, Write, Agent +--- + +# CSS Best Practices + +Reference guide for writing, reviewing, and refactoring CSS in this project. Apply these rules when touching `style.css`, Tailwind classes in TSX files, or CSS custom properties. + +--- + +## Project-Specific Conventions + +This project uses **Tailwind CSS v3** with the `vc-` prefix, CSS custom properties with `--c-` prefix, and a single `style.css` file for global styles + tokens. + +> **Full styling reference**: See [STYLING.md](../../docs/STYLING.md) for the complete token system, panel architecture, `vim-hidden` pattern, third-party override conventions, and code style rules. + +### Token Hierarchy +``` +Primitives (--c-primary, --c-dark-gray, --gap-xs, --z-ui …) + → used directly in style.css rules and Tailwind config +``` + +### Token Rules +- All Tailwind classes MUST use the `vc-` prefix: `vc-flex`, `vc-p-4`, `hover:vc-bg-blue-500` +- Never mix prefixed and non-prefixed Tailwind classes +- CSS custom properties use `--c-` prefix for colors: `--c-primary`, `--c-dark-gray-cool` +- Responsive/state variants go before the prefix: `sm:vc-flex`, `hover:vc-opacity-100` +- Inline Tailwind classes in TSX are preferred over @apply for component-specific styles +- Extract React components instead of creating @apply abstractions +- **Zero values**: always bare `0`, never `0px` — the unit is ignored on zero and `0px` is inconsistent +- **Gap values**: use `var(--gap-xs)` (4px) or `var(--gap-sm)` (10px) — never raw `4px`, `8px`, `0.25rem`, `0.5rem` +- **Magic numbers**: add an inline comment when a value isn't self-evident (e.g. `/* chevron size */`, `/* 33% label / 67% value split */`) + +--- + +## 1. AI-Friendly CSS Patterns + +LLMs cannot render CSS — they infer visual outcomes. Structure CSS to minimize ambiguity. + +### Do +- **Colocate styles with components** — Tailwind inline classes keep full context in one file +- **Use flat, single-class selectors** — each class does one thing, no cascade surprises +- **Use constrained vocabularies** — Tailwind utilities are a predictable API +- **Make cascade explicit** — use `@layer` declarations to spell out priority order +- **One styling approach per component** — don't mix inline styles, CSS classes, and Tailwind +- **Use semantic token names** — `--c-primary` not `--c-blue-hex` + +### Don't +- **Rely on implicit inheritance chains** — `.form .input .label` requires tracing up the DOM +- **Use ambiguous class names** — `.container`, `.wrapper`, `.inner` mean nothing to an LLM +- **Scatter related styles across files** — forces massive context to reason about a single component +- **Use deeply nested selectors** — `.a .b .c .d .e` is unpredictable + +### Why This Matters +- Utility-first is the most AI-friendly approach: full context in one file, constrained vocabulary, no cascade +- CSS Modules are second-best: scoped by default, standard CSS syntax, no global side effects +- Global CSS files are worst: require cross-file context, implicit cascade, naming ambiguity + +--- + +## 2. Specificity Management + +### Cascade Layers (`@layer`) +Layers define explicit precedence that overrides specificity entirely. + +```css +@layer reset, base, tokens, components, utilities, overrides; + +@layer components { + .card { padding: 1rem; } +} +@layer utilities { + .hidden { display: none; } /* always beats .card even though same specificity */ +} +``` + +### Rules +- Keep all selectors at the same specificity level (single class = `0,1,0`) +- Never use IDs for styling +- Never use `!important` except in utility layers (and even then, sparingly) +- Avoid deeply nested selectors — if you need nesting, limit to 2–3 levels max + +--- + +## 3. CSS Custom Properties + +### Three-Tier Token Pattern +```css +/* Tier 1: Primitive tokens */ +:root { --color-blue-600: #0052CC; } + +/* Tier 2: Semantic tokens */ +:root { --c-interactive: var(--color-blue-600); } + +/* Tier 3: Component tokens (private, --_ prefix) */ +.button { --_button-bg: var(--c-interactive); background: var(--_button-bg); } +``` + +### Rules +- Never use raw hex/rgb values in component CSS — always go through tokens +- Set at `:root` for global scope, on selectors for scoped overrides +- Use `--_` prefix for private/internal component tokens +- Theme switching should only redefine primitive tokens + +--- + +## 4. Layout + +### Grid vs Flexbox +- **Grid**: page/section-level 2D layouts (dashboards, page structures) +- **Flexbox**: component-level 1D alignment (toolbars, button groups, centering) +- Combine them: Grid for structure, Flexbox inside grid cells for alignment + +### Responsive Patterns +```css +/* Fluid typography */ +font-size: clamp(1rem, 2vw + 0.5rem, 1.5rem); + +/* Breakpoint-less grid */ +grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + +/* Container queries > media queries for components */ +.card-container { container-type: inline-size; } +@container (min-width: 400px) { + .card { display: grid; grid-template-columns: 1fr 2fr; } +} +``` + +### Logical Properties +Prefer `margin-inline`, `padding-block` over directional properties for i18n support. + +--- + +## 5. Performance + +### Critical Rules +- **Animate only compositor properties**: `transform`, `opacity`, `filter`, `clip-path` — GPU-accelerated, no layout/paint +- **Never animate**: `width`, `height`, `margin`, `padding`, `top`, `left` — trigger full layout recalculation +- **Use `contain`** on independent sections: + ```css + .sidebar { contain: layout; } /* 50-70% layout calc improvement */ + .card { contain: paint; } /* reduces paint areas up to 80% */ + .widget { contain: content; } /* layout + paint */ + ``` +- **Use `will-change` sparingly** — promotes to GPU layer, remove after animation completes +- **Avoid layout thrashing** in JS — batch DOM reads before writes + +### Animation Guidelines +- CSS transitions/animations over JavaScript when possible +- Keep animations 200–500ms for micro-interactions +- Use `ease-out` for entries, `ease-in` for exits +- Always provide `prefers-reduced-motion` alternatives + +--- + +## 6. Accessibility + +### Focus +```css +:focus-visible { + outline: 2px solid var(--c-primary); + outline-offset: 2px; +} +:focus:not(:focus-visible) { + outline: none; +} +``` + +### Motion +```css +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} +``` + +### General +- Minimum touch target: 44x44px +- Use `rem`/`em` for font sizes, not `px` +- Color contrast: WCAG AA = 4.5:1 for text, 3:1 for large text +- Focus indicators need 3:1 contrast against background + +--- + +## 7. Dark Mode + +### Recommended: Data Attribute + CSS Variables +```css +:root, [data-theme="light"] { --c-bg: #ffffff; --c-text: #111; } +[data-theme="dark"] { --c-bg: #111; --c-text: #eee; } +``` + +### Rules +- Never mix hardcoded colors with tokens — use variables everywhere +- Set `color-scheme: light dark` for native element adaptation (scrollbars, form inputs) +- The new `light-dark()` function is another option: + ```css + :root { color-scheme: light dark; } + body { background: light-dark(#fff, #111); } + ``` + +--- + +## 8. Modern CSS Features to Adopt + +### Container Queries (90%+ support) +Component-level responsiveness based on container width, not viewport. +```css +.panel { container-type: inline-size; } +@container (min-width: 400px) { .panel-content { flex-direction: row; } } +``` + +### `:has()` Selector (95%+ support) +Style parents based on child state — replaces many JS-driven conditional classes. +```css +.form-group:has(:focus-visible) { outline: 2px solid var(--c-primary); } +label:has(input:checked) { font-weight: bold; } +``` + +### Native CSS Nesting (96%+ support) +```css +.card { + padding: 1rem; + & .title { font-size: 1.25rem; } + &:hover { box-shadow: 0 4px 12px rgb(0 0 0 / 0.1); } + @media (min-width: 768px) { padding: 2rem; } +} +``` +- `&` is mandatory (unlike Sass bare nesting) +- No suffix concatenation (`&-modifier` doesn't work) +- Limit to 2–3 levels + +### `color-mix()` (92%+ support) +Runtime color manipulation without preprocessors. +```css +.button:hover { background: color-mix(in oklch, var(--c-primary) 80%, white); } +``` + +### Cascade Layers (all modern browsers) +See section 2 above. + +### Subgrid (97%+ support) +Align children across sibling grid items (e.g., card grids with aligned titles/footers). + +--- + +## 9. Tailwind-Specific Best Practices + +### @apply vs Inline +- **Prefer inline classes** in TSX — keeps context colocated, no file switching, no naming +- **@apply only for**: tiny, highly-reused patterns like input resets, AND only when component extraction feels too heavy +- **In React**: extract a component (` - - - -
+ + +
) } -async function createWebgl (viewerRef: MutableRefObject, div: HTMLDivElement) { - const viewer = await VIM.React.Webgl.createViewer(div, {ui: { - }}) - +async function createWebgl (viewerRef: RefObject, div: HTMLDivElement) { + const viewer = await VIM.React.Webgl.createViewer(div) viewerRef.current = viewer - globalThis.viewer = viewer // for testing in browser console - - const url = getPathFromUrl() ?? 'https://storage.cdn.vimaec.com/samples/residence.v1.2.75.vim' - const request = viewer.load({ url }) - - const result = await request.getResult() - if (result.isError) { - console.error('Load failed:', result.error) - return - } + globalThis.viewer = viewer - + const url = getPathFromUrl() ?? 'https://storage.cdn.vimaec.com/samples/residence.v1.2.75.vim' + const request = viewer.load({ url }, { prewarmBim: true }) + await request.getVim() viewer.framing.frameScene.call() } -async function createUltra (viewerRef: MutableRefObject, div: HTMLDivElement) { +async function createUltra (viewerRef: RefObject, div: HTMLDivElement) { const viewer = await VIM.React.Ultra.createViewer(div) await viewer.core.connect() - - globalThis.viewer = viewer // for testing in browser console + viewerRef.current = viewer + globalThis.viewer = viewer + const url = getPathFromUrl() ?? 'https://storage.cdn.vimaec.com/samples/residence.v1.2.75.vim' const request = viewer.load({ url }) - // Track progress - void (async () => { - for await (const progress of request.getProgress()) { - console.log('Loading progress:', progress) - } - })() - const result = await request.getResult() if (result.isError) { console.error('Load failed:', result.type, result.error) @@ -159,10 +99,7 @@ async function createUltra (viewerRef: MutableRefObject, div: HTMLDiv viewer.framing.frameScene.call() } - - function getPathFromUrl () { const params = new URLSearchParams(window.location.search) return params.get('vim') ?? undefined } - diff --git a/src/vim-web/core-viewers/shared/vim.ts b/src/vim-web/core-viewers/shared/vim.ts index c48271b40..87a6b17ab 100644 --- a/src/vim-web/core-viewers/shared/vim.ts +++ b/src/vim-web/core-viewers/shared/vim.ts @@ -48,6 +48,13 @@ export interface IVim { */ getElementFromIndex(element: number): T | undefined + /** + * Retrieves the element associated with the specified Revit unique ID string. + * @param uniqueId - The Revit unique ID string. + * @returns The element, or undefined if not found. + */ + getElementFromUniqueId(uniqueId: string): T | undefined + /** * Retrieves all elements within the Vim. * @returns An array of all Vim objects. diff --git a/src/vim-web/core-viewers/ultra/camera.ts b/src/vim-web/core-viewers/ultra/camera.ts index 616d7e9dd..8c833347b 100644 --- a/src/vim-web/core-viewers/ultra/camera.ts +++ b/src/vim-web/core-viewers/ultra/camera.ts @@ -135,7 +135,6 @@ export class Camera implements IUltraCamera { private _restoreLastPosition () { if (this._lastPosition?.isValid()) { - console.log('Restoring camera position: ', this._lastPosition) this._rpc.RPCSetCameraView(this._lastPosition, 0.5) } } diff --git a/src/vim-web/core-viewers/ultra/decoderWithWorker.ts b/src/vim-web/core-viewers/ultra/decoderWithWorker.ts index c5e9bfe4c..2f1a7911d 100644 --- a/src/vim-web/core-viewers/ultra/decoderWithWorker.ts +++ b/src/vim-web/core-viewers/ultra/decoderWithWorker.ts @@ -56,7 +56,6 @@ export class DecoderWithWorker { // Listen for decoded frames from the worker this._worker.onmessage = (event) => { const msg = event.data - console.log('received message from worker', msg) if (msg.type === 'frame') { this.renderFrame(msg.frame) } @@ -69,7 +68,6 @@ export class DecoderWithWorker { // Function to send frames to the worker sendFrameToWorker (frame: VideoFrameMessage) { // Transfer the dataBuffer to avoid copying - // console.log('sending message to worker') this._worker.postMessage({ type: 'frame', timestamp: frame.header.timestamp, diff --git a/src/vim-web/core-viewers/ultra/decoderWorker.js b/src/vim-web/core-viewers/ultra/decoderWorker.js index a27255120..dce80ebda 100644 --- a/src/vim-web/core-viewers/ultra/decoderWorker.js +++ b/src/vim-web/core-viewers/ultra/decoderWorker.js @@ -57,7 +57,6 @@ function log (message) { postMessage(`[Decoder Worker] ${message}`) } function handleFrame (videoFrame) { - console.log('Frame decoded', videoFrame) // Transfer the VideoFrame back to the main thread postMessage({ type: 'frame', frame: videoFrame }, [videoFrame]) } diff --git a/src/vim-web/core-viewers/ultra/inputAdapter.ts b/src/vim-web/core-viewers/ultra/inputAdapter.ts index 6bcbd00ab..c4091b703 100644 --- a/src/vim-web/core-viewers/ultra/inputAdapter.ts +++ b/src/vim-web/core-viewers/ultra/inputAdapter.ts @@ -55,7 +55,7 @@ function createAdapter(viewer: UltraViewer): IInputAdapter { // handled server side }, toggleOrthographic: () => { - console.log('toggleOrthographic. Not supported yet'); + // Not supported in Ultra }, resetCamera: () => { viewer.camera.lerp().reset(); diff --git a/src/vim-web/core-viewers/ultra/renderer.ts b/src/vim-web/core-viewers/ultra/renderer.ts index 4dc82f030..2a0ed68af 100644 --- a/src/vim-web/core-viewers/ultra/renderer.ts +++ b/src/vim-web/core-viewers/ultra/renderer.ts @@ -38,7 +38,7 @@ export interface IUltraRenderer { hdrBackgroundScale: number hdrBackgroundSaturation: number backgroundBlur: number - backgroundColor: THREE.Color + background: THREE.Color getBoundingBox(): Promise } @@ -164,7 +164,7 @@ export class Renderer implements IUltraRenderer { * Gets the background color * @returns Current background color as RGBA */ - get backgroundColor(): THREE.Color { + get background(): THREE.Color { return this._settings.backgroundColor; } @@ -253,7 +253,7 @@ export class Renderer implements IUltraRenderer { * Sets the background color * @param value - New background color as THREE.Color */ - set backgroundColor(value: THREE.Color) { + set background(value: THREE.Color) { if (this._settings.backgroundColor.equals(value)) return; this._settings.backgroundColor = value.clone(); this._updateLighting = true diff --git a/src/vim-web/core-viewers/ultra/rpcMarshal.ts b/src/vim-web/core-viewers/ultra/rpcMarshal.ts index f8ed17390..3a626add6 100644 --- a/src/vim-web/core-viewers/ultra/rpcMarshal.ts +++ b/src/vim-web/core-viewers/ultra/rpcMarshal.ts @@ -287,7 +287,6 @@ export class ReadMarshal{ return value } - //TODO: Maybe wrong public readUInt64(): bigint { const low = this.readUInt(); // lower 32 bits const high = this.readUInt(); // upper 32 bits diff --git a/src/vim-web/core-viewers/ultra/rpcSafeClient.ts b/src/vim-web/core-viewers/ultra/rpcSafeClient.ts index 4302a71cf..506b7f9ab 100644 --- a/src/vim-web/core-viewers/ultra/rpcSafeClient.ts +++ b/src/vim-web/core-viewers/ultra/rpcSafeClient.ts @@ -38,7 +38,7 @@ export type VimLoadingState = { status: VimLoadingStatus; /** - * Loading progress as a percentage from 0 to 100. + * Loading progress as a fraction from 0 to 1. */ progress: number; } diff --git a/src/vim-web/core-viewers/ultra/vim.ts b/src/vim-web/core-viewers/ultra/vim.ts index e392e7932..be854d5df 100644 --- a/src/vim-web/core-viewers/ultra/vim.ts +++ b/src/vim-web/core-viewers/ultra/vim.ts @@ -121,9 +121,11 @@ export class Vim implements IUltraVim { return object } + getElementFromUniqueId(_uniqueId: string): Element3D | undefined { + return undefined + } + getElementsFromId(_id: number | bigint): Element3D[] { - // Ultra viewer does not support element ID lookup. - // Use getElementFromIndex() or getAllElements() instead. return [] } @@ -132,7 +134,7 @@ export class Vim implements IUltraVim { } getAllElements(): Element3D[] { - for (var i = 0; i < this._elementCount; i++) { + for (let i = 0; i < this._elementCount; i++) { this.getElement(i) } return Array.from(this._objects.values()) @@ -250,7 +252,7 @@ export class Vim implements IUltraVim { try { const state = await this._rpc.RPCGetVimLoadingState(handle) this._logger.log('state :', state) - result.onProgress({ type: 'percent', current: state.progress, total: 100 }) + result.onProgress({ type: 'percent', current: state.progress * 100, total: 100 }) switch (state.status) { case VimLoadingStatus.Loading: case VimLoadingStatus.Downloading: @@ -295,7 +297,6 @@ export class Vim implements IUltraVim { } else if (Utils.isFileURI(source.url)) { handle = await this._rpc.RPCLoadVim(source) } else { - console.log('Defaulting to file path') handle = await this._rpc.RPCLoadVim(source) } } catch (e) { diff --git a/src/vim-web/core-viewers/webgl/index.ts b/src/vim-web/core-viewers/webgl/index.ts index 859b53a61..8d9de34dc 100644 --- a/src/vim-web/core-viewers/webgl/index.ts +++ b/src/vim-web/core-viewers/webgl/index.ts @@ -5,7 +5,6 @@ import './style.css' export { MaterialSet } from './loader' export type { VimSettings, VimPartialSettings } from './loader' export type { RequestSource, IWebglLoadRequest } from './loader' -export type { TransparencyMode } from './loader' export type { IElement3D } from './loader' export type { IScene } from './loader' export type { IMaterials } from './loader' diff --git a/src/vim-web/core-viewers/webgl/loader/element3d.ts b/src/vim-web/core-viewers/webgl/loader/element3d.ts index 512271f1b..3a90c7c7d 100644 --- a/src/vim-web/core-viewers/webgl/loader/element3d.ts +++ b/src/vim-web/core-viewers/webgl/loader/element3d.ts @@ -36,9 +36,13 @@ export interface IElement3D extends ISelectable { readonly element: number /** The unique element ID. */ readonly elementId: bigint + /** The Revit unique ID string. */ + readonly elementUniqueId: string | undefined /** The geometry instances associated with this element. */ readonly instances: number[] | undefined - /** True if this element has associated geometry. */ + /** True if this element has geometry definitions (instances). Always available after the vim is parsed, even before geometry is loaded. */ + readonly hasGeometry: boolean + /** True if this element has loaded mesh data. Only true after `vim.load()` has been called for a subset containing this element. */ readonly hasMesh: boolean /** True if this element is a room. */ readonly isRoom: boolean @@ -106,14 +110,29 @@ export class Element3D implements IElement3D { return this._vim.map.getElementId(this.element)! } + /** + * The Revit unique ID string of the element associated with this object. + */ + get elementUniqueId () : string | undefined { + return this._vim.map.getElementUniqueId(this.element) + } + /** * The geometry instances associated with this object. */ readonly instances: number[] | undefined /** - * Checks if this object has associated geometry. - * @returns {boolean} True if this object has geometry, otherwise false. + * True if this element has geometry definitions (instances). + * Always available after the vim is parsed, even before geometry is loaded. + */ + get hasGeometry () { + return (this.instances?.length ?? 0) > 0 + } + + /** + * True if this element has loaded mesh data. + * Only true after `vim.load()` has been called for a subset containing this element. */ get hasMesh () { return (this._meshes?.length ?? 0) > 0 @@ -256,7 +275,32 @@ export class Element3D implements IElement3D { * @returns {VimHelpers.ElementParameter[]} An array of all bim parameters for this elements. */ async getBimParameters (): Promise { - return VimHelpers.getElementParameters(this._vim.bim, this.element) + const cache = await this._vim.getParameterCache() + if (!cache) return VimHelpers.getElementParameters(this._vim.bim, this.element) + + // Pre-computed element set: this element + family type + family (all O(1) lookups) + const related = cache.familyElements.get(this.element) ?? new Map([[this.element, true]]) + + const { values, descriptorIndices, descriptorNames, descriptorGroups, parametersByElement } = cache + const result: VimHelpers.ElementParameter[] = [] + + // Only visit parameter rows belonging to related elements (O(params per element), not O(all params)) + for (const [elementIdx, isInstance] of related) { + const rows = parametersByElement.get(elementIdx) + if (!rows) continue + for (const i of rows) { + const descriptor = descriptorIndices[i] + const value = values[i] + const displayValue = value?.indexOf('|') >= 0 ? value.substring(value.indexOf('|') + 1) : value + result.push({ + name: Number.isInteger(descriptor) ? descriptorNames?.[descriptor] : undefined, + value: displayValue, + group: Number.isInteger(descriptor) ? descriptorGroups?.[descriptor] : undefined, + isInstance, + }) + } + } + return result } /** diff --git a/src/vim-web/core-viewers/webgl/loader/elementMapping.ts b/src/vim-web/core-viewers/webgl/loader/elementMapping.ts index f6d959625..66ae61530 100644 --- a/src/vim-web/core-viewers/webgl/loader/elementMapping.ts +++ b/src/vim-web/core-viewers/webgl/loader/elementMapping.ts @@ -8,6 +8,8 @@ import { VimDocument } from 'vim-format' export interface IElementMapping { /** Returns element indices associated with element ID. */ getElementsFromElementId(id: number | bigint): number[] | undefined + /** Returns the element index associated with a Revit unique ID string. */ + getElementFromUniqueId(uniqueId: string): number | undefined /** Returns true if element exists in the vim. */ hasElement(element: number): boolean /** Returns all element indices. */ @@ -18,6 +20,8 @@ export interface IElementMapping { getElementFromInstance(instance: number): number | undefined /** Returns the element ID for a given element index. */ getElementId(element: number): bigint | undefined + /** Returns the Revit unique ID string for a given element index. */ + getElementUniqueId(element: number): string | undefined } /** @internal */ @@ -26,6 +30,10 @@ export class ElementNoMapping implements IElementMapping { return undefined } + getElementFromUniqueId (_uniqueId: string) { + return undefined + } + hasElement (element: number) { return false } @@ -45,6 +53,10 @@ export class ElementNoMapping implements IElementMapping { getElementId (element: number) : bigint | undefined { return undefined } + + getElementUniqueId (_element: number): string | undefined { + return undefined + } } /** @internal */ @@ -52,11 +64,14 @@ export class ElementMapping implements IElementMapping { private _instanceToElement: number[] | Int32Array private _elementToInstances: (number[] | undefined)[] private _elementIds: BigInt64Array + private _elementUniqueIds: string[] | undefined private _elementIdToElements: Map | null = null + private _uniqueIdToElement: Map | null = null constructor ( instanceToElement: number[] | Int32Array, - elementIds: BigInt64Array + elementIds: BigInt64Array, + elementUniqueIds?: string[] ) { // Direct reference - no copy needed (read-only) this._instanceToElement = instanceToElement @@ -68,15 +83,18 @@ export class ElementMapping implements IElementMapping { ) this._elementIds = elementIds + this._elementUniqueIds = elementUniqueIds } static async fromG3d (bim: VimDocument) { const instanceToElement = await bim.node.getAllElementIndex() const elementIds = await bim.element.getAllId() + const elementUniqueIds = await bim.element.getAllUniqueId() return new ElementMapping( - instanceToElement, // No conversion - use directly to avoid memory duplication - elementIds + instanceToElement, + elementIds, + elementUniqueIds ?? undefined ) } @@ -132,6 +150,29 @@ export class ElementMapping implements IElementMapping { return this._elementIds[element] } + /** + * Returns the Revit unique ID string for a given element index. + */ + getElementUniqueId (element: number): string | undefined { + return this._elementUniqueIds?.[element] + } + + /** + * Returns the element index for a given Revit unique ID string. + */ + getElementFromUniqueId (uniqueId: string): number | undefined { + if (!this._uniqueIdToElement) { + this._uniqueIdToElement = new Map() + if (this._elementUniqueIds) { + for (let i = 0; i < this._elementUniqueIds.length; i++) { + const uid = this._elementUniqueIds[i] + if (uid) this._uniqueIdToElement.set(uid, i) + } + } + } + return this._uniqueIdToElement.get(uniqueId) + } + /** * Builds element→instances array by inverting the instance→element mapping */ diff --git a/src/vim-web/core-viewers/webgl/loader/geometry.ts b/src/vim-web/core-viewers/webgl/loader/geometry.ts index b132ab75d..fb54392c7 100644 --- a/src/vim-web/core-viewers/webgl/loader/geometry.ts +++ b/src/vim-web/core-viewers/webgl/loader/geometry.ts @@ -6,21 +6,6 @@ import * as THREE from 'three' import { MeshSection } from 'vim-format' import { MappedG3d } from './progressive/mappedG3d' -/** - * Determines how to draw (or not) transparent and opaque objects - */ -export type TransparencyMode = 'opaqueOnly' | 'transparentOnly' | 'allAsOpaque' | 'all' - -/** - * @internal - * Returns true if the transparency mode is one of the valid values - */ -export function isTransparencyModeValid (value: string | undefined | null): value is TransparencyMode { - if (!value) return false - return ['all', 'opaqueOnly', 'transparentOnly', 'allAsOpaque'].includes( - value - ) -} /** * @internal diff --git a/src/vim-web/core-viewers/webgl/loader/index.ts b/src/vim-web/core-viewers/webgl/loader/index.ts index 7bfaf983c..08e0f3ad1 100644 --- a/src/vim-web/core-viewers/webgl/loader/index.ts +++ b/src/vim-web/core-viewers/webgl/loader/index.ts @@ -1,7 +1,6 @@ // Types export type { VimSettings, VimPartialSettings } from './vimSettings'; export type { RequestSource, IWebglLoadRequest } from './progressive/loadRequest'; -export type { TransparencyMode } from './geometry'; export type { IElement3D } from './element3d'; export type { IScene } from './scene'; export type { IMaterials } from './materials/materials'; diff --git a/src/vim-web/core-viewers/webgl/loader/materials/materials.ts b/src/vim-web/core-viewers/webgl/loader/materials/materials.ts index b8e15c0e7..611df2b51 100644 --- a/src/vim-web/core-viewers/webgl/loader/materials/materials.ts +++ b/src/vim-web/core-viewers/webgl/loader/materials/materials.ts @@ -45,6 +45,9 @@ export interface IMaterials { /** Clipping planes applied to all materials. Set to undefined to disable clipping. */ clippingPlanes: THREE.Plane[] | undefined + /** Opacity of the transparent model material (0 = invisible, 1 = fully opaque). Default: 0.25. */ + transparentOpacity: number + /** Selection fill mode: 'none' | 'default' | 'xray' | 'seethrough'. */ selectionFillMode: SelectionFillMode /** Color used to tint selected elements. */ @@ -174,6 +177,10 @@ export class Materials implements IMaterials { get ghostColor () { return this._ghost.color } set ghostColor (value: THREE.Color) { this._ghost.color = value } + /** Opacity of the transparent model material (0 = invisible, 1 = fully opaque). Default: 0.25. */ + get transparentOpacity () { return this._modelTransparent.baseOpacity } + set transparentOpacity (value: number) { this._modelTransparent.baseOpacity = value } + /** * Updates material settings based on the provided configuration. */ diff --git a/src/vim-web/core-viewers/webgl/loader/materials/modelMaterial.ts b/src/vim-web/core-viewers/webgl/loader/materials/modelMaterial.ts index 0302696de..39865ede1 100644 --- a/src/vim-web/core-viewers/webgl/loader/materials/modelMaterial.ts +++ b/src/vim-web/core-viewers/webgl/loader/materials/modelMaterial.ts @@ -33,6 +33,17 @@ export class ModelMaterial { this._onUpdate?.() } + /** Base opacity of the material (0 = invisible, 1 = fully opaque). */ + get baseOpacity (): number { + return this.three.uniforms.baseOpacity.value + } + + set baseOpacity (value: number) { + this.three.uniforms.baseOpacity.value = value + this.three.uniformsNeedUpdate = true + this._onUpdate?.() + } + /** Blend strength for selection tint (0 = off, 1 = solid). */ get selectionTintOpacity (): number { return this.three.uniforms.selectionTintOpacity.value @@ -102,6 +113,7 @@ function createModelMaterialShader (transparent: boolean = false) { colorPaletteTexture: { value: null }, selectionTintColor: { value: new THREE.Color(0x0064ff) }, selectionTintOpacity: { value: 0.0 }, + baseOpacity: { value: transparent ? 0.25 : 1.0 }, }, clipping: true, transparent: transparent, @@ -164,6 +176,7 @@ function createModelMaterialShader (transparent: boolean = false) { uniform vec3 selectionTintColor; uniform float selectionTintOpacity; + uniform float baseOpacity; out vec4 fragColor; @@ -185,7 +198,7 @@ function createModelMaterialShader (transparent: boolean = false) { finalColor = mix(finalColor, selectionTintColor, selectionTintOpacity); } - fragColor = vec4(finalColor, ${transparent ? '0.25' : '1.0'}); + fragColor = vec4(finalColor, baseOpacity); } ` }) diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts b/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts index 1811279f5..5e1e9bceb 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts @@ -5,6 +5,7 @@ import { MeshSection } from 'vim-format' import { G3dMeshOffsets } from './g3dOffsets' import { MappedG3d } from './mappedG3d' +import { IElementMapping } from '../elementMapping' /** Filter mode for subset operations. Only exports modes that are actually implemented. */ export type SubsetFilter = 'instance' | 'mesh' @@ -39,6 +40,10 @@ export interface ISubset { except(mode: SubsetFilter, filter: number[] | Set): ISubset /** Return a new subset including only instances matching the filter. */ filter(mode: SubsetFilter, filter: number[] | Set): ISubset + /** Return a new subset including only instances whose element matches one of the given Revit unique IDs. */ + filterByUniqueId(uniqueIds: string[] | Set): ISubset + /** Return a new subset excluding instances whose element matches one of the given Revit unique IDs. */ + exceptByUniqueId(uniqueIds: string[] | Set): ISubset } /** @@ -48,6 +53,7 @@ export interface ISubset { */ export class G3dSubset implements ISubset { private _source: MappedG3d + private _mapping: IElementMapping | null /** Lazy flat instance list — only materialized when filter/getVimInstance needs it */ private _flatInstances: number[] | null = null @@ -62,8 +68,9 @@ export class G3dSubset implements ISubset { /** * Creates a full set containing all instances from the source. */ - constructor (source: MappedG3d) { + constructor (source: MappedG3d, mapping?: IElementMapping) { this._source = source + this._mapping = mapping ?? null this._meshes = source._meshKeys this._meshInstances = source._meshValues this._instanceCount = source._totalInstanceCount @@ -75,12 +82,14 @@ export class G3dSubset implements ISubset { */ private static _fromPrebuilt ( source: MappedG3d, + mapping: IElementMapping | null, instanceCount: number, meshes: number[], meshInstances: number[][] ): G3dSubset { const subset = Object.create(G3dSubset.prototype) as G3dSubset subset._source = source + subset._mapping = mapping subset._flatInstances = null subset._instanceCount = instanceCount subset._meshes = meshes @@ -111,7 +120,7 @@ export class G3dSubset implements ISubset { if (currentSize > count) { result.push(G3dSubset._fromPrebuilt( - this._source, currentInstanceCount, currentMeshes, currentMeshInstances + this._source, this._mapping, currentInstanceCount, currentMeshes, currentMeshInstances )) currentMeshes = [] currentMeshInstances = [] @@ -122,7 +131,7 @@ export class G3dSubset implements ISubset { if (currentInstanceCount > 0) { result.push(G3dSubset._fromPrebuilt( - this._source, currentInstanceCount, currentMeshes, currentMeshInstances + this._source, this._mapping, currentInstanceCount, currentMeshes, currentMeshInstances )) } @@ -229,7 +238,7 @@ export class G3dSubset implements ISubset { } } - return G3dSubset._fromPrebuilt(this._source, instanceCount, meshes, meshInstances) + return G3dSubset._fromPrebuilt(this._source, this._mapping, instanceCount, meshes, meshInstances) } /** @@ -260,8 +269,8 @@ export class G3dSubset implements ISubset { } return [ - G3dSubset._fromPrebuilt(this._source, lowCount, lowMeshes, lowMeshInstances), - G3dSubset._fromPrebuilt(this._source, highCount, highMeshes, highMeshInstances) + G3dSubset._fromPrebuilt(this._source, this._mapping, lowCount, lowMeshes, lowMeshInstances), + G3dSubset._fromPrebuilt(this._source, this._mapping, highCount, highMeshes, highMeshInstances) ] } @@ -293,6 +302,53 @@ export class G3dSubset implements ISubset { return this._filter(mode, filter, true) } + /** + * Returns a new subset including only instances whose element matches one of the given Revit unique IDs. + */ + filterByUniqueId (uniqueIds: string[] | Set): G3dSubset { + return this._filterByUniqueId(uniqueIds, true) + } + + /** + * Returns a new subset excluding instances whose element matches one of the given Revit unique IDs. + */ + exceptByUniqueId (uniqueIds: string[] | Set): G3dSubset { + return this._filterByUniqueId(uniqueIds, false) + } + + private _filterByUniqueId (uniqueIds: string[] | Set, has: boolean): G3dSubset { + const filterSize = uniqueIds instanceof Set ? uniqueIds.size : uniqueIds.length + if (filterSize === 0) { + return has + ? G3dSubset._fromPrebuilt(this._source, this._mapping, 0, [], []) + : this + } + + const set = uniqueIds instanceof Set ? uniqueIds : new Set(uniqueIds) + const mapping = this._mapping + + const meshes: number[] = [] + const meshInstances: number[][] = [] + let instanceCount = 0 + + for (let i = 0; i < this._meshes.length; i++) { + const filtered = this._meshInstances[i].filter(inst => { + const element = mapping?.getElementFromInstance(this._source.instanceNodes[inst]) + if (element === undefined) return !has + const uid = mapping?.getElementUniqueId(element) + return set.has(uid!) === has + }) + if (filtered.length > 0) { + meshes.push(this._meshes[i]) + meshInstances.push(filtered) + instanceCount += filtered.length + } + } + + if (instanceCount === this._instanceCount) return this + return G3dSubset._fromPrebuilt(this._source, this._mapping, instanceCount, meshes, meshInstances) + } + private _filter ( mode: SubsetFilter, filter: number[] | Set, @@ -302,7 +358,7 @@ export class G3dSubset implements ISubset { const filterSize = filter instanceof Set ? filter.size : filter.length if (filterSize === 0) { return has - ? G3dSubset._fromPrebuilt(this._source, 0, [], []) + ? G3dSubset._fromPrebuilt(this._source, this._mapping, 0, [], []) : this } @@ -328,7 +384,7 @@ export class G3dSubset implements ISubset { } if (instanceCount === this._instanceCount) return this - return G3dSubset._fromPrebuilt(this._source, instanceCount, meshes, meshInstances) + return G3dSubset._fromPrebuilt(this._source, this._mapping, instanceCount, meshes, meshInstances) } /** Lazily materializes the flat instance array from mesh-grouped data. */ diff --git a/src/vim-web/core-viewers/webgl/loader/vim.ts b/src/vim-web/core-viewers/webgl/loader/vim.ts index d311c2121..694deaf55 100644 --- a/src/vim-web/core-viewers/webgl/loader/vim.ts +++ b/src/vim-web/core-viewers/webgl/loader/vim.ts @@ -15,6 +15,7 @@ import { import { G3dSubset, ISubset } from './progressive/g3dSubset' import { VimMeshFactory } from './progressive/vimMeshFactory' import { IVim } from '../../shared/vim' +import { SignalDispatcher, ISignal } from 'ste-signals' import { MappedG3d } from './progressive/mappedG3d' /** @@ -67,12 +68,17 @@ export interface IWebglVim extends IVim { subset(): ISubset /** * Loads geometry for the given subset, or all geometry if no subset is provided. - * Caller is responsible for not loading the same subset twice. + * Clears any previously loaded geometry first so meshes are never duplicated. * @param subset - The subset to load. Omit to load everything. */ load(subset?: ISubset): Promise /** Removes all loaded geometry from the renderer (does NOT unload the vim from the viewer). */ clear(): void + /** Fires after `load(subset)` completes and new geometry is available. */ + readonly onGeometryLoaded: ISignal + /** Pre-caches BIM parameter table columns so subsequent `getBimParameters()` calls are fast. + * Call after loading if your application uses BIM data. No-op if already cached. */ + prewarmBimCache(): Promise } /** @internal */ @@ -123,6 +129,86 @@ export class Vim implements IWebglVim { private readonly _factory: VimMeshFactory private readonly _elementToObject = new Map() + private readonly _onGeometryLoaded = new SignalDispatcher() + + async prewarmBimCache () { + await this.getParameterCache() + } + + /** @internal Cached parameter + family table columns — loaded once, shared by all getBimParameters() calls. */ + _parameterCache: { + elements: number[] + values: string[] + descriptorIndices: number[] + descriptorNames: string[] + descriptorGroups: string[] + /** element index → Set of related element indices (family type + family) with isInstance flag */ + familyElements: Map> + /** element index → parameter indices in the elements/values/descriptors arrays */ + parametersByElement: Map + } | undefined + + /** @internal */ + async getParameterCache () { + if (this._parameterCache) return this._parameterCache + if (!this.bim) return undefined + const [ + elements, values, descriptorIndices, descriptorNames, descriptorGroups, + fiElements, fiFamilyTypes, + ftElements, ftFamilies, + fElements + ] = await Promise.all([ + this.bim.parameter.getAllElementIndex(), + this.bim.parameter.getAllValue(), + this.bim.parameter.getAllParameterDescriptorIndex(), + this.bim.parameterDescriptor.getAllName(), + this.bim.parameterDescriptor.getAllGroup(), + // Family resolution columns — cached to avoid per-query decompression + this.bim.familyInstance.getAllElementIndex(), + this.bim.familyInstance.getAllFamilyTypeIndex(), + this.bim.familyType.getAllElementIndex(), + this.bim.familyType.getAllFamilyIndex(), + this.bim.family.getAllElementIndex(), + ]) + if (!elements || !values || !descriptorIndices) return undefined + + // Pre-compute family element sets for every element — O(1) lookup per getBimParameters call + const familyElements = new Map>() + if (fiElements && fiFamilyTypes && ftElements && ftFamilies && fElements) { + const fiByElement = new Map() + for (let i = 0; i < fiElements.length; i++) fiByElement.set(fiElements[i], i) + + for (const [element, fi] of fiByElement) { + const related = new Map() + related.set(element, true) + const ftIdx = fiFamilyTypes[fi] + if (Number.isInteger(ftIdx)) { + const ftElement = ftElements[ftIdx] + if (ftElement !== undefined) related.set(ftElement, false) + const fIdx = ftFamilies[ftIdx] + if (Number.isInteger(fIdx)) { + const fElement = fElements[fIdx] + if (fElement !== undefined) related.set(fElement, false) + } + } + familyElements.set(element, related) + } + } + + // Pre-index: element → parameter row indices for O(1) lookup per element + const parametersByElement = new Map() + for (let i = 0; i < elements.length; i++) { + const el = elements[i] + let list = parametersByElement.get(el) + if (!list) { list = []; parametersByElement.set(el, list) } + list.push(i) + } + + this._parameterCache = { elements, values, descriptorIndices, descriptorNames, descriptorGroups, familyElements, parametersByElement } + return this._parameterCache + } + + get onGeometryLoaded (): ISignal { return this._onGeometryLoaded.asEvent() } constructor ( header: VimHeader | undefined, @@ -168,6 +254,12 @@ export class Vim implements IWebglVim { * @param {number} id - The element ID to retrieve objects for. * @returns {THREE.Object3D[]} An array of objects corresponding to the element ID, or an empty array if none are found. */ + getElementFromUniqueId (uniqueId: string): Element3D | undefined { + const element = this.map.getElementFromUniqueId(uniqueId) + if (element === undefined) return + return this.getElementFromIndex(element) + } + getElementsFromId (id: number | bigint) { const elements = this.map.getElementsFromElementId(id) return elements @@ -213,18 +305,20 @@ export class Vim implements IWebglVim { * @returns {ISubset} A subset containing all instances. */ subset (): ISubset { - return new G3dSubset(this._factory.g3d) + return new G3dSubset(this._factory.g3d, this.map) } /** * Loads geometry for the given subset, or all geometry if no subset is provided. - * Caller is responsible for not loading the same subset twice. + * Clears any previously loaded geometry first so meshes are never duplicated. * @param subset - The subset to load. Omit to load everything. */ async load (subset?: ISubset) { subset ??= this.subset() if (subset.getInstanceCount() === 0) return + this.clear() this._factory.add(subset as G3dSubset) + this._onGeometryLoaded.dispatch() } /** diff --git a/src/vim-web/core-viewers/webgl/loader/vimSettings.ts b/src/vim-web/core-viewers/webgl/loader/vimSettings.ts index ca4e3f784..cc00d8b26 100644 --- a/src/vim-web/core-viewers/webgl/loader/vimSettings.ts +++ b/src/vim-web/core-viewers/webgl/loader/vimSettings.ts @@ -3,7 +3,6 @@ */ import deepmerge from 'deepmerge' -import { TransparencyMode, isTransparencyModeValid } from './geometry' import * as THREE from 'three' /** @@ -37,11 +36,6 @@ export type VimSettings = { */ matrix: THREE.Matrix4 - /** - * Determines whether objects are drawn based on their transparency. - */ - transparency: TransparencyMode - /** * Set to true to enable verbose HTTP logging. */ @@ -58,7 +52,6 @@ export function getDefaultVimSettings(): VimSettings { rotation: new THREE.Vector3(), scale: 1, matrix: undefined, - transparency: 'all', verboseHttp: false } } @@ -79,10 +72,6 @@ export function createVimSettings (options?: VimPartialSettings): VimSettings { ? deepmerge(getDefaultVimSettings(), options, undefined) : getDefaultVimSettings()) as VimSettings - merge.transparency = isTransparencyModeValid(merge.transparency) - ? merge.transparency - : 'all' - merge.matrix = merge.matrix ?? new THREE.Matrix4().compose( merge.position, new THREE.Quaternion().setFromEuler( diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts index 56f322dac..3195d8ab6 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts @@ -59,9 +59,11 @@ export class CameraLerp extends CameraMovement { if (t >= 1) { t = 1 this._running = false - this.onProgress = undefined } this.onProgress?.(t) + if (!this._running) { + this.onProgress = undefined + } } protected applyMove (worldVector: THREE.Vector3): void { diff --git a/src/vim-web/core-viewers/webgl/viewer/gizmos/axes/gizmoAxes.ts b/src/vim-web/core-viewers/webgl/viewer/gizmos/axes/gizmoAxes.ts index e081af8d9..83d73d088 100644 --- a/src/vim-web/core-viewers/webgl/viewer/gizmos/axes/gizmoAxes.ts +++ b/src/vim-web/core-viewers/webgl/viewer/gizmos/axes/gizmoAxes.ts @@ -94,6 +94,7 @@ export class GizmoAxes implements IGizmoAxes { this._center = new THREE.Vector3(size / 2, size / 2, 0) this._axes = createAxes(this._options) + this._resized = true } private createCanvas () { @@ -186,10 +187,10 @@ export class GizmoAxes implements IGizmoAxes { const drag = new THREE.Vector2(x, y).sub(this._dragLast) this._dragLast.set(x, y) - const rotX = drag.y / this._canvas.height - const rotY = drag.x / this._canvas.width + const azimuth = drag.x / this._canvas.width + const elevation = drag.y / this._canvas.height - this._camera.snap().orbit(new THREE.Vector2(rotX * -180, rotY * -180)) + this._camera.snap().orbit(new THREE.Vector2(azimuth * -180, elevation * -180)) } private endDrag () { diff --git a/src/vim-web/core-viewers/webgl/viewer/gizmos/gizmoOrbit.ts b/src/vim-web/core-viewers/webgl/viewer/gizmos/gizmoOrbit.ts index 6b6fdde42..96076a089 100644 --- a/src/vim-web/core-viewers/webgl/viewer/gizmos/gizmoOrbit.ts +++ b/src/vim-web/core-viewers/webgl/viewer/gizmos/gizmoOrbit.ts @@ -126,6 +126,7 @@ export class GizmoOrbit implements IGizmoOrbit { } clearTimeout(this._timeout) + if (this._gizmos.visible === show) return this._gizmos.visible = show this._renderer.requestRender() diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts index 3cd98b243..09084abf6 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts @@ -207,20 +207,21 @@ export class GpuPicker implements IRaycaster { // Disable NoRaycast layer to hide skybox and gizmos camera.layers.disable(Layers.NoRaycast) - // Calculate pixel position for scissor (flip Y for WebGL coordinate system) + // Calculate pixel position (flip Y for WebGL coordinate system) const pixelX = Math.floor(screenPos.x * this._renderTarget.width) const pixelY = Math.floor((1 - screenPos.y) * this._renderTarget.height) - // Scissor to a 1x1 pixel region around the pick point. - // The GPU skips fragment processing for all other pixels and can - // cull geometry that doesn't intersect the scissor rect. + // Render with scissor optimization: only process the 1x1 pixel we need. + // Use raw GL scissor because Three.js render() internally resets viewport/scissor + // via setRenderTarget(), which can discard the scissor rect we set. + const gl = this._renderer.getContext() this._renderer.setRenderTarget(this._renderTarget) - this._renderer.setScissorTest(true) - this._renderer.setScissor(pixelX, pixelY, 1, 1) this._renderer.setClearColor(0x000000, 0) + gl.enable(gl.SCISSOR_TEST) + gl.scissor(pixelX, pixelY, 1, 1) this._renderer.clear() this._renderer.render(this._scene.threeScene, camera) - this._renderer.setScissorTest(false) + gl.disable(gl.SCISSOR_TEST) // Restore state this._renderer.setRenderTarget(currentRenderTarget) diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts index 35d05cab6..f0a19f75b 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts @@ -92,7 +92,7 @@ export class Renderer implements ISceneRenderer { private _renderText: boolean | undefined private _needsUpdate: boolean - + private _onSceneUpdate = new SignalDispatcher() private _onBoxUpdated = new SignalDispatcher() private _sceneUpdated = false @@ -119,6 +119,7 @@ export class Renderer implements ISceneRenderer { */ requestRender () { this._needsUpdate = true + } constructor ( @@ -177,8 +178,12 @@ export class Renderer implements ISceneRenderer { this._camera.onSettingsChanged.sub(() => { this._composer.camera = this._camera.three this._needsUpdate = true + + }) + this._materials.onUpdate.sub(() => { + this._needsUpdate = true + }) - this._materials.onUpdate.sub(() => (this._needsUpdate = true)) this.background = settings.background.color } @@ -283,16 +288,19 @@ export class Renderer implements ISceneRenderer { notifySceneUpdate () { this._sceneUpdated = true this._needsUpdate = true + } addOutline () { this._outlineCount++ this._needsUpdate = true + } removeOutline () { this._outlineCount-- this._needsUpdate = true + } /** Whether selection outlines are enabled. When false, outlines are suppressed even if elements have outline=true. */ @@ -305,9 +313,10 @@ export class Renderer implements ISceneRenderer { this._needsUpdate = true } - /** Sets the selection fill mode on the rendering composer. */ + /** Sets the selection fill mode on both the rendering pipeline and materials. */ set selectionFillMode (value: SelectionFillMode) { this._composer.selectionFillMode = value + this._materials.selectionFillMode = value this._needsUpdate = true } diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/renderingComposer.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/renderingComposer.ts index ab077c8fb..40cfa2d6b 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/renderingComposer.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/renderingComposer.ts @@ -287,7 +287,7 @@ export class RenderingComposer { */ render () { this._timer.update() - var delta = this._timer.getDelta() + const delta = this._timer.getDelta() // Render main scene to scene target this._renderPass.render( this._renderer, diff --git a/src/vim-web/core-viewers/webgl/viewer/settings/viewerDefaultSettings.ts b/src/vim-web/core-viewers/webgl/viewer/settings/viewerDefaultSettings.ts index 66a080008..0679c3c10 100644 --- a/src/vim-web/core-viewers/webgl/viewer/settings/viewerDefaultSettings.ts +++ b/src/vim-web/core-viewers/webgl/viewer/settings/viewerDefaultSettings.ts @@ -38,7 +38,7 @@ export function getDefaultViewerSettings(): ViewerSettings { opacityAlways: 0.1 } }, - background: { color: new THREE.Color(0xffffff) }, + background: { color: new THREE.Color().setRGB(0xF0 / 255, 0xF0 / 255, 0xF0 / 255) }, materials: { ghost: { color: new THREE.Color(0x0E0E0E), @@ -48,7 +48,7 @@ export function getDefaultViewerSettings(): ViewerSettings { opacity: 0.85, color: new THREE.Color(0x00ffff), scale: 2, - thickness: 3 + thickness: 2 }, selection: { fillMode: 'none', diff --git a/src/vim-web/core-viewers/webgl/viewer/settings/viewerSettings.ts b/src/vim-web/core-viewers/webgl/viewer/settings/viewerSettings.ts index 7a0c399a5..98397bd7b 100644 --- a/src/vim-web/core-viewers/webgl/viewer/settings/viewerSettings.ts +++ b/src/vim-web/core-viewers/webgl/viewer/settings/viewerSettings.ts @@ -255,8 +255,8 @@ export type ViewerSettings = { */ background: { /** - * Color of the cavas background - * Default: THREE.Color('#96999f') + * Color of the canvas background. + * Default: #F0F0F0 */ color: THREE.Color }, diff --git a/src/vim-web/react-viewers/bim/bimInfoBody.tsx b/src/vim-web/react-viewers/bim/bimInfoBody.tsx deleted file mode 100644 index e532bed0c..000000000 --- a/src/vim-web/react-viewers/bim/bimInfoBody.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import React, { useEffect } from 'react' -import ReactTooltip from 'react-tooltip' -import * as Icons from '../icons' -import * as BIM from './bimInfoData' -import { createOpenState } from './openState' -import { BimInfoPanelApi } from './bimInfoData' - -/** - * Represents the details of a BIM object. - */ -export function BimBody ( - props:{ - bimInfoRef: BimInfoPanelApi, - sections : BIM.Section[], - } -) { - const open = createOpenState() - - useEffect(() => { - ReactTooltip.rebuild() - }) - - useEffect(() => { - // Initialize open state with all groups open - if (props.sections !== undefined) { - open.init(props.sections.flatMap((s) => s.content.map(g => g.title))) - } - }, [props.sections]) - - if (!props.sections) { - // Loading until data is available - return
Loading . . .
- } - - function func (section: BIM.Section, i: number) { - const standard = () => createSection(props.bimInfoRef, section, open.get, open.set) - if (props.bimInfoRef.onRenderBodySection !== undefined) { - return React.createElement(props.bimInfoRef.onRenderBodySection, { data: section, standard }) - } - return standard() - } - return ( -
- {props.sections.map((section, i) => ( -
- {func(section, i)} -
))} -
) -} - -function createSection ( - bimInfoRef: BimInfoPanelApi, - section: BIM.Section, - getOpen: (key: string) => boolean, - setOpen: (key: string, value: boolean) => void -) { - const createTitle = (value: string) => { - return ( -

- {value} -

- ) - } - - const createContent = (group: BIM.Group) => { - const standard = () => createGroup(bimInfoRef, group, getOpen, setOpen) - if (bimInfoRef.onRenderBodyGroup !== undefined) { - return React.createElement(bimInfoRef.onRenderBodyGroup, { data: group, standard }) - } - return standard() - } - - const content = Array.from(section.content, (group, i) => ( -
- {createContent(group)} -
)) - - return <> - {section.title ? createTitle(section.title) : null} - {content} - {
} - -} - -function createGroup ( - bimInfoRef: BimInfoPanelApi, - group: BIM.Group, - getOpen: (key: string) => boolean, - setOpen: (key: string, value: boolean) => void -) { - const open = getOpen(group.title) - return ( -
    -
  • -

    - {group.title} - {createCollapseButton(open, (b) => setOpen(group.title, b))} -

    -
  • - {createGroupContent(bimInfoRef, group, open)} -
- ) -} - -function createCollapseButton ( - open: boolean, - setOpen: (b: boolean) => void -) { - return ( - - ) -} - -function createGroupContent ( - bimInfoRef: BimInfoPanelApi, - group: BIM.Group, - open: boolean) { - if (open === false) return null - - const func = (entry: BIM.Entry) => { - const standard = () => createEntry(bimInfoRef, entry) - if (bimInfoRef.onRenderBodyEntry !== undefined) { - return React.createElement(bimInfoRef.onRenderBodyEntry, { data: entry, standard }) - } - return standard() - } - - return group.content.map((entry, i) => ( -
- {func(entry)} -
)) -} - -function createEntry (bimInfoRef: BimInfoPanelApi, entry: BIM.Entry) { - const func = () => { - const standard = () => (<>{entry.value}) - if (bimInfoRef.onRenderBodyEntryValue !== undefined) { - return React.createElement(bimInfoRef.onRenderBodyEntryValue, { data: entry, standard }) - } - return standard() - } - - return ( -
  • - - {entry.label} - - - {func()} - -
  • - ) -} diff --git a/src/vim-web/react-viewers/bim/bimInfoConvert.tsx b/src/vim-web/react-viewers/bim/bimInfoConvert.tsx new file mode 100644 index 000000000..506503d8d --- /dev/null +++ b/src/vim-web/react-viewers/bim/bimInfoConvert.tsx @@ -0,0 +1,89 @@ +import React from 'react' +import { GenericEntryType, GenericContent } from '../generic/genericField' +import { Data, Entry, Section, Group, BimInfoPanelApi } from './bimInfoData' + +function entryId(prefix: string, key: string | undefined, i: number) { + return `${prefix}-${key ?? i}` +} + +function headerEntryToGeneric(entry: Entry, i: number, api: BimInfoPanelApi): GenericEntryType { + if (api.onRenderHeaderEntry) { + const standard = () => + return { type: 'element', id: entryId('h', entry.key, i), element: React.createElement(api.onRenderHeaderEntry, { data: entry, standard }) } + } + return headerEntryToReadonly(entry, i, api) +} + +function headerEntryToReadonly(entry: Entry, i: number, api: BimInfoPanelApi): GenericEntryType { + const renderValue = api.onRenderHeaderEntryValue + ? () => React.createElement(api.onRenderHeaderEntryValue, { data: entry, standard: () => <>{entry.value} }) + : undefined + return { type: 'readonly', id: entryId('h', entry.key, i), label: entry.label ?? '', value: entry.value ?? '', renderValue } +} + +function bodyEntryToGeneric(entry: Entry, i: number, api: BimInfoPanelApi): GenericEntryType { + if (api.onRenderBodyEntry) { + const standard = () => + return { type: 'element', id: entryId('be', entry.key, i), element: React.createElement(api.onRenderBodyEntry, { data: entry, standard }) } + } + return bodyEntryToReadonly(entry, i, api) +} + +function bodyEntryToReadonly(entry: Entry, i: number, api: BimInfoPanelApi): GenericEntryType { + const renderValue = api.onRenderBodyEntryValue + ? () => React.createElement(api.onRenderBodyEntryValue, { data: entry, standard: () => <>{entry.value} }) + : undefined + return { type: 'readonly', id: entryId('be', entry.key, i), label: entry.label ?? '', value: entry.value ?? '', renderValue } +} + +function groupToItems(group: Group, api: BimInfoPanelApi): GenericEntryType[] { + if (api.onRenderBodyGroup) { + const items = groupToFlatItems(group, api) + const standard = () => + return [{ type: 'element', id: entryId('g', group.key, 0), element: React.createElement(api.onRenderBodyGroup, { data: group, standard }) }] + } + return [ + { type: 'section', id: entryId('g', group.key, 0), label: group.title ?? '' }, + ...group.content.map((e, i) => bodyEntryToGeneric(e, i, api)) + ] +} + +function groupToFlatItems(group: Group, api: BimInfoPanelApi): GenericEntryType[] { + return group.content.map((e, i) => bodyEntryToGeneric(e, i, api)) +} + +function sectionToItems(section: Section, api: BimInfoPanelApi): GenericEntryType[] { + if (api.onRenderBodySection) { + const items = sectionToFlatItems(section, api) + const standard = () => + return [{ type: 'element', id: entryId('s', section.key, 0), element: React.createElement(api.onRenderBodySection, { data: section, standard }) }] + } + return [ + { type: 'group', id: entryId('s', section.key, 0), label: section.title }, + ...section.content.flatMap(g => groupToItems(g, api)) + ] +} + +function sectionToFlatItems(section: Section, api: BimInfoPanelApi): GenericEntryType[] { + return section.content.flatMap(g => groupToFlatItems(g, api)) +} + +export function headerToEntries(data: Data | undefined, api: BimInfoPanelApi): GenericEntryType[] { + if (!data?.header) return [] + if (api.onRenderHeader) { + const headerItems = data.header.map((e, i) => headerEntryToReadonly(e, i, api)) + const standard = () => + return [{ type: 'element', id: 'header', element: React.createElement(api.onRenderHeader, { data: data.header, standard }) }] + } + return data.header.map((e, i) => headerEntryToGeneric(e, i, api)) +} + +export function bodyToEntries(data: Data | undefined, api: BimInfoPanelApi): GenericEntryType[] { + if (!data?.body) return [] + if (api.onRenderBody) { + const bodyItems = data.body.flatMap(s => sectionToFlatItems(s, api)) + const standard = () => + return [{ type: 'element', id: 'body', element: React.createElement(api.onRenderBody, { data: data.body, standard }) }] + } + return data.body.flatMap(s => sectionToItems(s, api)) +} diff --git a/src/vim-web/react-viewers/bim/bimInfoData.ts b/src/vim-web/react-viewers/bim/bimInfoData.ts index 363a22c3a..199c679b1 100644 --- a/src/vim-web/react-viewers/bim/bimInfoData.ts +++ b/src/vim-web/react-viewers/bim/bimInfoData.ts @@ -102,7 +102,7 @@ export type DataCustomization = (data: Data, source: Core.Webgl.IWebglVim | Core * @param props.standard - The standard rendering function for the data. * @returns A custom JSX element to render, or `undefined` to use the default rendering. */ -export type DataRender = ((props: { data: T; standard: () => JSX.Element }) => JSX.Element) | undefined +export type DataRender = ((props: { data: T; standard: () => React.ReactElement }) => React.ReactElement) | undefined /** * A reference object exposing multiple customization callbacks for transforming data and rendering diff --git a/src/vim-web/react-viewers/bim/bimInfoHeader.tsx b/src/vim-web/react-viewers/bim/bimInfoHeader.tsx deleted file mode 100644 index c2322b623..000000000 --- a/src/vim-web/react-viewers/bim/bimInfoHeader.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from 'react' -import * as BIM from './bimInfoData' -import { BimInfoPanelApi } from './bimInfoData' - -export function BimHeader (props: { - bimInfoRef: BimInfoPanelApi - entries: BIM.Entry[] -}) { - if (props.entries === undefined) { - return
    Loading . . .
    - } - - const create = (entry: BIM.Entry, i: number) => { - const standard = () => createEntry(props.bimInfoRef, entry) - if (props.bimInfoRef.onRenderHeaderEntry !== undefined) { - return React.createElement(props.bimInfoRef.onRenderHeaderEntry, { data: entry, standard }) - } - return standard() - } - - const rows = props.entries.map((entry, rowIndex) => ( -
    - {create(entry, rowIndex)} -
    )) - - return ( -
    {rows}
    - ) -} - -function createEntry (bimInfoRef: BimInfoPanelApi, entry: BIM.Entry) { - const create = () => { - const standard = () => (<>{entry.value?.toString()}) - if (bimInfoRef.onRenderHeaderEntryValue !== undefined) { - return React.createElement(bimInfoRef.onRenderHeaderEntryValue, { data: entry, standard }) - } - return standard() - } - return ( -
    - < dt - data-tip={entry.label} - className={'bim-header-entry-title vc-mr-1 vc-shrink-0 vc-select-none vc-whitespace-nowrap vc-truncate vc-text-gray-medium vc-w-1/3'} - key={`dt-${entry.key}`} - > - {entry.label} - -
    - {create()} -
    -
    - ) -} diff --git a/src/vim-web/react-viewers/bim/bimInfoPanel.tsx b/src/vim-web/react-viewers/bim/bimInfoPanel.tsx index ce86c9b13..cba2235a6 100644 --- a/src/vim-web/react-viewers/bim/bimInfoPanel.tsx +++ b/src/vim-web/react-viewers/bim/bimInfoPanel.tsx @@ -1,69 +1,60 @@ -import React, { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import * as Core from '../../core-viewers' -import ReactTooltip from 'react-tooltip' -import { BimBody } from './bimInfoBody' -import { BimHeader } from './bimInfoHeader' import { getObjectData } from './bimInfoObject' import { getVimData } from './bimInfoVim' import { AugmentedElement } from '../helpers/element' import { Data, BimInfoPanelApi } from './bimInfoData' +import { GenericContent } from '../generic/genericField' +import { headerToEntries, bodyToEntries } from './bimInfoConvert' -export function BimInfoPanel (props : { - object: Core.Webgl.IElement3D, - vim: Core.Webgl.IWebglVim, - elements: AugmentedElement[], - full : boolean - bimInfoRef: BimInfoPanelApi - } -) { +export function BimInfoPanel(props: { + object: Core.Webgl.IElement3D + vim: Core.Webgl.IWebglVim + elements: AugmentedElement[] + full: boolean + bimInfoRef: BimInfoPanelApi +}) { const target = props.object?.type === 'Element3D' ? props.object : undefined - useEffect(() => { - ReactTooltip.rebuild() - }) const [data, setData] = useState() + const debounce = useRef>(undefined) useEffect(() => { - // Update data when inputs change - async function update () { + let cancelled = false + clearTimeout(debounce.current) + debounce.current = setTimeout(async () => { + if (cancelled) return + await new Promise(r => setTimeout(r, 0)) + if (cancelled) return let data = props.object === undefined ? await getVimData(props.vim) : await getObjectData(target, props.elements) + if (cancelled) return + // Yield again so the browser can paint between query and render + await new Promise(r => setTimeout(r, 0)) + if (cancelled) return data = await props.bimInfoRef.onData(data, target ?? props.vim) + if (cancelled) return setData(data) - } - - // UseEffect doesn't accept async functions so we need to wrap it - update() - } - , [props.object, props.vim, props.elements, props.bimInfoRef]) - - const header = () => { - const standard = () => - if (props.bimInfoRef.onRenderHeader !== undefined) { - return React.createElement(props.bimInfoRef.onRenderHeader, { data: data?.header, standard }) - } - return standard() - } - - const body = () => { - const standard = () => - if (props.bimInfoRef.onRenderBody !== undefined) { - return React.createElement(props.bimInfoRef.onRenderBody, { data: data?.body, standard }) - } - return standard() - } + }, 50) + return () => { cancelled = true; clearTimeout(debounce.current) } + }, [props.object, props.vim, props.elements, props.bimInfoRef]) return ( -
    -

    - Bim Inspector -

    -
    - {header()} - {body()} +
    +

    + Bim Inspector +

    +
    + {data + ? <> +
    +
    + + : Loading . . . + } +
    -
    ) } diff --git a/src/vim-web/react-viewers/bim/bimPanel.tsx b/src/vim-web/react-viewers/bim/bimPanel.tsx index a241b1f24..8262ea1db 100644 --- a/src/vim-web/react-viewers/bim/bimPanel.tsx +++ b/src/vim-web/react-viewers/bim/bimPanel.tsx @@ -2,17 +2,17 @@ * @module viw-webgl-react */ -import React, { useMemo, useState } from 'react' +import React, { useMemo } from 'react' import * as Core from '../../core-viewers' -import { whenAllTrue, whenFalse, whenSomeTrue, whenTrue } from '../helpers/utils' +import { whenAllTrue, whenSomeTrue, whenTrue } from '../helpers/utils' import { FramingApi } from '../state/cameraState' import { IsolationApi } from '../state/sharedIsolation' import { ViewerState } from '../webgl/viewerState' import { BimInfoPanelApi } from './bimInfoData' import { BimInfoPanel } from './bimInfoPanel' import { BimSearch } from './bimSearch' -import { BimTree, TreeActionApi } from './bimTree' +import { BimTree } from './bimTreeHeadless' import { toTreeData } from './bimTreeData' import { WebglSettings } from '../webgl/settings' import { isFalse } from '../settings/userBoolean' @@ -27,7 +27,6 @@ export function OptionalBimPanel (props: { isolation: IsolationApi visible: boolean settings: WebglSettings - treeRef: React.MutableRefObject bimInfoRef: BimInfoPanelApi }) { return whenSomeTrue([ @@ -52,7 +51,6 @@ export function BimPanel (props: { isolation: IsolationApi visible: boolean settings: WebglSettings - treeRef: React.MutableRefObject bimInfoRef: BimInfoPanelApi }) { @@ -66,27 +64,26 @@ export function BimPanel (props: { const fullTree = isFalse(props.settings.ui.panelBimInfo) const fullInfo = isFalse(props.settings.ui.panelBimTree) return ( -
    +
    {whenTrue(props.settings.ui.panelBimTree, -
    - {

    - Project Inspector -

    } +
    +
    Project Inspector
    - + {props.viewerState.filter.get() && props.viewerState.elements.get()?.length === 0 + ?
    No results for "{props.viewerState.filter.get()}"
    + : + }
    )} { @@ -99,7 +96,7 @@ export function BimPanel (props: { divider()) } {whenTrue(props.settings.ui.panelBimInfo, -
    +
    + return
    } diff --git a/src/vim-web/react-viewers/bim/bimSearch.tsx b/src/vim-web/react-viewers/bim/bimSearch.tsx index 4f0495c6c..38e859571 100644 --- a/src/vim-web/react-viewers/bim/bimSearch.tsx +++ b/src/vim-web/react-viewers/bim/bimSearch.tsx @@ -21,7 +21,8 @@ export function BimSearch (props: { count: number }) { const [text, setText] = useState('') - const changeTimeout = useRef>() + const changeTimeout = useRef>(undefined) + const inputRef = useRef(null) useEffect(() => { setText(props.filter) @@ -44,6 +45,7 @@ export function BimSearch (props: { setText('') clearTimeout(changeTimeout.current) props.setFilter('') + inputRef.current?.focus() } const onFocus = () => { @@ -55,9 +57,9 @@ export function BimSearch (props: { } return ( -
    +
    0 ? ( + ) +} diff --git a/src/vim-web/react-viewers/components/Input.tsx b/src/vim-web/react-viewers/components/Input.tsx new file mode 100644 index 000000000..07bc65a15 --- /dev/null +++ b/src/vim-web/react-viewers/components/Input.tsx @@ -0,0 +1,13 @@ +import React from 'react' + +type InputProps = React.InputHTMLAttributes + +export function Input({ type = 'text', className = '', ...props }: InputProps) { + return ( + + ) +} diff --git a/src/vim-web/react-viewers/components/Select.tsx b/src/vim-web/react-viewers/components/Select.tsx new file mode 100644 index 000000000..3dbd4b27b --- /dev/null +++ b/src/vim-web/react-viewers/components/Select.tsx @@ -0,0 +1,76 @@ +import { useEffect, useRef, useState } from 'react' + +type SelectOption = { + label: string + value: string +} + +type SelectProps = { + value: string + options: SelectOption[] + onChange: (value: string) => void + disabled?: boolean + /** 'inline' uses a compact rounded style (settings panel), 'full' fills its container (generic fields) */ + variant?: 'inline' | 'full' +} + +const chevron = ( + + + +) + +export function Select({ value, options, onChange, disabled, variant = 'inline' }: SelectProps) { + const [open, setOpen] = useState(false) + const ref = useRef(null) + + useEffect(() => { + if (!open) return + const onPointerDown = (e: PointerEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false) + } + } + document.addEventListener('pointerdown', onPointerDown) + return () => document.removeEventListener('pointerdown', onPointerDown) + }, [open]) + + const current = options.find(o => o.value === value) + + return ( +
    + + {open && ( +
    + {options.map(opt => ( +
    { + e.stopPropagation() + onChange(opt.value) + setOpen(false) + }} + > + {opt.label} +
    + ))} +
    + )} +
    + ) +} diff --git a/src/vim-web/react-viewers/components/Tooltip.tsx b/src/vim-web/react-viewers/components/Tooltip.tsx new file mode 100644 index 000000000..b3624f3b4 --- /dev/null +++ b/src/vim-web/react-viewers/components/Tooltip.tsx @@ -0,0 +1,104 @@ +import React, { useRef, useState, useEffect, useCallback, useLayoutEffect } from 'react' +import { createPortal } from 'react-dom' + +const GAP = 6 + +/** + * Place once around a container with many [data-tip] elements. + * Uses event delegation — zero per-item overhead. + * Tracks mouse position so the tooltip appears at the cursor's current location. + */ +export function TooltipZone({ children, delay = 300 }: { + children: React.ReactNode + delay?: number +}) { + const containerRef = useRef(null) + const timer = useRef>(undefined) + const mouse = useRef({ x: 0, y: 0 }) + const activeTarget = useRef(null) + const [tip, setTip] = useState<{ text: string, x: number, top: number, bottom: number } | null>(null) + + const onMove = useCallback((e: MouseEvent) => { + mouse.current = { x: e.clientX, y: e.clientY } + }, []) + + const onEnter = useCallback((e: MouseEvent) => { + const target = (e.target as HTMLElement).closest?.('[data-tip]') as HTMLElement | null + if (!target || target === activeTarget.current) return + activeTarget.current = target + clearTimeout(timer.current) + mouse.current = { x: e.clientX, y: e.clientY } + timer.current = setTimeout(() => { + const text = target.getAttribute('data-tip') + if (!text) return + const rect = target.getBoundingClientRect() + setTip({ text, x: mouse.current.x, top: rect.top, bottom: rect.bottom }) + }, delay) + }, [delay]) + + const onLeave = useCallback((e: MouseEvent) => { + const related = (e.relatedTarget as HTMLElement)?.closest?.('[data-tip]') + if (related === activeTarget.current) return + activeTarget.current = null + clearTimeout(timer.current) + setTip(null) + }, []) + + useEffect(() => { + const el = containerRef.current + if (!el) return + el.addEventListener('mouseover', onEnter) + el.addEventListener('mouseout', onLeave) + el.addEventListener('mousemove', onMove) + return () => { + clearTimeout(timer.current) + el.removeEventListener('mouseover', onEnter) + el.removeEventListener('mouseout', onLeave) + el.removeEventListener('mousemove', onMove) + } + }, [onEnter, onLeave, onMove]) + + return ( +
    + {children} + {tip && } +
    + ) +} + +function TooltipPortal({ text, anchorX, anchorTop, anchorBottom }: { + text: string, anchorX: number, anchorTop: number, anchorBottom: number +}) { + const ref = useRef(null) + const [pos, setPos] = useState<{ left: number, top: number } | null>(null) + + useLayoutEffect(() => { + const el = ref.current + if (!el) return + const w = el.offsetWidth + const h = el.offsetHeight + + let left = anchorX - w / 2 + left = Math.max(4, Math.min(left, window.innerWidth - w - 4)) + + // Above the element; if no room, below the element + let top = anchorTop - h - GAP + if (top < 4) top = anchorBottom + GAP + + setPos({ left, top }) + }, [anchorX, anchorTop, anchorBottom, text]) + + return createPortal( +
    + {text} +
    , + document.body + ) +} diff --git a/src/vim-web/react-viewers/components/index.ts b/src/vim-web/react-viewers/components/index.ts new file mode 100644 index 000000000..5e96a574d --- /dev/null +++ b/src/vim-web/react-viewers/components/index.ts @@ -0,0 +1,4 @@ +export { IconButton } from './IconButton' +export { Input } from './Input' +export { Checkbox } from './Checkbox' +export { Select } from './Select' diff --git a/src/vim-web/react-viewers/container.tsx b/src/vim-web/react-viewers/container.tsx index 2cb541571..9e5102cc6 100644 --- a/src/vim-web/react-viewers/container.tsx +++ b/src/vim-web/react-viewers/container.tsx @@ -34,7 +34,7 @@ export function createContainer (element?: HTMLElement): Container { if (root === undefined) { root = document.createElement('div') document.body.append(root) - root.classList.add('vc-inset-0') + root.style.inset = '0' } // UI relies on absolute positioning root.style.position = 'absolute' @@ -42,11 +42,11 @@ export function createContainer (element?: HTMLElement): Container { // container for viewer canvases const gfx = document.createElement('div') - gfx.className = 'vim-gfx vc-absolute vc-inset-0 vc-pointer-events-none' + gfx.className = 'vim-gfx' // container for ui const ui = document.createElement('div') - ui.className = 'vim-ui vc-absolute vc-inset-0' + ui.className = 'vim-ui' root.append(gfx) root.append(ui) diff --git a/src/vim-web/react-viewers/controlbar/controlBar.tsx b/src/vim-web/react-viewers/controlbar/controlBar.tsx index 6c6fadc5c..71bb7513e 100644 --- a/src/vim-web/react-viewers/controlbar/controlBar.tsx +++ b/src/vim-web/react-viewers/controlbar/controlBar.tsx @@ -2,8 +2,6 @@ * @module viw-webgl-react */ -import { useEffect } from 'react' -import ReactTooltip from 'react-tooltip' import { createSection, IControlBarSection } from './controlBarSection' /** @@ -30,26 +28,13 @@ export type ControlBarCustomization = ( */ export function ControlBar (props : { content: IControlBarSection[], show: boolean }) { - // On Each Render - useEffect(() => { - ReactTooltip.rebuild() - }) - if (!props.show) { return null } return ( -
    +
    {props.content.map(createSection)}
    ) } - diff --git a/src/vim-web/react-viewers/controlbar/controlBarButton.tsx b/src/vim-web/react-viewers/controlbar/controlBarButton.tsx index a85b25994..70b89e390 100644 --- a/src/vim-web/react-viewers/controlbar/controlBarButton.tsx +++ b/src/vim-web/react-viewers/controlbar/controlBarButton.tsx @@ -1,24 +1,25 @@ -import * as Style from './style' import { IconOptions } from '../icons' +import { IconButton } from '../components' +import { ButtonVariant } from './style' export interface IControlBarButton { id: string, enabled?: (() => boolean) | undefined - tip: string + tip: string | (() => string) action: () => void - icon: (options?: IconOptions) => JSX.Element + icon: (options?: IconOptions) => React.ReactElement isOn?: () => boolean - style?: (on: boolean) => string + variant?: ButtonVariant } export function createButton (button: IControlBarButton) { if (button.enabled !== undefined && !button.enabled()) return null - const style = (button.style?? Style.buttonDefaultStyle)(button.isOn?.()) + const variant = button.variant ?? 'default' + const on = button.isOn?.() ?? false return ( - + + {button.icon()} + ) } - diff --git a/src/vim-web/react-viewers/controlbar/controlBarSection.tsx b/src/vim-web/react-viewers/controlbar/controlBarSection.tsx index 480c5e799..d64320065 100644 --- a/src/vim-web/react-viewers/controlbar/controlBarSection.tsx +++ b/src/vim-web/react-viewers/controlbar/controlBarSection.tsx @@ -1,17 +1,18 @@ import { createButton, IControlBarButton } from './controlBarButton' -import * as Style from './style' +import { SectionVariant } from './style' export interface IControlBarSection { id: string, enable? : (() => boolean) | undefined buttons: (IControlBarButton)[] - style?: string + variant?: SectionVariant } -//TODO: Support injecting custom elements export function createSection (section: IControlBarSection) { if (section.enable !== undefined && !section.enable()) return null - return
    + return ( +
    {section.buttons.map(b => createButton(b))}
    + ) } diff --git a/src/vim-web/react-viewers/controlbar/style.ts b/src/vim-web/react-viewers/controlbar/style.ts index fa2d7ecb3..ee03f8447 100644 --- a/src/vim-web/react-viewers/controlbar/style.ts +++ b/src/vim-web/react-viewers/controlbar/style.ts @@ -1,34 +1,11 @@ -export const baseSectionStyle = 'vc-flex vc-items-center vc-rounded-full vc-mb-2 vc-shadow-md' -export const sectionDefaultStyle = baseSectionStyle + ' vc-bg-white' -export const sectionBlueStyle = baseSectionStyle + ' vc-bg-primary' -export const sectionNoPadStyle = baseSectionStyle.replace('vc-px-2','') + ' vc-bg-white' - - -export const buttonBaseStyle = 'vim-control-bar-button vc-rounded-full vc-items-center vc-justify-center vc-flex vc-transition-all hover:vc-scale-110' -export function buttonDefaultStyle (on: boolean) { - return on - ? buttonBaseStyle + ' vc-text-primary' - : buttonBaseStyle + ' vc-text-gray-medium' -} - -export function buttonExpandStyle (on: boolean) { - return on - ? buttonBaseStyle + ' vc-text-white vc-bg-primary' - : buttonBaseStyle + ' vc-text-gray-medium' -} - -export function buttonDisableStyle (on: boolean) { - return on - ? buttonBaseStyle + ' vc-text-gray-medium' - : buttonBaseStyle + ' vc-text-gray vc-pointer-events-none' -} - -export function buttonDisableDefaultStyle (on: boolean) { - return on - ? buttonBaseStyle + ' vc-text-primary' - : buttonBaseStyle + ' vc-text-gray vc-pointer-events-none' -} - -export function buttonBlueStyle (on: boolean) { - return buttonBaseStyle + ' vc-text-white' -} +export type ButtonVariant = 'default' | 'expand' | 'disabled' | 'disabled-default' | 'blue' +export type SectionVariant = 'default' | 'blue' + +export const buttonDefaultStyle: ButtonVariant = 'default' +export const buttonExpandStyle: ButtonVariant = 'expand' +export const buttonDisableStyle: ButtonVariant = 'disabled' +export const buttonDisableDefaultStyle: ButtonVariant = 'disabled-default' +export const buttonBlueStyle: ButtonVariant = 'blue' +export const sectionDefaultStyle: SectionVariant = 'default' +export const sectionBlueStyle: SectionVariant = 'blue' +export const sectionNoPadStyle: SectionVariant = 'default' diff --git a/src/vim-web/react-viewers/errors/errorStyle.tsx b/src/vim-web/react-viewers/errors/errorStyle.tsx index 8ac9620fd..20af4fa20 100644 --- a/src/vim-web/react-viewers/errors/errorStyle.tsx +++ b/src/vim-web/react-viewers/errors/errorStyle.tsx @@ -1,47 +1,34 @@ -export const vcColorPrimary = 'vc-text-[#212733]' -export const vcColorSecondary = 'vc-text-[#787C83]' -export const vcColorLink = 'vc-text-[#0590CC]' -export const vcLink = `${vcColorLink} vc-underline` -export const vcLabel = 'vc-text-[#3F444F]' -export const vcRoboto = 'vc-font-[\'Roboto\',sans-serif]' - -export function footer () { - return <> -} - -export function mainText (text: JSX.Element) { - return

    - {text} -

    +export function mainText (text: React.ReactElement) { + return

    {text}

    } export function detailText (text: string) { - return {text} + return {text} } export function bold (text: string) { - return {text} + return {text} } export function subTitle (title: string) { - return

    {title}

    + return

    {title}

    } -export function dotList (elements: (JSX.Element | string)[]) { +export function dotList (elements: (React.ReactElement | string)[]) { return ( -
      +
        {elements.filter(v => v).map((element, index) => ( -
      • {element}
      • +
      • {element}
      • ))}
      ) } -export function numList (elements: (JSX.Element | string)[]) { +export function numList (elements: (React.ReactElement | string)[]) { return ( -
        +
          {elements.filter(v => v).map((element, index) => ( -
        • {element}
        • +
        • {element}
        • ))}
        ) @@ -49,12 +36,15 @@ export function numList (elements: (JSX.Element | string)[]) { export function bullet (label: string, value: string) { return <> - {label} {value} + {label}{' '} + {value} } export function link (url: string, text: string) { - return - {text} - + return {text} +} + +export function footer () { + return <> } diff --git a/src/vim-web/react-viewers/generic/genericField.tsx b/src/vim-web/react-viewers/generic/genericField.tsx index 7efbcab3e..ac9f9b945 100644 --- a/src/vim-web/react-viewers/generic/genericField.tsx +++ b/src/vim-web/react-viewers/generic/genericField.tsx @@ -2,6 +2,7 @@ import React from "react"; import { InputNumber } from "./inputNumber"; import { StateRef, useRefresher } from "../helpers/reactUtils"; +import { Input, Checkbox, Select } from '../components' // A text field. export interface GenericTextEntry { @@ -23,6 +24,8 @@ export interface GenericNumberEntry { min?: number; max?: number; step?: number; + info?: string; + transform?: (n: number) => number; } // A boolean field. @@ -46,14 +49,135 @@ export interface GenericSelectEntry { state: StateRef; } -export type GenericEntryType = GenericTextEntry | GenericBoolEntry | GenericNumberEntry | GenericSelectEntry; +export interface GenericSubtitleEntry { + type: 'section' + id: string + label: string +} + +export interface GenericGroupEntry { + type: 'group' + id: string + label: string +} + +export interface GenericReadonlyEntry { + type: 'readonly' + id: string + label: string + value: string + visible?: () => boolean + renderValue?: () => React.ReactNode +} + +export interface GenericElementEntry { + type: 'element' + id: string + element: React.ReactElement +} + +export type GenericEntryType = + | GenericTextEntry + | GenericBoolEntry + | GenericNumberEntry + | GenericSelectEntry + | GenericSubtitleEntry + | GenericGroupEntry + | GenericReadonlyEntry + | GenericElementEntry + +type Section = { id: string; label: string; items: GenericEntryType[] } +type Group = { id: string; label: string; sections: Section[] } + +function buildHierarchy(items: GenericEntryType[]): Group[] { + const groups: Group[] = [] + let currentGroup: Group | null = null + let currentSection: Section | null = null + + for (const item of items) { + if (item.type === 'group') { + currentSection = null + currentGroup = { id: item.id, label: item.label, sections: [] } + groups.push(currentGroup) + } else if (item.type === 'section') { + if (!currentGroup) { currentGroup = { id: 'default', label: '', sections: [] }; groups.push(currentGroup) } + currentSection = { id: item.id, label: item.label, items: [] } + currentGroup.sections.push(currentSection) + } else { + if (!currentGroup) { currentGroup = { id: 'default', label: '', sections: [] }; groups.push(currentGroup) } + if (!currentSection) { currentSection = { id: 'default', label: '', items: [] }; currentGroup.sections.push(currentSection) } + currentSection.items.push(item) + } + } + return groups +} + +function SectionContent({ section }: { section: Section }) { + if (!section.label) { + return
        {section.items.map(GenericEntry)}
        + } + return ( +
        + {section.label} +
        {section.items.map(GenericEntry)}
        +
        + ) +} + +export function GenericContent({ items }: { items: GenericEntryType[] }) { + const hasGroups = items.some(i => i.type === 'group') + const hasSections = items.some(i => i.type === 'section') + + if (!hasGroups && !hasSections) { + return
        {items.map(GenericEntry)}
        + } + + const hierarchy = buildHierarchy(items) + + if (!hasGroups) { + return ( +
        + {hierarchy[0]?.sections.map(s => )} +
        + ) + } + + return ( +
        + {hierarchy.map(group => group.label ? ( +
        + {group.label} +
        + {group.sections.map(s => )} +
        +
        + ) : ( +
        + {group.sections.map(s => )} +
        + ))} +
        + ) +} -/** - * Renders a panel field based on its type. - * @param field - The panel field to render. - * @returns The rendered field element. - */ export function GenericEntry(field: GenericEntryType): React.ReactNode { + if (field.type === 'section') { + return
        {field.label}
        + } + if (field.type === 'group') return null // rendered by GenericContent, not here + if (field.type === 'element') { + return {field.element} + } + if (field.type === 'readonly') { + if (field.visible?.() === false) return null + return ( +
        +
        {field.label}
        +
        {field.renderValue ? field.renderValue() : field.value}
        +
        + ) + } + if (field.visible?.() === false) return null; const isEnabled = field.enabled?.() !== false; @@ -61,12 +185,11 @@ export function GenericEntry(field: GenericEntryType): React.ReactNode { return (
        -
        {field.label}
        -
        +
        {field.label}
        +
        @@ -76,8 +199,18 @@ export function GenericEntry(field: GenericEntryType): React.ReactNode { function GenericField(props:{field: GenericEntryType, disabled?: boolean}): React.ReactNode { switch (props.field.type) { - case "number": - return ; + case "number": { + const f = props.field + const info = f.info + ?? (f.min !== undefined && f.max !== undefined ? `[${f.min}, ${f.max}]` + : f.min !== undefined ? `≥ ${f.min}` + : f.max !== undefined ? `≤ ${f.max}` + : undefined) + return <> + + {info && {info}} + ; + } case "text": return ; case "bool": @@ -97,16 +230,13 @@ function GenericField(props:{field: GenericEntryType, disabled?: boolean}): Reac function GenericTextField(props:{state: StateRef, disabled?: boolean}): React.ReactNode { const refresher = useRefresher() // Makes sure the component re-renders when the state changes. return ( - { refresher.refresh() props.state.set(e.target.value) }} - className="vc-border vc-inline vc-border-gray-300 vc-py-1 vc-w-full vc-px-1" - onBlur={() => props.state.confirm()} /> ); } @@ -119,15 +249,13 @@ function GenericTextField(props:{state: StateRef, disabled?: boolean}): function GenericBoolField(props:{state: StateRef, disabled?: boolean }): React.ReactNode { const refresher = useRefresher() // Makes sure the component re-renders when the state changes. return ( - { + onChange={(checked) => { refresher.refresh() - props.state.set(e.target.checked)} - } - className="vc-border vc-inline vc-border-gray-300 vc-py-1 vc-w-full vc-px-1" + props.state.set(checked) + }} + disabled={props.disabled ?? false} /> ); } @@ -135,18 +263,15 @@ function GenericBoolField(props:{state: StateRef, disabled?: boolean }) function GenericSelectField(props:{field: GenericSelectEntry, disabled?: boolean}): React.ReactNode { const refresher = useRefresher() return ( - + disabled={props.disabled ?? false} + /> ); } \ No newline at end of file diff --git a/src/vim-web/react-viewers/generic/genericPanel.tsx b/src/vim-web/react-viewers/generic/genericPanel.tsx index 3bb89b3b7..1f0ef4c4d 100644 --- a/src/vim-web/react-viewers/generic/genericPanel.tsx +++ b/src/vim-web/react-viewers/generic/genericPanel.tsx @@ -1,9 +1,7 @@ -import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; -import * as Icons from '../icons'; -import { StateRef } from "../helpers/reactUtils"; +import { forwardRef, useImperativeHandle, useRef } from "react"; +import { StateRef, useCustomizer } from "../helpers/reactUtils"; import { useFloatingPanelPosition } from "../helpers/layout"; -import { GenericEntryType, GenericEntry } from "./genericField"; -import { useCustomizer } from "../helpers/customizer"; +import { GenericEntryType, GenericContent } from "./genericField"; // Generic props for the panel. export interface GenericPanelProps { @@ -27,37 +25,30 @@ export const GenericPanel = forwardRef((prop props.showPanel.get() ); - const entries = useCustomizer(props.entries, ref); + const [entries, api] = useCustomizer(props.entries) + useImperativeHandle(ref, () => api) if (!props.showPanel.get()) return null; return ( -
        +
        e.stopPropagation()} > -
        - +
        + {props.header || "Panel Header"} - +
        +
        +
        + +
        -
        - {entries.map(GenericEntry)} -
        ); diff --git a/src/vim-web/react-viewers/generic/inputNumber.tsx b/src/vim-web/react-viewers/generic/inputNumber.tsx index e6e4cac3f..aabe001af 100644 --- a/src/vim-web/react-viewers/generic/inputNumber.tsx +++ b/src/vim-web/react-viewers/generic/inputNumber.tsx @@ -1,5 +1,6 @@ import { useRef, useState, useSyncExternalStore, useEffect } from "react"; import { GenericNumberEntry } from "./genericField"; +import { Input } from '../components' export function InputNumber(props: {entry : GenericNumberEntry}) { const entry = props.entry; @@ -27,26 +28,26 @@ export function InputNumber(props: {entry : GenericNumberEntry}) { const parsed = parseFloat(input); if (!isNaN(parsed)) { - state.set(parsed); + state.set(entry.transform ? entry.transform(parsed) : parsed); } }; const handleBlur = () => { const parsed = parseFloat(inputValue); - const value = isNaN(parsed) ? defaultValue.current : parsed; - + let value = isNaN(parsed) ? defaultValue.current : parsed; + if (entry.transform) value = entry.transform(value); state.set(value); setInputValue(state.get().toString()); }; + const InputAny = Input as any return ( - { - customize(fn: (entries: TData) => TData); -} - -export function useCustomizer( - baseEntries: TData, - ref: React.Ref> -) { - const customization = useRef<(entries: TData) => TData>(); - const [entries, setEntries] = useState(baseEntries); - - const applyCustomization = () => { - setEntries(customization.current ? customization.current(baseEntries) : baseEntries); - }; - - const setCustomization = (fn: (entries: TData) => TData) => { - customization.current = fn; - applyCustomization(); - }; - - useEffect(() => { - applyCustomization(); - }, [baseEntries]); - - useImperativeHandle(ref, () => ({ - customize: setCustomization - })); - - return entries; -} \ No newline at end of file diff --git a/src/vim-web/react-viewers/helpers/element.ts b/src/vim-web/react-viewers/helpers/element.ts index f4cc3aef4..a48288c62 100644 --- a/src/vim-web/react-viewers/helpers/element.ts +++ b/src/vim-web/react-viewers/helpers/element.ts @@ -35,7 +35,7 @@ export async function getElements (vim: Core.Webgl.IWebglVim) { worksetName: worksets ? worksets[e?.worksetIndex ?? -1] : undefined })) as AugmentedElement[] - const real = result.filter(e => vim.getElementFromIndex(e.index).hasMesh) + const real = result.filter(e => vim.getElementFromIndex(e.index)?.hasMesh) return real } diff --git a/src/vim-web/react-viewers/helpers/reactUtils.ts b/src/vim-web/react-viewers/helpers/reactUtils.ts index a356e94cc..4ecbefbf2 100644 --- a/src/vim-web/react-viewers/helpers/reactUtils.ts +++ b/src/vim-web/react-viewers/helpers/reactUtils.ts @@ -10,9 +10,10 @@ * Common shapes: `FuncRef`, `FuncRef>`, `FuncRef` */ -import { useEffect, useMemo, useRef, useState } from "react"; +import { DependencyList, useEffect, useMemo, useRef, useState } from "react"; import type { ISimpleEvent } from '../../core-viewers/shared/events' import { SimpleEventDispatcher } from 'ste-simple-events' +import { storageGet, storageSet } from '../settings/localStorage' /** * Observable state container. Read, write, and subscribe to changes. @@ -32,10 +33,6 @@ export interface StateRef { * @param value - The new state value. */ set(value: T): void; - /** - * Confirms the current state (potentially applying a confirmation transformation). - */ - confirm(): void; onChange: ISimpleEvent; } @@ -67,10 +64,6 @@ class MutableState implements StateRef { this._onChange.dispatch(value); } - confirm(): void { - // No-op by default - } - get onChange(): ISimpleEvent { return this._onChange.asEvent(); } @@ -100,8 +93,14 @@ export function useRefresher() : StateRefresher{ * @returns An object implementing StateRef along with additional helper hooks. */ -export function useStateRef(initialValue: T | (() => T), isLazy = false) { +export function useStateRef(initialValue: T | (() => T), isLazy = false, storageKey?: string) { const getInitialValue = (): T => { + if (storageKey) { + const stored = storageGet(storageKey) + if (stored !== null) { + try { return JSON.parse(stored) as T } catch {} + } + } if (isLazy && typeof initialValue === 'function') { return (initialValue as () => T)(); } @@ -113,7 +112,7 @@ export function useStateRef(initialValue: T | (() => T), isLazy = false) { const [box, setBox] = useState>(() => ({ current: getInitialValue() })); - + const ref = useRef(undefined!); if (ref.current === undefined) { ref.current = getInitialValue(); @@ -121,7 +120,7 @@ export function useStateRef(initialValue: T | (() => T), isLazy = false) { const event = useRef(new SimpleEventDispatcher()); const validate = useRef((next: T, current: T) => next); - const confirm = useRef((value: T) => value); + /** * Updates the state if the validated value differs from the current value. @@ -135,6 +134,7 @@ export function useStateRef(initialValue: T | (() => T), isLazy = false) { ref.current = finalValue; setBox({ current: finalValue }); + if (storageKey) storageSet(storageKey, JSON.stringify(finalValue)); event.current.dispatch(finalValue); }; @@ -147,12 +147,6 @@ export function useStateRef(initialValue: T | (() => T), isLazy = false) { }, set, onChange: event.current.asEvent(), - /** - * Confirms the current state by applying the confirm function and updating the state. - */ - confirm() { - set(confirm.current(ref.current)); - }, /** * Registers a callback to be invoked when the state changes. @@ -162,13 +156,18 @@ export function useStateRef(initialValue: T | (() => T), isLazy = false) { */ useOnChange(on: (value: T) => void | (() => void) | Promise) { useEffect(() => { - return event.current.subscribe((value) => { - const result = on(value); - // If it's a promise, we just call it and ignore resolution/rejection + let cleanup: (() => void) | undefined + const unsub = event.current.subscribe((value) => { + cleanup?.() + cleanup = undefined + const result = on(value) if (result instanceof Promise) { - result.catch(console.error); // Optional: log errors + result.catch(console.error) + } else if (typeof result === 'function') { + cleanup = result } - }); + }) + return () => { cleanup?.(); unsub() } }, []); }, @@ -192,15 +191,6 @@ export function useStateRef(initialValue: T | (() => T), isLazy = false) { validate.current = on; }, []); }, - /** - * Sets a confirmation function to process the state value during confirmation. - * @param on - A function that confirms (and optionally transforms) the current state value. - */ - useConfirm(on: (value: T) => T) { - useEffect(() => { - confirm.current = on; - }, []); - }, }; } @@ -246,6 +236,54 @@ export interface FuncRef { update(transform: (prev: (arg: TArg) => TReturn) => (arg: TArg) => TReturn): void; } +/** + * Subscribes to a signal for the lifetime of the component. Cleanup is automatic. + * Accepts both ISignal (no payload) and ISimpleEvent (with payload). + */ +export function useSubscribe( + signal: { subscribe: (fn: (value: T) => void) => () => void }, + callback: (value: T) => void, + deps: DependencyList = [] +) { + useEffect(() => signal.subscribe(callback), deps) +} + +/** + * Derives a React state value from an external signal. + * Re-renders whenever the signal fires. + */ +export function useSignalState( + signal: ISimpleEvent, + getState: () => T +): [T, (value: T) => void] { + const [state, setState] = useState(getState) + useEffect(() => signal.subscribe(() => setState(getState())), []) + return [state, setState] +} + +/** + * Stores a transform function and applies it to a base value. + * The function is stored in a ref (no `() => fn` setState footgun). + * Returns the transformed value and a setter to update the transform. + * + * @example + * const [sections, setCustomization] = useCustomizer(baseSections) + * // later: setCustomization(sections => [...sections, mySection]) + */ +export function useCustomizer(base: TData): [TData, { customize: (fn: (data: TData) => TData) => void }] { + const fn = useRef<((data: TData) => TData) | undefined>(undefined) + const [, setVersion] = useState(0) + + const api = useRef({ + customize: (newFn: (data: TData) => TData) => { + fn.current = newFn + setVersion(v => v + 1) + } + }) + + return [fn.current ? fn.current(base) : base, api.current] +} + /** * Creates a function reference. Works for both sync and async, with or without arguments. * diff --git a/src/vim-web/react-viewers/helpers/utils.ts b/src/vim-web/react-viewers/helpers/utils.ts index 176c31c7f..dfbd92a59 100644 --- a/src/vim-web/react-viewers/helpers/utils.ts +++ b/src/vim-web/react-viewers/helpers/utils.ts @@ -1,25 +1,25 @@ import { isTrue, isFalse, UserBoolean } from '../settings' -export function whenTrue (value: UserBoolean | boolean, element: JSX.Element) { +export function whenTrue (value: UserBoolean | boolean, element: React.ReactElement) { return isTrue(value) ? element : null } -export function whenFalse (value: UserBoolean | boolean, element: JSX.Element) { +export function whenFalse (value: UserBoolean | boolean, element: React.ReactElement) { return isFalse(value) ? element : null } -export function whenAllTrue (value: (UserBoolean| boolean)[], element: JSX.Element) { +export function whenAllTrue (value: (UserBoolean| boolean)[], element: React.ReactElement) { return value.every(isTrue) ? element : null } -export function whenAllFalse (value: (UserBoolean| boolean)[], element: JSX.Element) { +export function whenAllFalse (value: (UserBoolean| boolean)[], element: React.ReactElement) { return value.every(isFalse) ? element : null } -export function whenSomeTrue (value: (UserBoolean| boolean)[], element: JSX.Element) { +export function whenSomeTrue (value: (UserBoolean| boolean)[], element: React.ReactElement) { return value.some(isTrue) ? element : null } -export function whenSomeFalse (value: (UserBoolean| boolean)[], element: JSX.Element) { +export function whenSomeFalse (value: (UserBoolean| boolean)[], element: React.ReactElement) { return value.some(isFalse) ? element : null } diff --git a/src/vim-web/react-viewers/index.ts b/src/vim-web/react-viewers/index.ts index 2d00ecb8f..cb7a229c5 100644 --- a/src/vim-web/react-viewers/index.ts +++ b/src/vim-web/react-viewers/index.ts @@ -24,7 +24,8 @@ export type ViewerApi = WebglViewerApi | UltraViewerApi export type { FramingApi } from './state/cameraState' export type { SectionBoxApi } from './state/sectionBoxState' export type { IsolationApi, VisibilityStatus } from './state/sharedIsolation' -export type { SettingsApi } from './state/settingsApi' +export type { RenderSettingsApi } from './state/renderSettings' +export type { WebglUiApi, UltraUiApi } from './state/uiState' // Ref types export type { diff --git a/src/vim-web/react-viewers/module.d.ts b/src/vim-web/react-viewers/module.d.ts index 4c96c0108..a7c9e1599 100644 --- a/src/vim-web/react-viewers/module.d.ts +++ b/src/vim-web/react-viewers/module.d.ts @@ -1,4 +1,9 @@ -// Fixes image import errors in typecript. +// Fixes import errors for non-code modules in TypeScript. + +declare module '*.css' { + const value: any + export default value +} declare module '*.png' { const value: any diff --git a/src/vim-web/react-viewers/panels/axesPanel.tsx b/src/vim-web/react-viewers/panels/axesPanel.tsx index fd101d42a..daee50e45 100644 --- a/src/vim-web/react-viewers/panels/axesPanel.tsx +++ b/src/vim-web/react-viewers/panels/axesPanel.tsx @@ -6,7 +6,6 @@ import React, { useEffect, useRef, useState } from 'react' import * as Core from '../../core-viewers' import * as Icons from '../icons' import { FramingApi } from '../state/cameraState' -import { SettingsState } from '../settings/settingsState' import { whenAllTrue, whenTrue } from '../helpers/utils' import { WebglSettings } from '../webgl/settings' import { isTrue } from '../settings/userBoolean' @@ -26,13 +25,13 @@ export const AxesPanelMemo = React.memo(AxesPanel) /** * JSX Component for axes gizmo. */ -function AxesPanel (props: { viewer: Core.Webgl.Viewer, framing: FramingApi, settings: SettingsState }) { +function AxesPanel (props: { viewer: Core.Webgl.Viewer, framing: FramingApi, settings: WebglSettings }) { const viewer = props.viewer const [ortho, setOrtho] = useState(viewer.camera.orthographic) const gizmoDiv = useRef(null) - const resize = useRef() + const resize = useRef(undefined) useEffect(() => { const gizmo = gizmoDiv.current @@ -50,11 +49,7 @@ function AxesPanel (props: { viewer: Core.Webgl.Viewer, framing: FramingApi, set if (viewer.gizmos.axes.canvas) { gizmo.appendChild(viewer.gizmos.axes.canvas) - viewer.gizmos.axes.canvas.classList.add( - 'vc-absolute', - 'vc-inset-0', - 'vc-order-1' - ) + viewer.gizmos.axes.canvas.classList.add('vim-axes-canvas') } // Clean up @@ -68,61 +63,47 @@ function AxesPanel (props: { viewer: Core.Webgl.Viewer, framing: FramingApi, set props.framing.reset.call() } - const btnStyle = - 'vim-axes-button vc-flex vc-items-center vc-justify-center vc-text-gray-medium vc-transition-all hover:vc-text-primary-royal' - const btnHome = ( ) + const btnOrtho = ( ) - const hidden = isTrue(props.settings.value.ui.panelAxes) ? '' : ' vc-hidden' - const empty = !anyUiAxesButton(props.settings.value) + const empty = !anyUiAxesButton(props.settings) + const hidden = isTrue(props.settings.ui.panelAxes) ? '' : ' vim-hidden' const createBar = () => { if (empty) return null return ( -
        -
        - {whenAllTrue([ - props.settings.value.ui.axesOrthographic - ], btnOrtho)} - {whenTrue(props.settings.value.ui.axesHome, btnHome)} -
        +
        + {whenAllTrue([props.settings.ui.axesOrthographic], btnOrtho)} + {whenTrue(props.settings.ui.axesHome, btnHome)}
        ) } return ( -