Clirio Cloud
Eine webbasierte Sammlung von Tools zur Verwaltung von Arbeitsbereichen in Clirio View plus ein leichtgewichtiger Webviewer zum Teilen von Scans. Enthält Dashboard, Workspace-Verwaltung, interaktive Bing-Karten und einbettbare 3D-Scan-Freigaben.

Übersicht
Clirio Cloud ist eine Sammlung von Tools, die im Laufe der Zeit gewachsen ist. Diese Tools werden den Nutzern der Clirio Suite auf einer mit Blazor entwickelten Website zur Verfügung gestellt. Clirio entwickelt Lösungen, die den Nutzern helfen, geodätische Arbeitsbereiche in 3D und XR zu inspizieren, zu dokumentieren und zusammenzuarbeiten.
Eines der Hauptziele von Clirio Cloud ist es, die Upload- und Organisationsfunktionen der iOS-Apps von Clirio zu ergänzen, die als primäre Erfassungsplattform dienen. Während ein Benutzer mit den iOS-Apps von Clirio problemlos 3D-Photogrammetriescans erfassen und hochladen kann, möchte er möglicherweise auch andere 3D-Modelle hochladen, für die Clirio Cloud eine Schnittstelle bereitstellt. Weitere Werkzeuge für den Arbeitsbereich sind das Hochladen und Verwalten von benutzerdefinierten Basiskarten, das Hochladen von Bohrlöchern, das Einladen von Gästen, das Erstellen von Points of Interest und das Löschen von Arbeitsbereichen.
Darüber hinaus bietet Clirio Cloud den Nutzern ein Analyse-Dashboard sowie eine einfache Möglichkeit, auf die Clirio-Wissensdatenbank und Schulungsvideos zuzugreifen. Schließlich gibt es auch Tools zur Abonnement- und Kontoverwaltung.
Über die Workspace-Verwaltung hinaus nutzt derselbe Blazor- und Three.js-Stack Scan Share: einen leichtgewichtigen Webviewer und Gast-Links zum Teilen von Photogrammetrie-Scans aus Clirio View. Freigabeoptionen in der Unity-App konfigurieren Ablaufzeit, Passwortschutz, Workspace-Einladungen und einbettbare Iframes; Empfänger öffnen den Scan im Browser ohne App-Installation.




Entwicklung
Die Website wurde ursprünglich eingerichtet von Timothy Thibault unter der verwendung von Blazor, um ein Web-Framework mit C# als Programmiersprache auszuprobieren, was ein Vorteil wäre, wenn man bedenkt, dass wir C# auch für die Unity-Entwicklung verwenden. Nach der Einrichtung der Authentifizierung, der Abonnementverwaltung und der Auflistung der Arbeitsbereiche mit Metadaten wurde die Entwicklung größtenteils an mich übergeben.
Nachdem ich alle Funktionen zur Verwaltung des Arbeitsbereichs, wie z. B. den Upload von 3D-Modellen und Benutzereinladungen, hinzugefügt hatte, begann ich mit der Arbeit an einer weiteren Reihe von Geschichten, die sich um das Dashboard drehten. Ich habe mich gepartnert mitJordan Wischmann, der Mockups entwarf, die ich dann umsetzte.
Lehren


Code Beispiel
Ein Beispiel für die Datenbindung zwischen dem Formular und dem Datenmodell für die POI Erstellen Seite.
<!-- 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
Mit Scan Share können Nutzer einen mit der Clirio Scan-App erfassten Photogrammetrie-Scan schnell teilen. Die Funktion verbindet Freigabeoptionen in der Clirio View-App (Unity) mit einem Webviewer (Blazor und Three.js). Die Freigabe-Schaltfläche in Clirio View öffnet ein Pop-up zur Konfiguration: Ablaufzeit, Passwortschutz, optional die Anforderung, dass der Betrachter in den Workspace eingeladen wurde (Clirio-Login), Kopieren in die Zwischenablage oder native Freigabedialoge sowie die Erzeugung eines formatierten Embed-Iframes.
Der Webviewer soll so leichtgewichtig und zugänglich wie möglich sein. Der Scan kann aus verschiedenen Blickwinkeln betrachtet werden; Metadaten und eine Maßstabslegende werden angezeigt, und Schaltflächen erlauben das Ein- und Ausblenden von Elementen.
Share-UI und Webviewer
Die clientseitige Share-UI wurde mit C# in Unity als Pop-up aus den Beobachtungsdetails eines Scans entwickelt; die UI entstand in Figma mit Jordan Wischmann. Webviewer und Three.js-Frontend waren mein Schwerpunkt; das Backend-Token-Sharing implementierte Timothy Thibault.



Iterationen der Share-UI
Die Share-UI durchlief mehrere Iterationen. Ursprünglich für MRTK2 mit wenigen Optionen entwickelt, wurde die Funktion später um Ablaufzeit, Passwortschutz und Embed-Funktionen erweitert. 2023 wurde die UI in Zusammenarbeit mit Jordan Wischmann überarbeitet, als die Clirio View-App auf MRTK3 migriert wurde.
Die Backend-API zur Link-Generierung implementierte Timothy Thibault. Das zurückgegebene Ergebnis enthält eine URL, die der Client dem Nutzer anzeigen und in die nativen Freigabeoptionen jeder Plattform einbinden kann.
Die größte Herausforderung war die UX-Gestaltung, da es viele Optionen für den Nutzer gab – einschließlich privater Freigaben auf Workspace-Teams, was einen zusätzlichen Authentifizierungsfluss im Webviewer erforderte. Dynamische Layouts blendeten Optionen wie Ablaufverlängerung oder Passwortänderung elegant ein oder aus. Nach jeder Nutzerinteraktion aktualisiert sich die UI entsprechend der Freigabekonfiguration; gibt es eine passende bestehende Freigabe, wird der Link angezeigt, andernfalls die Schaltfläche zum Erzeugen einer neuen Freigabe.
Eine Herangehensweise, die ich erstmals in diesem Feature testete – nach einer Diskussion mit Tonio Stillman – war, alle Event-Listener im Controller zu setzen, Listener-Funktionen privat zu halten und jede Schaltfläche nur einmal im Inspector zuzuweisen, statt viele OnClick()-Handler im Editor zu verdrahten.
Three.js-Webviewer
Die Implementierung des Webviewers war die komplexere Aufgabe, da er eigenständig ist und die meiste Funktionalität von Grund auf neu entstehen musste. Der Viewer nutzt Blazor, Three.js und Tailwind CSS. Herausforderungen waren das Laden von Texturdateien aus MTL mit korrekten SAS-Tokens, verschiedene MTL-Formate und Materialeigenschaften sowie ein dynamischer Viewer für unterschiedliche Modellgrößen und Geräte.
Code-Beispiel
Scan-Share-Webviewer (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();}