Clirio Cloud
A web-based collection of tools for managing workspaces in Clirio View, plus a lightweight scan-sharing webviewer. Includes dashboard, workspace management, interactive Bing maps, and embeddable 3D scan shares.

Overview
Clirio Cloud is a collection of tools that have been growing over time. These tools are provided to users of the Clirio Suite on a website developed with Blazor. Clirio builds solutions that help users inspect, document, and collaborate in 3D and XR on geospatial workspaces.
One of the main goals of Clirio Cloud is to complement the upload and organizational capabilities of the iOS Clirio apps, which serve as the primary capture platform. While a user can easily capture and upload 3D photogrammetry scans with Clirio's iOS apps, they might also want to upload other 3D models, for which Clirio Cloud provides an interface. Other workspace tools are upload and management of custom basemaps, upload of boreholes, guest invitations, point of interest creation, and workspace deletion.
Furthermore, Clirio Cloud provides an analytics dashboard to users, as well as an easy way to access the Clirio knowledge base and training videos. Finally, there also are subscription and account management tools.
Beyond workspace management, the same Blazor and Three.js stack powers Scan Share: a lightweight webviewer and guest links that let users share photogrammetry scans captured in Clirio View. Share options in the Unity app configure expiry, password protection, workspace invitations, and embeddable iframes; recipients open the scan in the browser without installing an app.




Development
The website was initially set up by Timothy Thibault using Blazor, to try using a web framework that has C# as its programming language, which would be an advantage considering we also used C# for Unity development. After setting up authentication, subscription management, and workspace listing with metadata, development was mostly handed off to me.
After adding all of the workspace management features such as 3D model upload and user invitations, I started working on another set of stories revolving around the dashboard. I partnered up with Jordan Wischmann, who designed mockups, which I then implemented.
Learning


