/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * UI state slice */ import type { StateCreator } from 'zustand'; import { MERGE_LAYERS_STORAGE_KEY, UI_DEFAULTS } from '../constants.js'; import type { ContactShadingQuality, SeparationLinesQuality } from '@ifc-lite/renderer'; import type { FederatedModel } from '../types.js'; import type { GeometryResult } from '@ifc-lite/geometry'; import type { CesiumPlacementDraft } from './cesiumSlice.js'; export type ThemeMode = 'light' | 'dark' | 'colorful'; /** * One-shot target for "jump to a property and edit it" flows (issue #1107). * Armed when a property is added from the bSDD card, consumed by the * Properties panel once the user arrives on the Properties tab — it scrolls * the row into view, highlights it and enters edit mode, then clears itself. * Identified by the same (raw) modelId + expressId the selection carries, so * a stale focus left over from a different entity is simply never matched. */ export interface PropertyFocusTarget { modelId: string; entityId: number; psetName: string; propName: string; } /** * Tools that require edit mode to function. Entering one of them * flips `editEnabled` on; leaving edit mode forces these tools * back to `'select'`. Keep the list in sync — duplicating the * authoring-tool check between `setActiveTool` and * `setEditEnabled` is how the two states drift apart in the * "enter edit, switch tool, exit edit" flow. */ const AUTHORING_TOOLS: ReadonlySet = new Set([ 'addElement', 'cesium-placement', 'split', 'spaceSketch', ]); /** * Cross-slice surface UISlice reaches into via the combined Zustand * `get()` to decide whether toggling a load-time setting needs a * reload (only meaningful while a model is in scope). */ export interface UICrossSliceState { models: Map; geometryResult: GeometryResult | null; /** * Cesium placement draft state owned by `CesiumSlice`. UISlice * reaches in to clear it when global edit mode flips off, so that * "exit edit" really exits everything (the placement editor, the * draft values, the active tool) in a single atomic update. */ cesiumPlacementEditMode: boolean; cesiumPlacementDraftModelId: string | null; cesiumPlacementDraft: CesiumPlacementDraft | null; } export interface UISlice { // State leftPanelCollapsed: boolean; rightPanelCollapsed: boolean; activeTool: string; /** * Global edit mode. When `true`, all in-place editing affordances * (inline property/attribute editors, future geometry manipulators, * georeference placement, the add-element draw tools) are unlocked. * When `false` the viewer is strictly read-only — this is the * default. The toggle is surfaced as a single pill in the main * toolbar so the user has one switch for "am I editing anything?" * rather than per-panel toggles. */ editEnabled: boolean; /** Active tab in the Properties panel. Controlled so in-app flows (e.g. * adding a bSDD property) can jump back to "properties" — issue #1107. */ propertiesActiveTab: 'properties' | 'quantities' | 'bsdd' | 'raw-step'; /** One-shot "scroll to + highlight + edit this property" request, armed by * the bSDD add flow and consumed by the Properties panel. Null when idle. */ pendingPropertyFocus: PropertyFocusTarget | null; theme: ThemeMode; isMobile: boolean; hoverTooltipsEnabled: boolean; visualEnhancementsEnabled: boolean; edgeContrastEnabled: boolean; edgeContrastIntensity: number; contactShadingQuality: ContactShadingQuality; contactShadingIntensity: number; contactShadingRadius: number; separationLinesEnabled: boolean; separationLinesQuality: SeparationLinesQuality; separationLinesIntensity: number; separationLinesRadius: number; /** * Issue #540 — "Merge Multilayer Walls" load-time toggle. Reading * this on next file load is what the WASM bridge actually uses; * flipping it while a model is in scope sets * `mergeLayersPendingReload` so the UI can prompt the user. */ mergeLayers: boolean; /** True after the user flipped `mergeLayers` while a model was loaded. */ mergeLayersPendingReload: boolean; // Actions setLeftPanelCollapsed: (collapsed: boolean) => void; setRightPanelCollapsed: (collapsed: boolean) => void; setActiveTool: (tool: string) => void; setEditEnabled: (enabled: boolean) => void; toggleEditEnabled: () => void; setPropertiesActiveTab: (tab: 'properties' | 'quantities' | 'bsdd' | 'raw-step') => void; /** Arm (or clear, with null) the one-shot property-focus request. */ setPendingPropertyFocus: (focus: PropertyFocusTarget | null) => void; setTheme: (theme: ThemeMode) => void; toggleTheme: () => void; /** Shift+click secret: toggle colorful mode on/off */ toggleColorful: () => void; setIsMobile: (isMobile: boolean) => void; toggleHoverTooltips: () => void; setVisualEnhancementsEnabled: (enabled: boolean) => void; setEdgeContrastEnabled: (enabled: boolean) => void; setEdgeContrastIntensity: (intensity: number) => void; setContactShadingQuality: (quality: ContactShadingQuality) => void; setContactShadingIntensity: (intensity: number) => void; setContactShadingRadius: (radius: number) => void; setSeparationLinesEnabled: (enabled: boolean) => void; setSeparationLinesQuality: (quality: SeparationLinesQuality) => void; setSeparationLinesIntensity: (intensity: number) => void; setSeparationLinesRadius: (radius: number) => void; /** Update the merge-layers toggle and persist to localStorage. */ setMergeLayers: (v: boolean) => void; /** Acknowledge the reload banner without performing a reload. */ clearMergeLayersPendingReload: () => void; } /** Apply the correct CSS classes on for the given theme */ function applyThemeClasses(theme: ThemeMode) { const el = document.documentElement; el.classList.toggle('dark', theme === 'dark'); el.classList.toggle('colorful', theme === 'colorful'); } /** * Returns true when any geometry is loaded — federated model map has * entries OR the legacy single-model `geometryResult` is non-null with * at least one mesh. Centralised here so the merge-layers toggle has * a single source of truth for "is a model loaded?". */ function hasLoadedModel(state: UICrossSliceState): boolean { if (state.models.size > 0) return true; return (state.geometryResult?.meshes.length ?? 0) > 0; } export const createUISlice: StateCreator = (set, get) => ({ // Initial state leftPanelCollapsed: false, rightPanelCollapsed: false, activeTool: UI_DEFAULTS.ACTIVE_TOOL, editEnabled: false, propertiesActiveTab: 'properties', pendingPropertyFocus: null, theme: UI_DEFAULTS.THEME, isMobile: false, hoverTooltipsEnabled: UI_DEFAULTS.HOVER_TOOLTIPS_ENABLED, visualEnhancementsEnabled: UI_DEFAULTS.VISUAL_ENHANCEMENTS_ENABLED, edgeContrastEnabled: UI_DEFAULTS.EDGE_CONTRAST_ENABLED, edgeContrastIntensity: UI_DEFAULTS.EDGE_CONTRAST_INTENSITY, contactShadingQuality: UI_DEFAULTS.CONTACT_SHADING_QUALITY, contactShadingIntensity: UI_DEFAULTS.CONTACT_SHADING_INTENSITY, contactShadingRadius: UI_DEFAULTS.CONTACT_SHADING_RADIUS, separationLinesEnabled: UI_DEFAULTS.SEPARATION_LINES_ENABLED, separationLinesQuality: UI_DEFAULTS.SEPARATION_LINES_QUALITY, separationLinesIntensity: UI_DEFAULTS.SEPARATION_LINES_INTENSITY, separationLinesRadius: UI_DEFAULTS.SEPARATION_LINES_RADIUS, mergeLayers: UI_DEFAULTS.MERGE_LAYERS, mergeLayersPendingReload: false, // Actions setLeftPanelCollapsed: (leftPanelCollapsed) => set({ leftPanelCollapsed }), setRightPanelCollapsed: (rightPanelCollapsed) => set({ rightPanelCollapsed }), setActiveTool: (activeTool) => { // Authoring tools require edit mode. Entering one of them flips // the global toggle on so the rest of the UI (Properties panel, // future manipulators) stays in sync. Read-only tools leave the // flag alone. set(AUTHORING_TOOLS.has(activeTool) ? { activeTool, editEnabled: true } : { activeTool }); }, setEditEnabled: (editEnabled) => { if (!editEnabled) { // Flipping edit mode off must clear every authoring sub-state // that depends on it — otherwise the viewer ends up "not in // edit mode" but still carrying a georef draft or a half-drawn // slab polygon. Cross-slice reset lives here so callers don't // have to remember to mop up. set((s) => ({ editEnabled: false, activeTool: AUTHORING_TOOLS.has(s.activeTool) ? 'select' : s.activeTool, cesiumPlacementEditMode: false, cesiumPlacementDraftModelId: null, cesiumPlacementDraft: null, })); return; } // Turning edit mode ON with nothing selected auto-opens the // AddElement panel — most "I want to edit" sessions start // with adding something, and forcing the user to click an // extra button to reach the panel adds friction. When a // selection already exists, leave activeTool alone so the // Properties panel + Geometry edit card stay primary. set((s) => { const next: Partial = { editEnabled: true }; const slice = s as unknown as { selectedEntity?: unknown }; if (s.activeTool === 'select' && !slice.selectedEntity) { next.activeTool = 'addElement'; } return next; }); }, toggleEditEnabled: () => { get().setEditEnabled(!get().editEnabled); }, setPropertiesActiveTab: (propertiesActiveTab) => set({ propertiesActiveTab }), setPendingPropertyFocus: (pendingPropertyFocus) => set({ pendingPropertyFocus }), setTheme: (theme) => { applyThemeClasses(theme); localStorage.setItem('ifc-lite-theme', theme); set({ theme }); }, toggleTheme: () => { // Normal toggle: dark ↔ light. If currently colorful, drop to dark. const current = get().theme; const newTheme = current === 'dark' ? 'light' : 'dark'; applyThemeClasses(newTheme); localStorage.setItem('ifc-lite-theme', newTheme); set({ theme: newTheme }); }, toggleColorful: () => { // Shift+click secret: toggle colorful on/off // Into colorful from any state. Out of colorful → light (the storm clears). const current = get().theme; const newTheme: ThemeMode = current === 'colorful' ? 'light' : 'colorful'; applyThemeClasses(newTheme); localStorage.setItem('ifc-lite-theme', newTheme); set({ theme: newTheme }); }, setIsMobile: (isMobile) => set({ isMobile }), toggleHoverTooltips: () => set((state) => ({ hoverTooltipsEnabled: !state.hoverTooltipsEnabled })), setVisualEnhancementsEnabled: (visualEnhancementsEnabled) => set({ visualEnhancementsEnabled }), setEdgeContrastEnabled: (edgeContrastEnabled) => set({ edgeContrastEnabled }), setEdgeContrastIntensity: (edgeContrastIntensity) => set({ edgeContrastIntensity }), setContactShadingQuality: (contactShadingQuality) => set({ contactShadingQuality }), setContactShadingIntensity: (contactShadingIntensity) => set({ contactShadingIntensity }), setContactShadingRadius: (contactShadingRadius) => set({ contactShadingRadius }), setSeparationLinesEnabled: (separationLinesEnabled) => set({ separationLinesEnabled }), setSeparationLinesQuality: (separationLinesQuality) => set({ separationLinesQuality }), setSeparationLinesIntensity: (separationLinesIntensity) => set({ separationLinesIntensity }), setSeparationLinesRadius: (separationLinesRadius) => set({ separationLinesRadius }), setMergeLayers: (next) => { const current = get(); if (current.mergeLayers === next) return; // Persist eagerly so the next page-load picks the same value up // through `getInitialMergeLayers` (constants.ts). Wrap in // try/catch — Safari private mode / locked storage throws. try { localStorage.setItem(MERGE_LAYERS_STORAGE_KEY, String(next)); } catch { /* storage unavailable — accept the in-memory toggle silently */ } // Only ask the user to reload if a model is currently in scope. // Toggling the setting on an empty viewer simply changes the // future load behaviour with no visible effect. const pending = hasLoadedModel(current); set({ mergeLayers: next, mergeLayersPendingReload: pending }); }, clearMergeLayersPendingReload: () => set({ mergeLayersPendingReload: false }), });