# Model Viewer > Embed interactive 3D models with AR support into Dash applications using Google's model-viewer. --- .. toc:: .. llms_copy::Model Viewer `dash-model-viewer` is a Dash component library that wraps Google's `model-viewer` web component, allowing you to easily display and interact with 3D models (.glb, .gltf) within your Python Dash dashboards. It features interactive controls, Augmented Reality (AR) support via WebXR, annotations, dynamic updates, and extensive customization options. ### Installation [Visit GitHub Repo](https://github.com/pip-install-python/dash-model-viewer) ```bash pip install dash-model-viewer ``` --- ### Quick Start Embed a 3D model with basic controls and AR capability. .. exec::docs.dash_model_viewer.quick_start_example :code: false ```python # File: docs/dash_model_viewer/quick_start_example.py import dash_mantine_components as dmc from dash_model_viewer import DashModelViewer as ModelViewer # Assume assets folder is configured by the main docs app ASTRONAUT_SRC = "https://modelviewer.dev/shared-assets/models/Astronaut.glb" component = dmc.Paper( p="md", shadow="sm", withBorder=True, children=[ ModelViewer( id="3d-model", src="https://modelviewer.dev/shared-assets/models/Astronaut.glb", alt="A 3D model of an astronaut", cameraControls=True, touchAction="pan-y", ar=True, poster="https://modelviewer.dev/shared-assets/models/Astronaut.webp", style={"height": "65vh", "width": "100%"}, arButtonText="View in AR" ), ] ) ``` --- ### Camera Views via Hotspots Configure hotspots to act as camera position presets. Clicking a hotspot with `orbit` and `target` defined will automatically animate the camera to that view. The component handles this interaction internally. .. exec::docs.dash_model_viewer.camera_views_example :code: false ```python # File: docs/dash_model_viewer/camera_views_example.py import dash_mantine_components as dmc from dash_model_viewer import DashModelViewer as ModelViewer from dash import get_asset_url import re # Using pre-scaled values for documentation simplicity # Original example used SCALE_FACTOR = 40 THOR_MODEL_SRC = get_asset_url("model_viewer/thor_and_the_midgard_serpent.glb") # Assumes served by docs app THOR_POSTER_SRC = "assets/model_viewer/ThorAndTheMidgardSerpent.webp" # --- Define a scaling factor --- # Should be the same factor used for positions SCALE_FACTOR = 40 # --- Helper Functions to Scale Target and Orbit Strings --- def scale_target_string(target_str, factor): """Parses 'Xm Ym Zm', scales X, Y, Z, returns 'scaledXm scaledYm scaledZm'.""" numbers = re.findall(r"[-+]?\d*\.?\d+", target_str) # Find numbers if len(numbers) == 3: try: x = float(numbers[0]) * factor y = float(numbers[1]) * factor z = float(numbers[2]) * factor # Keep sufficient precision and add 'm' suffix back return f"{x:.6f}m {y:.6f}m {z:.6f}m" except ValueError: return target_str # Return original on error return target_str # Return original if parsing fails def scale_orbit_string(orbit_str, radius_factor): """Parses 'Thetadeg Phideg Radiusm', scales Radius, returns 'Thetadeg Phideg scaledRadiusm'.""" parts = orbit_str.split() if len(parts) == 3: try: theta = parts[0] # Keep angle with 'deg' phi = parts[1] # Keep angle with 'deg' # Extract number from radius string (e.g., '0.065m') radius_num_str = re.findall(r"[-+]?\d*\.?\d+", parts[2])[0] scaled_radius = float(radius_num_str) * radius_factor # Keep sufficient precision and add 'm' suffix back return f"{theta} {phi} {scaled_radius:.8f}m" except (ValueError, IndexError): return orbit_str # Return original on error return orbit_str # Return original if parsing fails # --- Hotspot Structure for Camera Views --- # Scaled position, target, and orbit radius values camera_view_hotspots = [ { "slot": "hotspot-0", "position": f"{-0.0569 * SCALE_FACTOR:.4f} {0.0969 * SCALE_FACTOR:.4f} {-0.1398 * SCALE_FACTOR:.4f}", "normal": "-0.5829775 0.2863482 -0.7603565", # Scale Target and Orbit Radius "orbit": scale_orbit_string("-50.94862deg 84.56856deg 0.06545582m", SCALE_FACTOR), "target": scale_target_string("-0.04384604m 0.07348397m -0.1213202m", SCALE_FACTOR), "text": "The Fighters", "children_classname": "view-button" }, { "slot": "hotspot-1", "position": f"{-0.1997 * SCALE_FACTOR:.4f} {0.11766 * SCALE_FACTOR:.4f} {0.0056 * SCALE_FACTOR:.4f}", "normal": "-0.4421014 0.04410423 0.8958802", # Scale Target and Orbit Radius "orbit": scale_orbit_string("3.711166deg 92.3035deg 0.04335197m", SCALE_FACTOR), "target": scale_target_string("-0.1879433m 0.1157161m -0.01563221m", SCALE_FACTOR), "text": "Hold Tight!", "children_classname": "view-button" }, { "slot": "hotspot-2", "position": f"{0.0608 * SCALE_FACTOR:.4f} {0.0566 * SCALE_FACTOR:.4f} {0.0605 * SCALE_FACTOR:.4f}", "normal": "0.2040984 0.7985359 -0.56629", # Scale Target and Orbit Radius "orbit": scale_orbit_string("42.72974deg 84.74043deg 0.07104211m", SCALE_FACTOR), "target": scale_target_string("0.0757959m 0.04128428m 0.07109568m", SCALE_FACTOR), "text": "The Encounter", "children_classname": "view-button" }, { "slot": "hotspot-3", "position": f"{0.1989 * SCALE_FACTOR:.4f} {0.16711 * SCALE_FACTOR:.4f} {-0.0749 * SCALE_FACTOR:.4f}", "normal": "0.7045857 0.1997957 -0.6809117", # Scale Target and Orbit Radius "orbit": scale_orbit_string("-40.11996deg 88.17818deg 0.07090651m", SCALE_FACTOR), "target": scale_target_string("0.2011831m 0.1398312m -0.07917573m", SCALE_FACTOR), "text": "Catapult", "children_classname": "view-button" }, { "slot": "hotspot-4", "position": f"{0.0677 * SCALE_FACTOR:.4f} {0.18906 * SCALE_FACTOR:.4f} {-0.0158 * SCALE_FACTOR:.4f}", "normal": "-0.008245394 0.6207898 0.7839338", # Scale Target and Orbit Radius "orbit": scale_orbit_string("-118.8446deg 98.83521deg 0.06m", SCALE_FACTOR), "target": scale_target_string("0.06528695m 0.1753406m -0.01964653m", SCALE_FACTOR), "text": "Thunder and Lightning", "children_classname": "view-button" }, { "slot": "hotspot-5", "position": f"{-0.1418 * SCALE_FACTOR:.4f} {-0.041 * SCALE_FACTOR:.4f} {0.174 * SCALE_FACTOR:.4f}", "normal": "-0.4924125 0.4698265 0.7326617", # Scale Target and Orbit Radius "orbit": scale_orbit_string("-2.305313deg 110.1798deg 0.04504082m", SCALE_FACTOR), "target": scale_target_string("-0.1151219m -0.04192762m 0.1523764m", SCALE_FACTOR), "text": "Knock Knock", "children_classname": "view-button" }, { "slot": "hotspot-6", "position": f"{0.08414419 * SCALE_FACTOR:.4f} {0.134 * SCALE_FACTOR:.4f} {-0.215 * SCALE_FACTOR:.4f}", "normal": "0.03777227 0.06876653 -0.9969176", # Scale Target and Orbit Radius "orbit": scale_orbit_string("-37.54149deg 82.16209deg 0.0468692m", SCALE_FACTOR), "target": scale_target_string("0.08566038m 0.1249514m -0.1939646m", SCALE_FACTOR), "text": "Lucky Shot", "children_classname": "view-button" }, { "slot": "hotspot-7", "position": f"{0.14598 * SCALE_FACTOR:.4f} {0.03177 * SCALE_FACTOR:.4f} {-0.05945886 * SCALE_FACTOR:.4f}", "normal": "-0.9392524 0.2397608 -0.2456009", # Scale Target and Orbit Radius "orbit": scale_orbit_string("-142.3926deg 86.45934deg 0.06213665m", SCALE_FACTOR), "target": scale_target_string("0.1519967m 0.01904771m -0.05945886m", SCALE_FACTOR), "text": "Get Away!", "children_classname": "view-button" }, { "slot": "hotspot-8", "position": f"{0.0094 * SCALE_FACTOR:.4f} {0.0894 * SCALE_FACTOR:.4f} {-0.15103 * SCALE_FACTOR:.4f}", "normal": "-0.3878782 0.4957891 -0.7770094", # Scale Target and Orbit Radius "orbit": scale_orbit_string("-118.6729deg 117.571deg 0.03905975m", SCALE_FACTOR), "target": scale_target_string("0.007600758m 0.06771782m -0.1386167m", SCALE_FACTOR), "text": "The Jump", "children_classname": "view-button" }, { "slot": "hotspot-9", "position": f"{-0.0658 * SCALE_FACTOR:.4f} {0.1786 * SCALE_FACTOR:.4f} {-0.0183 * SCALE_FACTOR:.4f}", "normal": "0.7857152 0.4059967 0.46671", # Scale Target and Orbit Radius "orbit": scale_orbit_string("53.28236deg 95.91318deg 0.1102844m", SCALE_FACTOR), "target": scale_target_string("-0.07579391m 0.1393538m -0.00851791m", SCALE_FACTOR), "text": "The Beast", "children_classname": "view-button" }, { "slot": "hotspot-10", "position": f"{0.02610224 * SCALE_FACTOR:.4f} {0.01458751 * SCALE_FACTOR:.4f} {-0.004978945 * SCALE_FACTOR:.4f}", "normal": "-0.602551 0.7856147 -0.1405055", # Scale Target and Orbit Radius "orbit": scale_orbit_string("-78.89725deg 77.17752deg 0.08451112m", SCALE_FACTOR), "target": scale_target_string("0.02610223m 0.0145875m -0.004978945m", SCALE_FACTOR), "text": "Treasure", "children_classname": "view-button" }, { "slot": "hotspot-11", "position": f"{-0.1053838 * SCALE_FACTOR:.4f} {0.01610652 * SCALE_FACTOR:.4f} {0.1076345 * SCALE_FACTOR:.4f}", "normal": "-0.624763 0.5176854 0.5845283", # Scale Target and Orbit Radius "orbit": scale_orbit_string("10.89188deg 119.9775deg 0.03543022m", SCALE_FACTOR), "target": scale_target_string("-0.1053838m 0.01610652m 0.1076345m", SCALE_FACTOR), "text": "Desperation", "children_classname": "view-button" }, ] # Scaled initial view initial_orbit = scale_orbit_string("-8.142746deg 68.967deg 0.6179899m", SCALE_FACTOR) initial_target = scale_target_string("-0.003m 0.0722m 0.0391m", SCALE_FACTOR) component = dmc.Paper( p="md", shadow="sm", withBorder=True, children=[ dmc.Text("Click the buttons to change camera view.", size="sm", ta="center", mb="xs"), ModelViewer( id="hotspot-camera-view-demo", # --- Model --- src=get_asset_url("model_viewer/thor_and_the_midgard_serpent.glb"), alt="Thor and the Midgard Serpent", # poster=get_asset_url("ThorAndTheMidgardSerpent.webp"), # --- Controls & Interaction --- cameraControls=True, touchAction="none", # Use scaled initial view cameraOrbit=initial_orbit, cameraTarget=initial_target, fieldOfView="45deg", minFieldOfView="25deg", maxFieldOfView="45deg", interpolationDecay=200, # Min orbit radius uses percentage, likely okay without scaling minCameraOrbit="auto auto 5%", # --- AR & Rendering --- ar=True, toneMapping="aces", shadowIntensity=1, # --- Hotspots --- hotspots=camera_view_hotspots, # Pass the fully scaled list # --- Style --- style={'width': '800px', 'height': '600px', 'margin': 'auto'} ), dmc.Text("Styling for '.view-button' needed in assets folder.", size="xs", c="dimmed", ta="center", mt="xs") ] ) # Example CSS for assets/camera_views_styles.css: # .view-button { # background: #ffffff; # border-radius: 4px; # border: none; # box-sizing: border-box; # box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25); # color: rgba(0, 0, 0, 0.8); # display: block; # font-family: Futura, Helvetica Neue, sans-serif; # font-size: 12px; # font-weight: 700; # max-width: 128px; # overflow-wrap: break-word; # padding: 0.5em 1em; # text-align: center; # width: max-content; # cursor: pointer; # } # .view-button:hover { background: #eee; } ``` **Note:** The example uses pre-scaled values for orbit/target suitable for the specific model. You might need to adjust these values or use scaling functions (like in the original `usage_camera_views.py`) for your own models. --- ### Dynamic Model Switching Update the `src` property of the `DashModelViewer` component using a standard Dash callback to load different 3D models interactively. .. exec::docs.dash_model_viewer.dynamic_switching_example :code: false ```python # File: docs/dash_model_viewer/dynamic_switching_example.py import dash from dash import html, callback, Input, Output, get_asset_url import dash_mantine_components as dmc from dash_model_viewer import DashModelViewer as ModelViewer MODELS = { "Shoe": get_asset_url("model_viewer/MaterialsVariantsShoe.glb"), "Woman": get_asset_url("model_viewer/kara_-_detroit_become_human.glb"), "Horse": "https://modelviewer.dev/shared-assets/models/Horse.glb" # Added another option } component = dmc.Paper( p="md", shadow="sm", withBorder=True, children=[ dmc.SegmentedControl( id="dyn-switch-model-select", data=list(MODELS.keys()), value="Astronaut", fullWidth=True, mb="md", ), ModelViewer( id="dynamic-switch-viewer", src=MODELS["Shoe"], # Initial model alt="A 3D model", cameraControls=True, ar=True, style={"height": "450px", "width": "100%"}, ) ] ) @callback( Output('dynamic-switch-viewer', 'src'), Input('dyn-switch-model-select', 'value') ) def update_dynamic_model(selected_model_name): return MODELS.get(selected_model_name, MODELS["Shoe"]) ``` --- ### Advanced: Dynamic Dimensions This example demonstrates controlling hotspot *presence* via Dash callbacks and relies on **client-side JavaScript** (not included in this basic demo) to: 1. Calculate model dimensions using the `model-viewer` API. 2. Position the hotspots correctly based on the bounding box. 3. Draw SVG lines between hotspots to visualize dimensions. 4. Handle unit conversions. The Python code sets up the necessary controls and toggles the hotspot list passed to the component. **Requires:** Corresponding JavaScript in `assets/model_viewer_clientside.js` and CSS in `assets/dimensions_styles.css` for full functionality. .. exec::docs.dash_model_viewer.dynamic_dimensions_example :code: false ```python # File: docs/dash_model_viewer/dynamic_dimensions_example.py # --- START OF FILE dynamic_dimensions_example.py --- import dash from dash import html, dcc, callback, Input, Output, State, get_asset_url, clientside_callback, ClientsideFunction import dash_mantine_components as dmc from dash_model_viewer import DashModelViewer as ModelViewer # Basic structure for hotspots - positions/text usually set by client-side JS # Keep children_classname for CSS targeting if needed (.dot, .dim) dimension_hotspots_structure = [ # Dots for line endpoints {"slot": "hotspot-dot+X-Y+Z", "normal": "1 0 0", "text": "", "children_classname": "dot"}, {"slot": "hotspot-dot+X-Y-Z", "normal": "1 0 0", "text": "", "children_classname": "dot"}, {"slot": "hotspot-dot+X+Y-Z", "normal": "0 1 0", "text": "", "children_classname": "dot"}, {"slot": "hotspot-dot-X+Y-Z", "normal": "0 1 0", "text": "", "children_classname": "dot"}, {"slot": "hotspot-dot-X-Y-Z", "normal": "-1 0 0", "text": "", "children_classname": "dot"}, {"slot": "hotspot-dot-X-Y+Z", "normal": "-1 0 0", "text": "", "children_classname": "dot"}, # Hotspots to display dimension text {"slot": "hotspot-dim+X-Y", "normal": "1 0 0", "text": "", "children_classname": "dim"}, # e.g., Length Z {"slot": "hotspot-dim+X-Z", "normal": "1 0 0", "text": "", "children_classname": "dim"}, # e.g., Height Y {"slot": "hotspot-dim+Y-Z", "normal": "0 1 0", "text": "", "children_classname": "dim"}, # e.g., Width X (Top) - Might be unused by JS line drawing {"slot": "hotspot-dim-X-Z", "normal": "-1 0 0", "text": "", "children_classname": "dim"}, # e.g., Height Y {"slot": "hotspot-dim-X-Y", "normal": "-1 0 0", "text": "", "children_classname": "dim"}, # e.g., Length Z ] # Ensure this path is correct relative to where assets are served in your docs app CHAIR_SRC = get_asset_url("model_viewer/Froggy_rocking_chair.glb") # Define unit options consistent with JS unit_options = [ {"label": "cm", "value": "cm"}, {"label": "mm", "value": "mm"}, {"label": "m", "value": "m"}, {"label": "in", "value": "in"}, {"label": "ft", "value": "ft"}, ] component = dmc.Paper( p="md", shadow="sm", withBorder=True, children=[ dmc.Group( # Group controls for better layout [ dmc.Checkbox( id="dims-show-checkbox", label="Show Dimensions", checked=True, # Start checked ), dmc.RadioGroup( id='dims-unit-select', label="Units", children=dmc.Group([dmc.Radio(label=opt['label'], value=opt['value']) for opt in unit_options]), value='cm', # Default value size="sm", mt="xs" # Add some margin top if Checkbox is above ), ], mb="md", # Margin bottom for the group align="flex-end" # Align items nicely ), html.Div( # Container for relative positioning (needed for SVG overlay) id="dims-model-container", style={'position': 'relative', 'height': '450px', 'width': '100%', 'border': '1px dashed #ccc'}, children=[ ModelViewer( id="dimension-demo-dynamic", src=CHAIR_SRC, alt="Chair for dimensions", cameraControls=True, cameraOrbit="-30deg auto auto", ar=True, shadowIntensity=1, # Hotspots are controlled by the callback below hotspots=dimension_hotspots_structure, # Initial state based on checkbox style={"height": "100%", "width": "100%", "position": 'absolute', 'top': 0, 'left': 0} ), # SVG overlay for lines will be added here by client-side JS ] ), dmc.Text( "Dimension calculation, positioning, text update, and line drawing require client-side JS.", size="xs", c="dimmed", ta="center", mt="sm" ), dmc.Text( "Hotspot *presence* is controlled server-side. Styling requires CSS.", size="xs", c="dimmed", ta="center", mt="xs" ) ] ) # Callback controls ONLY the presence of the hotspots passed to the component. # The actual positioning, text update, and line drawing require client-side JS. @callback( Output("dimension-demo-dynamic", "hotspots"), Input("dims-show-checkbox", "checked"), ) def control_hotspot_visibility(is_checked): if is_checked: # print("Server: Sending hotspot structure") # Debug return dimension_hotspots_structure # Send structure to component/JS else: # print("Server: Sending empty hotspot list") # Debug return [] # Send empty list to hide # UPDATED Client-side Callback # Triggers the JS function to perform calculations, updates, and SVG drawing. clientside_callback( ClientsideFunction( namespace='modelViewer', # Namespace defined in model_viewer_clientside.js function_name='updateDimensions' # Function name defined in model_viewer_clientside.js ), Output("dimension-demo-dynamic", "alt"), # Dummy output, needed for any clientside callback # --- Inputs that trigger the JS --- Input("dimension-demo-dynamic", "src"), # Trigger on model change Input("dims-show-checkbox", "checked"), # Trigger on checkbox change (passes boolean) Input("dims-unit-select", "value"), # Trigger on unit change (passes string like 'cm') Input("dimension-demo-dynamic", "hotspots"), # *** CRUCIAL: Trigger AFTER Python updates hotspots *** # --- States needed by the JS function --- State("dimension-demo-dynamic", "id"), # Pass the ID of the ModelViewer component State("dims-model-container", "id"), # Pass the ID of the container DIV prevent_initial_call=False # Allow to run on page load ) # --- END OF FILE dynamic_dimensions_example.py --- ``` --- ### Advanced: Interactive Hotspot Placement This example demonstrates a setup for allowing users to dynamically add hotspots to a model. It uses Dash callbacks and `dcc.Store` to manage the application state (viewing vs. adding mode) and relies on **client-side JavaScript** to: 1. Capture the user's click intention when in "Place Hotspot" mode. 2. Use the `model-viewer` API (`positionAndNormalFromPoint`) to get the 3D position and surface normal at the center of the viewer (where the reticle is). 3. Send this data back to the server via a `dcc.Store`. 4. The server-side callback then updates the list of hotspots displayed by the component. **Requires:** Corresponding JavaScript in `assets/model_viewer_clientside.js` and CSS for the reticle/hotspots in `assets/dynamic_hotspots.css` for full functionality. .. exec::docs.dash_model_viewer.dynamic_hotspots_example :code: false ```python # File: docs/dash_model_viewer/dynamic_hotspots_example.py # --- START OF FILE dynamic_hotspots_example.py --- import dash # ****** Add ClientsideFunction to imports ****** from dash import html, dcc, callback, Input, Output, State, no_update, clientside_callback, ClientsideFunction import dash_mantine_components as dmc from dash_model_viewer import DashModelViewer as ModelViewer import time # ****** Ensure this path is correct for your docs setup ****** # If your assets are in assets/model_viewer/, use get_asset_url # from dash import get_asset_url # ASTRONAUT_SRC = get_asset_url("model_viewer/Astronaut.glb") # Otherwise, use the direct URL if served externally ASTRONAUT_SRC = "https://modelviewer.dev/shared-assets/models/Astronaut.glb" component = dmc.Paper( p="md", shadow="sm", withBorder=True, children=[ # Stores to manage state and data between client and server dcc.Store(id='dyn-hotspot-store', data=[]), dcc.Store(id='dyn-mode-store', data='viewing'), # 'viewing' or 'adding' dcc.Store(id='dyn-new-hotspot-data-store', data=None), # Used by JS to send data # Controls dmc.Group( [ dmc.Button("Set Hotspot", id="dyn-set-place-button"), dmc.Button("Cancel", id="dyn-cancel-button", variant="outline", style={'display': 'none'}), dmc.TextInput( id="dyn-label-input", placeholder="Enter hotspot label...", style={'display': 'none', 'flexGrow': 1}, value="", # Set initial value ), ], mb="md" ), # Viewer Container html.Div( id="dyn-viewer-container", style={'position': 'relative', 'height': '450px', 'width': '100%', 'border': '1px dashed #ccc'}, children=[ ModelViewer( id="dynamic-hotspots-viewer", src=ASTRONAUT_SRC, alt="Astronaut for adding hotspots", cameraControls=True, ar=True, # Ensure model-viewer includes necessary JS for positionAndNormalFromPoint style={"height": "100%", "width": "100%", "position": 'absolute', 'top': 0, 'left': 0}, hotspots=[] # Start empty, updated by callback ), # Visual reticle - centered overlay, shown only in 'adding' mode html.Div( id="dyn-reticle", style={ 'position': 'absolute', 'top': '50%', 'left': '50%', 'transform': 'translate(-50%, -50%)', 'width': '30px', 'height': '30px', 'border': '2px solid red', 'borderRadius': '50%', 'pointerEvents': 'none', 'display': 'none' # Controlled by callback } ) ] ), dmc.Text( "Click 'Set Hotspot', type label, aim reticle, click 'Place Hotspot'. Requires client-side JS.", size="xs", c="dimmed", ta="center", mt="sm" ), # ****** Add note about CSS ****** dmc.Text( "Requires CSS for '.hotspot-dynamic' styling in assets folder.", size="xs", c="dimmed", ta="center", mt="xs" ) ] ) # --- Callbacks --- # Callback 1: Toggle Add/Viewing Mode State (Handles UI Changes) @callback( Output('dyn-mode-store', 'data'), Output('dyn-label-input', 'style'), Output('dyn-set-place-button', 'children'), Output('dyn-cancel-button', 'style'), Output('dyn-reticle', 'style'), Input('dyn-set-place-button', 'n_clicks'), Input('dyn-cancel-button', 'n_clicks'), State('dyn-mode-store', 'data'), prevent_initial_call=True ) def toggle_add_mode(set_clicks, cancel_clicks, current_mode): button_id = dash.callback_context.triggered_id reticle_style = { # Base style, display controlled below 'position': 'absolute', 'top': '50%', 'left': '50%', 'transform': 'translate(-50%, -50%)', 'width': '30px', 'height': '30px', 'border': '2px solid red', 'borderRadius': '50%', 'pointerEvents': 'none' } # --- Entering Add Mode --- if button_id == 'dyn-set-place-button' and current_mode == 'viewing': print("Entering Add Mode") # Debug reticle_style['display'] = 'block' return 'adding', {'display': 'inline-block', 'flexGrow': 1}, "Place Hotspot", {'display': 'inline-block'}, reticle_style # --- Exiting Add Mode (via Cancel or JS completion) --- # If 'Place Hotspot' is clicked while in 'adding' mode, this callback does nothing. # The clientside callback handles the action. Callback 2 handles resetting the UI AFTER data is received. elif button_id == 'dyn-cancel-button': print("Exiting Add Mode via Cancel") # Debug reticle_style['display'] = 'none' return 'viewing', {'display': 'none', 'flexGrow': 1}, "Set Hotspot", {'display': 'none'}, reticle_style elif button_id == 'dyn-set-place-button' and current_mode == 'adding': # This case is handled by the clientside callback below. # The server-side callback should do nothing here. print("Place Hotspot clicked - Clientside callback should handle this.") # Debug return no_update # Explicitly do nothing # Default case (shouldn't normally be reached for these inputs) return no_update # ****** START: ADDED CLIENTSIDE CALLBACK ****** # Callback 1.5: Trigger JS to get hotspot data when "Place Hotspot" is clicked clientside_callback( ClientsideFunction( namespace='modelViewer', # Namespace in your JS file function_name='handleAddHotspotClick' # Function name in your JS file ), Output('dyn-new-hotspot-data-store', 'data'), # JS function returns data here Input('dyn-set-place-button', 'n_clicks'), # Triggered by this button State('dynamic-hotspots-viewer', 'id'), # Pass viewer ID to JS State('dyn-mode-store', 'data'), # Pass current mode to JS State('dyn-label-input', 'value'), # Pass label text to JS prevent_initial_call=True ) # ****** END: ADDED CLIENTSIDE CALLBACK ****** # Callback 2: Process New Hotspot Data (received from Client-Side via Store) @callback( # Outputs to update the main hotspot list and reset the UI Output('dyn-hotspot-store', 'data', allow_duplicate=True), Output('dyn-mode-store', 'data', allow_duplicate=True), # Reset mode back to viewing Output('dyn-label-input', 'value', allow_duplicate=True), # Clear input Output('dyn-label-input', 'style', allow_duplicate=True), # Hide input Output('dyn-set-place-button', 'children', allow_duplicate=True), # Reset button text Output('dyn-cancel-button', 'style', allow_duplicate=True), # Hide Cancel button Output('dyn-reticle', 'style', allow_duplicate=True), # Hide reticle # Triggered ONLY when the clientside callback updates this store Input('dyn-new-hotspot-data-store', 'data'), # State needed to update the list State('dyn-hotspot-store', 'data'), # Get current list prevent_initial_call=True ) def add_new_hotspot(new_hotspot_data, current_hotspots): # Check if the trigger was just the initial None value or invalid data if new_hotspot_data is None or not isinstance(new_hotspot_data, dict): print(f"Dynamic Hotspots: Invalid or no new hotspot data received: {new_hotspot_data}") # Don't reset the UI if data is invalid, just don't add the hotspot # UI reset should only happen on SUCCESSFUL addition or explicit CANCEL. # However, if this callback IS triggered by bad data from JS somehow, # maybe we *should* reset? Let's keep the reset for now. reticle_style = { # Base style, display controlled below 'position': 'absolute', 'top': '50%', 'left': '50%', 'transform': 'translate(-50%, -50%)', 'width': '30px', 'height': '30px', 'border': '2px solid red', 'borderRadius': '50%', 'pointerEvents': 'none', 'display':'none' } # Return no_update for hotspot list, but still reset UI return no_update, 'viewing', "", {'display': 'none', 'flexGrow': 1}, "Set Hotspot", {'display': 'none'}, reticle_style print(f"Dynamic Hotspots (Server): Received new hotspot data: {new_hotspot_data}") # Add the new hotspot to the list if not isinstance(current_hotspots, list): current_hotspots = [] # Initialize if store is empty/invalid # Add a default class if needed for styling new hotspots # Ensure the keys from JS match what ModelViewer expects validated_hotspot = { "slot": new_hotspot_data.get("slot", f"hs-err-{time.time()}"), "position": new_hotspot_data.get("position", "0 0 0"), "normal": new_hotspot_data.get("normal"), # Optional "text": new_hotspot_data.get("text", ""), "children_classname": new_hotspot_data.get('children_classname', 'hotspot-dynamic') } current_hotspots.append(validated_hotspot) print(f"Dynamic Hotspots (Server): Updated list: {current_hotspots}") # Reset UI elements back to viewing state AFTER successful add reticle_style = { # Base style, display controlled below 'position': 'absolute', 'top': '50%', 'left': '50%', 'transform': 'translate(-50%, -50%)', 'width': '30px', 'height': '30px', 'border': '2px solid red', 'borderRadius': '50%', 'pointerEvents': 'none', 'display':'none' } return current_hotspots, 'viewing', "", {'display': 'none', 'flexGrow': 1}, "Set Hotspot", {'display': 'none'}, reticle_style # Callback 3: Update ModelViewer's 'hotspots' prop when the store changes @callback( Output('dynamic-hotspots-viewer', 'hotspots'), Input('dyn-hotspot-store', 'data') # Triggered by Callback 2 updating the store ) def update_viewer_hotspots_list(hotspot_list): print(f"Dynamic Hotspots (Server): Updating viewer component with hotspots: {hotspot_list}") # Ensure it's always a list, even if store somehow becomes None return hotspot_list if isinstance(hotspot_list, list) else [] # --- END OF FILE dynamic_hotspots_example.py --- ``` --- ### Component Properties | Property | Type | Default | Description | | :----------------- | :---------------------------------- | :----------------------------------------- |:----------------------------------------------------------------------------------------------------------------| | **`id`** | `string` | **Required** | Unique identifier for the component. | | **`src`** | `string` | **Required** | URL to the 3D model file (.glb, .gltf). Can be absolute or relative to assets folder. | | **`alt`** | `string` | **Required** | Alternative text description for accessibility. | | `style` | `object` | `{}` | Standard CSS styles for the outer container. | | `cameraControls` | `bool` | `True` | Enable user interaction to control the camera (orbit, zoom, pan). | | `touchAction` | `'pan-y'`, `'pan-x'`, `'none'` | `'pan-y'` | How touch gestures interact with the model (vertical pan, horizontal pan, or none). | | `cameraOrbit` | `string` | `undefined` | Sets the initial/current camera position (`theta phi radius`, e.g., `0deg 75deg 1.5m`). | | `cameraTarget` | `string` | `undefined` | Sets the point the camera looks at (`X Y Z`, e.g., `0m 1m 0m`). | | `fieldOfView` | `string` | `'auto'` | Camera's vertical field of view (e.g., `'45deg'`). | | `minFieldOfView` | `string` | `'25deg'` | Minimum vertical field of view allowed. | | `maxFieldOfView` | `string` | `'auto'` | Maximum vertical field of view allowed. | | `interpolationDecay`| `number` or `string` | `50` | Controls the speed of camera transitions (higher is faster decay, slower transition). 0 is instant. | | `minCameraOrbit` | `string` | `'auto auto auto'` | Sets minimum bounds for camera orbit (`theta phi radius`, use 'auto' for no limit). | | `maxCameraOrbit` | `string` | `'auto auto auto'` | Sets maximum bounds for camera orbit. | | `poster` | `string` | `undefined` | URL of an image to show before the model loads. Can be absolute or relative to assets. | | `ar` | `bool` | `True` | Enables AR features and displays the AR button if supported. | | `arModes` | `string` | `"webxr scene-viewer quick-look"` | Space-separated list of preferred AR modes. | | `arScale` | `'auto'`, `'fixed'` | `'auto'` | Controls model scaling in AR ('auto' tries world scale, 'fixed' uses model's scene units). | | `arButtonText` | `string` | `'View in your space'` | Text displayed on the default AR button. | | `customArPrompt` | `node` (string or Dash component) | `null` | Custom content to show while initializing AR (replaces default hand icon). Pass Dash components directly. | | `customArFailure` | `node` (string or Dash component) | `null` | Custom content to show if AR fails to start or track (replaces default message). Pass Dash components directly. | | `toneMapping` | `'neutral'`, `'aces'`, ... | `'neutral'` | Adjusts the color grading/tone mapping (see `model-viewer` docs for options like 'agx'). | | `shadowIntensity` | `number` or `string` | `0` | Controls the opacity of the model's shadow (0 to 1). | | `hotspots` | `array` | `[]` | List of hotspot configuration objects (see structure below). | | `variantName` | `string` | `null` | Selects a specific model variant if the GLTF file defines variants. Use `null` or `'default'` for default. | | `setProps` | `func` | (Dash Internal) | Callback function to update component properties. | | `loading_state` | `object` | (Dash Internal) | Object describing the loading state of the component or its props. | **Hotspot Object Structure:** Each object in the `hotspots` array represents a `div` placed inside the `model-viewer` and can have the following keys: * `slot`: (String, Required) A unique name for the hotspot's `slot` attribute (e.g., `"hotspot-1"`, `"hotspot-visor"`). Used for targeting with CSS (`.hotspot[slot='...']`). * `position`: (String, Required) The 3D coordinates `"X Y Z"` where the hotspot should be placed in model space (e.g., `"0 1.75 0.35"`). * `normal`: (String, Optional) The surface normal vector `"X Y Z"` at the position (e.g., `"0 0 1"`). Influences the hotspot's orientation relative to the surface. * `text`: (String, Optional) Text content to display *inside* the hotspot's `div` element. * `children_classname`: (String, Optional) A CSS class name to add *in addition* to `.hotspot` on the hotspot's `div` element for custom styling (e.g., `"view-button"`). * `orbit`: (String, Optional) If provided, clicking this hotspot will internally update the model viewer's camera orbit to this value (e.g., `"45deg 60deg 2m"`). Used for [Camera Views via Hotspots](#camera-views-via-hotspots). * `target`: (String, Optional) If provided along with `orbit`, clicking this hotspot updates the camera target (e.g., `"0m 1m 0m"`). * `fov`: (String, Optional) If provided along with `orbit`/`target`, clicking updates the field of view (e.g., `"30deg"`). Defaults to `45deg` for camera view hotspots if not specified. --- ### Client-Side Scripting For interactions beyond the built-in capabilities (like dynamic dimension drawing or complex hotspot logic), leverage Dash's `clientside_callback` mechanism. 1. Create a JavaScript file in your `assets` folder (e.g., `assets/model_viewer_clientside.js`). 2. Define functions within a namespace (e.g., `window.dash_clientside.clientside.modelViewer`). 3. Use `dash.clientside_callback` and `dash.ClientsideFunction` in Python to trigger these JS functions based on Dash Inputs/States. 4. Your JavaScript function can access the `model-viewer` DOM element using its `id` and interact with its powerful [JavaScript API](https://modelviewer.dev/docs/index.html#javascript-api). Refer to the "Advanced" examples (`dynamic_dimensions_example.py`, `dynamic_hotspots_example.py`) and their corresponding `usage_*.py` files in the GitHub repository for implementation patterns. --- ### Styling Style the component and its hotspots using CSS in your `assets` folder. * Target the viewer container: `#your-viewer-id { border: 1px solid blue; }` * Target all hotspots: `.hotspot { background-color: rgba(0, 0, 0, 0.5); color: white; padding: 4px 8px; border-radius: 4px; }` * Target specific hotspots: `.hotspot[slot='hotspot-visor'] { background-color: red; }` * Target custom hotspot classes: `.view-button { cursor: pointer; border: 1px solid white; }` * Style the AR button: `button[slot='ar-button'] { background-color: purple; color: white; }` See the CSS files associated with the usage examples in the GitHub repo for more detailed styling examples. --- ### Acknowledgements * This component is built upon Google's [`model-viewer` web component](https://modelviewer.dev/). * Developed using the [Plotly Dash](https://dash.plotly.com/) framework. --- *Source: /pip/dash_model_viewer* *Generated with dash-improve-my-llms*