Code Sample
An example of data binding between the form and data model for the Create POI page.
<!-- CODE SNIPPET, NOT THE FULL FILE --><EditForm EditContext="@EC" class="w-100 flex flex-column align-items-start" OnSubmit="@HandleCreatePoiSubmit"> <DataAnnotationsValidator /> <div class="font-bold text-xl mt-3 mb-6">POI Details</div> <div class="form-group mb-3 w-100"> <label for="poiCreateDisplayName">Name:</label> <InputTextOnInput id="poiCreateDisplayName" name="poiCreateDisplayName" @bind-Value="poi.DisplayName" class="form-control" /> <ValidationMessage For="() => poi.DisplayName" /> </div> <div class="form-group mb-3 w-100"> <label for="poiCreateDescription">Description:</label> <InputTextArea id="poiCreateDescription" name="poiCreateDescription" @bind-Value="poi.Description" class="form-control" /> </div> <div class="flex flex-col w-100 lg:flex-row lg:gap-4"> <div class="form-group mb-3 w-100"> <label for="poiCreateLocationLatitude">Latitude:</label> <InputNumber id="poiCreateLocationLatitude" name="poiCreateLocationLatitude" @bind-Value="poi.Latitude" class="form-control" step="any" /> <ValidationMessage For="() => poi.Latitude" /> </div> <div class="form-group mb-3 w-100"> <label for="poiCreateLocationLongitude">Longitude:</label> <InputNumber id="poiCreateLocationLongitude" name="poiCreateLocationLongitude" @bind-Value="poi.Longitude" class="form-control" step="any" /> <ValidationMessage For="() => poi.Longitude" /> </div> <div class="form-group mb-3 w-100"> <label for="poiCreateLocationAltitude">Altitude:</label> <div class="input-group"> <InputNumber id="poiCreateLocationAltitude" name="poiCreateLocationAltitude" @bind-Value="poi.Altitude" class="form-control" step="any" /> <div class="input-group-append"> <span class="input-group-text">m</span> </div> </div> <ValidationMessage For="() => poi.Altitude" /> </div> </div> <!-- Map + Submit / Cancel btns --> <div id="map" style="@(!bingMapLoaded ? "height:0px;width:100%;position:relative;" : "height:500px;width:100%;position:relative;")" class="cursor-pointer mb-2"> </div> <div class="text-gray-500">Click on map to place cursor in your desired location or enter coordinates manually, including altitude (in meters). Click and hold to drag map. To switch map views, select icon in top right corner of map.</div> <div class="flex gap-4 mt-2"> <button @onclick="@(e => CancelButtonClicked())" class="btn text-white bg-red-500 hover:bg-red-600"> Cancel </button> <button type="submit" id="submitButton" class="@(EC.Validate() ? "btn text-white bg-clirio-500 hover:bg-clirio-600" : "btn text-white bg-gray-500 hover:bg-gray-600"))"> Create POI </button> </div></EditForm>@code { // SOME CODE OMITTED // [Parameter] public string? workspaceId { get; set; } [Parameter] public string? workspaceName { get; set; } private EditContext EC { get; set; } private static POICreate poi = new(); private HttpClient? workspaceClient; private bool processingRequest = false; protected override async Task OnInitializedAsync() { EC = new EditContext(poi); poi.WorkspaceId = workspaceId; poi.POIDateTime = DateTime.UtcNow; await base.OnInitializedAsync(); workspaceClient = HttpClientFactory.CreateClient("WorkspaceApi"); workspaceClient.DefaultRequestHeaders.CacheControl = new CacheControlHeaderValue { NoCache = true }; LoadWorkspace(); } private async Task CreatePoi() { try { processingRequest = true; Console.WriteLine($"CreatePoi()"); string serialized = JsonConvert.SerializeObject(poi); await workspaceClient.PostAsJsonAsync<POICreate>("POI", poi); processingRequest = false; CancelButtonClicked(); } catch (Exception ex) { Console.WriteLine(ex); processingRequest = false; StateHasChanged(); return; } }}namespace Sitescan.Models{ public class POICreate { [Required] public string WorkspaceId { get; set; } [Required(ErrorMessage = "Name is required.")] [MinLength(1)] public string DisplayName { get; set; } public string Description { get; set; } [Required(ErrorMessage = "Altitude is required.")] public double? Altitude { get; set; } [Required(ErrorMessage = "Latitude is required.")] [Range(-90, 90, ErrorMessage = "Latitude is required, and must be between -90 and 90.")] public double? Latitude { get; set; } [Required(ErrorMessage = "Longitude is required.")] [Range(-180, 180, ErrorMessage = "Longitude is required, and must be between -180 and 180.")] public double? Longitude { get; set; } [Required] public DateTime POIDateTime { get; set; } }}Scan Share
Scan Share lets users quickly share a photogrammetry scan captured with the Clirio Scan app. The feature combines share options in the Clirio View app (Unity) with a webviewer (Blazor and Three.js) where shared scans can be viewed. Pressing the share button in Clirio View opens a pop-up to configure the share: expiry, password protection, optional requirement that the viewer was invited to the workspace (Clirio login), copy-to-clipboard or native share sheets, and generation of a formatted embed iframe.
The webviewer is intended to be as lightweight and accessible as possible. The scan can be viewed from different angles, metadata and a scale legend are displayed, and controls hide or show elements.
Share UI and webviewer
The client-side share UI was developed with C# in Unity as a pop-up from the observation details of any scan; UI was designed in Figma with Jordan Wischmann. The webviewer and Three.js front end were my focus; backend token sharing was implemented by Timothy Thibault.



Share UI iterations
The share UI has gone through multiple iterations. Initially developed for MRTK2 with few options, the feature was later expanded to include expiry, password protection, and embed functionalities. In 2023, the UI was refreshed in collaboration with Jordan Wischmann as the Clirio View app migrated to MRTK3.
The backend API for link generation was implemented by Timothy Thibault. The returned result includes a URL for the client to display to the user and to include in native share options for each platform.
The biggest challenge for me was designing the UX, as there were many options for the user—including private shares restricted to workspace teams, which required an additional authentication flow in the webviewer. I used dynamic layouts so hidden options such as expiry renewal or password change could be shown or hidden cleanly. After every user interaction, the UI refreshes to match the share configuration; if a share matches the configuration, that link is shown, otherwise the generate share button appears.
An approach I first tested on this feature, after a discussion with Tonio Stillman, was to set all event listeners in the controller itself, keep listener functions private, and assign each button once in the inspector—reducing setup errors compared to wiring many OnClick() handlers in the editor.
Three.js webviewer
Implementing the webviewer was the more complex story, as it was standalone and most functionality needed to be created from scratch. The viewer uses Blazor, Three.js, and Tailwind CSS. Challenges included loading texture files from MTL with correct SAS tokens, supporting varied MTL formats and material properties, and keeping the viewer dynamic across model sizes and devices.
Code Sample
Scan Share webviewer setup (Three.js).
// CODE SNIPPET, NOT THE FULL FILE ////______________________________________________________________________ Loadfunction loadModel(OBJFile, MTLFile, SasUrls) { //Load with MTL + Textures //Note: The MTLLoader has been customized so that it can have a SAS token specified, // and that it converts the filenames in the MTL file to lowercase(otherwise Azure Storage URL will be incorrect) var mtlLoader = new MTLLoader(); mtlLoader.setSasUrls(SasUrls); mtlLoader.load(MTLFile, function (materials) { materials.preload(); new OBJLoader() .setMaterials(materials) .load(OBJFile, function (object) { object.traverse(function (child) { // aka setTexture if (child instanceof THREE.Mesh) { child.material.side = THREE.DoubleSide; //The default color space is sRGB, we need Linear (will be too dark otherwise) //see https://threejs.org/docs/#manual/en/introduction/Color-management if (child.material.map) child.material.map.encoding = THREE.LinearEncoding; } }); object.rotation.y = Math.PI; //rotate 180 degrees to match orientation in Clirio View //Calculate y center pos, raise model and recenter camera on model var boundingBox = new THREE.Box3().setFromObject(object); size = new THREE.Vector3(); boundingBox.getSize(size); var center = new THREE.Vector3(); boundingBox.getCenter(center); var centerPos = object.localToWorld(center); objYCenterPos = size.y / 2 - centerPos.y + gapAboveGrid; longestSide = Math.max(size.x, size.y, size.z); object.position.set(centerPos.x, objYCenterPos, centerPos.z); //for large objects, update view + grid configureGridBasedOnModelSize(); resetView(); //add to scene, add scale legend, remove spinner scene.add(object); addScaleLegend(); scanSpinner.classList.add('hidden'); }, function (item) { //console.log("Load Progress: " + item.loaded + "/" + item.total); }, function (error) { console.error(error); }); }); animate();}