Compare commits

..

38 Commits

Author SHA1 Message Date
oguzhankoral b9ff8ee5f7 sanitize tag
build_powerbi / build-connector (push) Has been cancelled
build_powerbi / build-visual (push) Has been cancelled
build_powerbi / deploy-installers (push) Has been cancelled
2025-05-28 14:49:11 +03:00
oguzhankoral 30c2a2002c correct the version with assembly for file version 2025-05-28 14:39:14 +03:00
oguzhankoral 4502a48098 file version 2025-05-28 14:27:43 +03:00
oguzhankoral f2ab186bd8 fix path again 2025-05-28 14:08:43 +03:00
oguzhankoral fbe9672f81 fix path 2025-05-28 14:06:25 +03:00
oguzhankoral 2691902533 test 2025-05-28 14:03:04 +03:00
oguzhankoral 95294c6e6f Enrich receive info from desktop service 2025-05-28 14:02:32 +03:00
Dogukan Karatas e70aa8ae4b feat (data): sending version and branding info (#157)
* get version

* adds workspace info

* adds hideBranding

* adds workspace info

---------

Co-authored-by: Oğuzhan Koral <45078678+oguzhankoral@users.noreply.github.com>
2025-05-27 22:12:20 +03:00
oguzhankoral e65bf83995 Disable camera position persistence for performance reasons 2025-05-27 21:58:50 +03:00
oguzhankoral 74ade84015 Disable shadow catcher 2025-05-27 06:52:09 +03:00
oguzhankoral abc4bf11fe Remove ghost hidden context from color card 2025-05-27 06:41:55 +03:00
oguzhankoral 0bd1218d49 Bring conditional formatting back 2025-05-27 06:40:07 +03:00
oguzhankoral e277ce686e Ghost hidden on filter 2025-05-26 03:43:04 +03:00
oguzhankoral f3f5eddb7b Fix reset filter 2025-05-26 02:53:00 +03:00
oguzhankoral 28ce6b6a76 Reset filters 2025-05-26 02:46:52 +03:00
oguzhankoral fe483b7b2b Fix tooltip fckp 2025-05-26 01:23:45 +03:00
oguzhankoral 72b4b9b589 Fix selection issues 2025-05-26 00:46:32 +03:00
oguzhankoral 57c0f198bd Revert isolating every setDataInput 2025-05-25 18:47:44 +03:00
oguzhankoral 439bf56f47 capabilities for object ids 2025-05-25 11:48:57 +03:00
oguzhankoral 3ecae7f493 fix typo on conditions 2025-05-25 00:21:26 +03:00
Jonathon Broughton 843174f5b6 Update README.md (#147)
* Update README.md

* Update README.md
2025-05-25 00:21:26 +03:00
oguzhankoral 83cfa39be0 Fix initial isolate issue 2025-05-24 21:48:26 +03:00
oguzhankoral e4401da357 fix view mode cache 2025-05-24 20:55:39 +03:00
oguzhankoral 222a6f8987 Sort performance logging 2025-05-24 19:50:43 +03:00
oguzhankoral c44b54616e Remove console log 2025-05-24 15:50:50 +03:00
oguzhankoral 7b22e929e0 Fix saved objects 2025-05-24 15:49:13 +03:00
oguzhankoral e1e6d4e640 Remove console log 2025-05-24 15:30:30 +03:00
oguzhankoral 7bdb80f801 Toggle projection/orthi 2025-05-24 05:59:33 +03:00
oguzhankoral da774b631a not a css master commit 2025-05-24 05:42:13 +03:00
oguzhankoral ca0765c862 Hide viewer actions 2025-05-24 05:32:41 +03:00
oguzhankoral 87b64b7a11 Revamp viewer actions 2025-05-24 05:19:34 +03:00
oguzhankoral 41c4e642fe Navbar and cursors 2025-05-24 02:02:35 +03:00
oguzhankoral 76febcfce6 Delete unused code 2025-05-24 01:14:14 +03:00
oguzhankoral 8fbb5a3c9d Fix messaging on interactivity for tooltip data 2025-05-24 01:07:52 +03:00
oguzhankoral eecde37f8b delete logging 2025-05-24 00:27:45 +03:00
oguzhankoral a21a960516 Fix coloring 2025-05-24 00:27:45 +03:00
oguzhankoral 9e76f62ad0 No need tooltip data as part of interactivity 2025-05-24 00:27:45 +03:00
oguzhankoral ab266fa410 delete debugger 2025-05-24 00:27:45 +03:00
44 changed files with 741 additions and 2902 deletions
+3
View File
@@ -0,0 +1,3 @@
for /f "tokens=1 delims=-" %%i in ("%CIRCLE_TAG%") do set "TAG=%%i.%WORKFLOW_NUM%"
for /f "tokens=1 delims=/" %%j in ("%CIRCLE_TAG%") do set "SEMVER=%%j"
tools\InnoSetup\ISCC.exe tools\powerbi.iss /Sbyparam=$p /DINFO_VERSION=%TAG% /DVERSION=%SEMVER% %*
+166 -11
View File
@@ -1,16 +1,171 @@
# Use the latest 2.1 version of CircleCI pipeline process engine.
# See: https://circleci.com/docs/2.0/configuration-reference
version: 2.1
# Define the jobs we want to run for this project
jobs:
build:
docker:
- image: cimg/base:2023.03
steps:
- run: echo "so long and thanks for all the fish"
orbs:
win: circleci/windows@5.0
commands:
setup_digicert:
description: Set up Digicert Keylocker certificate for code-signing
steps:
- run:
name: "Digicert Signing Manager Setup"
command: |
cd C:\
curl.exe -X GET https://one.digicert.com/signingmanager/api-ui/v1/releases/smtools-windows-x64.msi/download -H "x-api-key:$env:SM_API_KEY" -o smtools-windows-x64.msi
msiexec.exe /i smtools-windows-x64.msi /quiet /qn | Wait-Process
- run:
name: Setup Digicert ONE Client Cert
command: |
cd C:\
echo $env:SM_CLIENT_CERT_FILE_B64 > certificate.txt
certutil -decode certificate.txt certificate.p12
- run:
name: Sync Certs
command: |
& $env:SSM\smksp_cert_sync.exe
jobs:
build-visual:
docker:
- image: cimg/node:18.20.3
steps:
- checkout
- run: node --version
- run:
name: "npm install"
command: "npm i"
working_directory: src/powerbi-visual
- run:
name: Set version
command: |
npm version ${CIRCLE_TAG:-2.0.0} --allow-same-version
working_directory: src/powerbi-visual
- run:
name: "npm run build"
command: "npm run build"
working_directory: src/powerbi-visual
- store_artifacts:
path: dist/*.pbiviz
- persist_to_workspace:
root: ./
paths:
- src/powerbi-visual/dist/*.pbiviz
build-connector:
executor:
name: win/default
shell: powershell.exe
environment:
SSM: 'C:\Program Files\DigiCert\DigiCert One Signing Manager Tools'
steps:
- checkout
- run:
name: "Set connector internal version"
command: |
$env:VERSION = if([string]::IsNullOrEmpty($env:CIRCLE_TAG)) { "2.0.0" } else { $env:CIRCLE_TAG }
(Get-Content ./Speckle.pq).replace('[Version = "2.0.0"]', '[Version = "'+$($env:VERSION)+'"]') | Set-Content ./Speckle.pq
working_directory: src/powerbi-data-connector
- run:
name: "Build Data Connector"
command: "msbuild Speckle.proj /restore /consoleloggerparameters:NoSummary /property:GenerateFullPaths=true"
working_directory: src/powerbi-data-connector
- run:
name: Create PQX file
command: .\tools\MakePQX\MakePQX.exe pack -mz src/powerbi-data-connector/bin/Speckle.mez -t src/powerbi-data-connector/bin/Speckle.pqx
- persist_to_workspace:
root: ./
paths:
- src/powerbi-data-connector/bin/Speckle.pqx
build-installer:
executor:
name: win/default
shell: powershell.exe
environment:
SSM: 'C:\Program Files\DigiCert\DigiCert One Signing Manager Tools'
steps:
- checkout
- attach_workspace:
at: ./
- unless: # Build installers unsigned on non-tagged builds
condition: << pipeline.git.tag >>
steps:
- run:
name: Build Installer
shell: cmd.exe #does not work in powershell
environment:
WORKFLOW_NUM: << pipeline.number >>
CIRCLE_TAG: 2.0.0
command: .circleci\build-installer.bat
- when: # Setup certificates and build installers signed for tagged builds
condition: << pipeline.git.tag >>
steps:
- setup_digicert
- run:
name: Build Installer
shell: cmd.exe #does not work in powershell
environment:
WORKFLOW_NUM: << pipeline.number >>
command: .circleci\build-installer.bat /DSIGN_INSTALLER /DCODE_SIGNING_CERT_FINGERPRINT=%SM_CODE_SIGNING_CERT_SHA1_HASH%
- store_artifacts:
path: ./installer
- persist_to_workspace:
root: ./
paths:
- installer/*.exe
deploy-connector-to-feed:
docker:
- image: mcr.microsoft.com/dotnet/sdk:6.0
steps:
- attach_workspace:
at: ./
- run:
name: Install Manager Feed CLI
command: dotnet tool install --global Speckle.Manager.Feed
- run:
name: Upload new version
command: |
TAG=$(if [ "${CIRCLE_TAG}" ]; then echo $CIRCLE_TAG; else echo "2.0.0"; fi;)
SEMVER=$(echo "$TAG" | sed -e 's/\/[a-zA-Z-]*//')
VER=$(echo "$SEMVER" | sed -e 's/-.*//')
VERSION=$(echo $VER.$WORKFLOW_NUM)
/root/.dotnet/tools/Speckle.Manager.Feed deploy -s powerbi -v ${SEMVER} -u https://releases.speckle.dev/installers/powerbi/powerbi-${SEMVER}.exe -o Win -a Any -f ./installer/powerbi-${SEMVER}.exe
environment:
WORKFLOW_NUM: << pipeline.number >>
# Orchestrate our job run sequence
workflows:
build_and_test:
when: false
build:
jobs:
- build
- build-connector:
context: digicert-keylocker
- build-visual
- build-installer:
context: digicert-keylocker
requires:
- build-connector
- build-visual
deploy:
jobs:
- build-connector:
filters: &deploy_filter
branches:
ignore: /.*/
tags:
only: /^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-\w{1,10})?$/
context: digicert-keylocker
- build-visual:
filters: *deploy_filter
- build-installer:
filters: *deploy_filter
context: digicert-keylocker
requires:
- build-connector
- build-visual
- deploy-connector-to-feed:
filters: *deploy_filter
requires:
- build-installer
context: do-spaces-speckle-releases
@@ -1,4 +1,4 @@
name: Build and deploy Connector and Visual
name: build_powerbi
on:
push:
branches: ["installer-test/**"]
@@ -8,39 +8,32 @@ jobs:
runs-on: windows-latest
outputs:
semver: ${{ steps.set-version.outputs.semver }}
file-version: ${{ steps.set-version.outputs.file-version }}
file-version: ${{ steps.set-info-version.outputs.file-version }}
env:
CertFile: "./speckle.pfx"
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python 3.10
uses: actions/setup-python@v3
with:
python-version: "3.10"
- id: set-version
name: Set version to output
shell: bash
run: |
TAG=${{ github.ref_name }}
if [[ "${{ github.ref }}" != refs/tags/* ]]; then
TAG="v3.0.99.${{ github.run_number }}"
fi
SEMVER="${TAG#v}"
FILE_VERSION=$(echo "$TAG" | sed -E 's/^v([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
FILE_VERSION="$FILE_VERSION.${{ github.run_number }}"
- name: Install GitVersion
uses: gittools/actions/gitversion/setup@v3.0.0
with:
versionSpec: 6.0.5 # github actions doesnt like 6.1.0 onwards https://github.com/GitTools/actions/blob/main/docs/versions.md
echo "semver=$SEMVER" >> "$GITHUB_OUTPUT"
echo "file-version=$FILE_VERSION" >> "$GITHUB_OUTPUT"
echo $SEMVER
echo $FILE_VERSION
- name: Determine Version
id: gitversion
uses: gittools/actions/gitversion/execute@v3.0.0
- name: Set connector version
run: |
python patch_version.py ${{steps.set-version.outputs.file-version}}
python patch_version.py ${{steps.gitversion.outputs.AssemblySemVer}}
- name: Setup MSBuild
uses: microsoft/setup-msbuild@v2
@@ -67,35 +60,37 @@ jobs:
if-no-files-found: error
retention-days: 1
- id: set-version
name: Set version to output
run: echo "semver=${{steps.gitversion.outputs.semVer}}" >> "$GITHUB_OUTPUT"
shell: bash
- id: set-info-version
name: Set version to output
run: echo "file-version=${{steps.gitversion.outputs.AssemblySemVer}}" >> "$GITHUB_OUTPUT"
shell: bash
build-visual:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
- id: set-version
name: Set version to output
shell: bash
run: |
TAG=${{ github.ref_name }}
if [[ "${{ github.ref }}" != refs/tags/* ]]; then
TAG="v3.0.99.${{ github.run_number }}"
fi
SEMVER="${TAG#v}"
FILE_VERSION=$(echo "$TAG" | sed -E 's/^v([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
FILE_VERSION="$FILE_VERSION.${{ github.run_number }}"
- name: Install GitVersion
uses: gittools/actions/gitversion/setup@v3.0.0
with:
versionSpec: 6.0.5 # github actions doesnt like 6.1.0 onwards https://github.com/GitTools/actions/blob/main/docs/versions.md
echo "semver=$SEMVER" >> "$GITHUB_OUTPUT"
echo "file-version=$FILE_VERSION" >> "$GITHUB_OUTPUT"
echo $SEMVER
echo $FILE_VERSION
- name: Determine Version
id: gitversion
uses: gittools/actions/gitversion/execute@v3.0.0
- run: npm ci
working-directory: src/powerbi-visual
- run: npm version ${{steps.set-version.outputs.semver}} --allow-same-version
- run: npm version ${{steps.gitversion.outputs.semVer}} --allow-same-version
working-directory: src/powerbi-visual
- run: npm run build
working-directory: src/powerbi-visual
-30
View File
@@ -1,30 +0,0 @@
name: Test Build Connector and Visual
on: pull_request
jobs:
build-connector:
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup MSBuild
uses: microsoft/setup-msbuild@v2
- name: Build Data Connector
working-directory: src/powerbi-data-connector
run: |
msbuild Speckle.proj /restore /consoleloggerparameters:NoSummary /property:GenerateFullPaths=true
build-visual:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
working-directory: src/powerbi-visual
- run: npm run build
working-directory: src/powerbi-visual
+11
View File
@@ -0,0 +1,11 @@
workflow: GitFlow/v1
next-version: 3.0.0
mode: ManualDeployment
branches:
main:
label: rc
develop:
regex: ^dev$
label: beta
unknown:
increment: None
+26 -60
View File
@@ -3,26 +3,18 @@
Speckle | Power BI
</h1>
<p align="center"><a href="https://twitter.com/SpeckleSystems"><img src="https://img.shields.io/twitter/follow/SpeckleSystems?style=social" alt="Twitter Follow"></a> <a href="https://speckle.community"><img src="https://img.shields.io/discourse/users?server=https%3A%2F%2Fspeckle.community&amp;style=flat-square&amp;logo=discourse&amp;logoColor=white" alt="Community forum users"></a> <a href="https://speckle.systems"><img src="https://img.shields.io/badge/https://-speckle.systems-royalblue?style=flat-square" alt="website"></a> <a href="https://docs.speckle.systems/"><img src="https://img.shields.io/badge/docs-speckle.systems-orange?style=flat-square&amp;logo=read-the-docs&amp;logoColor=white" alt="docs"></a></p>
<p align="center"><a href="https://twitter.com/SpeckleSystems"><img src="https://img.shields.io/twitter/follow/SpeckleSystems?style=social" alt="Twitter Follow"></a> <a href="https://speckle.community"><img src="https://img.shields.io/discourse/users?server=https%3A%2F%2Fspeckle.community&amp;style=flat-square&amp;logo=discourse&amp;logoColor=white" alt="Community forum users"></a> <a href="https://speckle.systems"><img src="https://img.shields.io/badge/https://-speckle.systems-royalblue?style=flat-square" alt="website"></a> <a href="https://speckle.guide/dev/"><img src="https://img.shields.io/badge/docs-speckle.guide-orange?style=flat-square&amp;logo=read-the-docs&amp;logoColor=white" alt="docs"></a></p>
> Speckle is the first AEC data hub that connects with your favorite AEC tools. Speckle exists to overcome the challenges of working in a fragmented industry where communication, creative workflows, and the exchange of data are often hindered by siloed software and processes. It is here to make the industry better.
<h3 align="center">
Speckle Connector and 3D Visual for Power BI
Speckle Connector and 3D Viewer Visual for Power BI
</h3>
## Features
# Features
Speckle Power BI Data Connector lets you easily get data from Speckle into Power BI reports and visualizations. You can access and analyze data from various AEC apps (like Revit, Archicad, Grasshopper, and more) and open-source files (IFC, STL, OBJ, etc.) into Power BI with ease.
<p align="center">
<div align="center">
<a href="https://app.speckle.systems/connectors/">
Download Power BI Connector
</a>
</div>
</p>
Speckles connection to Power BI consists of two parts:
- **Data Connector** fetches the data you uploaded from AEC apps to Speckle.
@@ -30,19 +22,19 @@ Speckles connection to Power BI consists of two parts:
![Desktop - 1 (1)](https://github.com/specklesystems/speckle-powerbi/assets/51519350/6d2c5224-965f-4eae-b869-be26cb48c6b2)
## Repository Structure
# Repo Structure
This repository is home to our Power BI connector. The Speckle Server provides all the web-facing functionality and can be found [here](https://github.com/specklesystems/Server).
This repo is home to our Power BI connector. The Speckle Server provides all the web-facing functionality and can be found [here](https://github.com/specklesystems/Server).
`src/powerbi-data-connector` contains all the code for the Data connector.
`src/powerbi-visual` contains all the code for 3D Visual.
## Installation
# Installation
Power BI connector installer can be downloaded from the [connectors portal](https://app.speckle.systems/connectors/). Full instructions for [installation](https://docs.speckle.systems/connectors/power-bi#setup) and [configuration](https://docs.speckle.systems/connectors/power-bi#why-dont-i-see-speckle-as-a-data-source-in-power-bi) can be found on our docs.
Speckle connector can be installed directly from the [connectors portal](https://app.speckle.systems/connectors/). Full instructions for [installation](https://speckle.guide/user/powerbi/installation.html) and [configuration](https://speckle.guide/user/powerbi/configuration.html) can be found on our docs.
### 3D Visual
# Using 3D Visual
3D Visual can be imported as any other Power BI custom visual.
@@ -51,62 +43,34 @@ Power BI connector installer can be downloaded from the [connectors portal](http
3. Go to `Documents/Power BI Desktop/Custom Visuals` and import `Speckle 3D Visual.pbiviz` file.
4. Speckle cube will appear in the Visualization pane.
For more on how to use the visual, [check our docs](https://docs.speckle.systems/connectors/power-bi).
For more on how to use the visual, [check our docs](https://speckle.guide/user/powerbi-visual/introduction.html).
## Quick Start
# Usage
To get started with Power BI connector, please take a look at the [documentation](https://docs.speckle.systems/connectors/power-bi) and extensive [tutorials](https://www.youtube.com/@SpeckleSystems) published.
To get started with Power BI connectors, please take a look at the [documentation](https://speckle.guide/user/powerbi/introduction.html) and extensive [tutorials](https://www.youtube.com/playlist?list=PLlI5Dyt2HaEsZHG2WJ75WIM0Brx6VHT2S) published.
## Development Setup
# **Developing & Debugging**
### For local development of the 3D Visual
We encourage everyone interested to debug/hack/contribute/give feedback to this project.
1. **Clone the repository**:
```bash
git clone https://github.com/specklesystems/speckle-powerbi.git
cd speckle-powerbi
```
## **Setup**
2. **Navigate to the visual directory**:
```bash
cd src/powerbi-visual
```
### **Install PowerQuery SDK**
3. **Install dependencies**:
```bash
npm install
# or
yarn install
```
Follow the instructions from the [official docs](https://docs.microsoft.com/en-us/power-query/installingsdk)
4. **Start development server**:
```bash
npm run dev
```
### **Build with Visual Studio**
5. **Build the visual**:
```bash
# Development build
npm run build:dev
# Production build
npm run build
```
Every time you build the connector, VisualStudio will copy the latest `.mez` connector file to the appropriate location. Just restart PowerBI to see the latest changes.
### For local development of the Data Connector
### **Debug**
1. **Install PowerQuery SDK**:
Follow the instructions from the [official docs](https://docs.microsoft.com/en-us/power-query/installingsdk)
You can start the PowerQuery connector in VisualStudio, this will open a standalone connector you can use for testing purposes.
2. **Open the project in Visual Studio Code**:
- Open `src/powerbi-data-connector/Speckle.proj`
- Build the project to generate the `.mez` file
We don't know of a way to debug the connector live in PowerBI, but we'd be happy to hear about it.
3. **Testing the connector**:
- Visual Studio will automatically copy the `.mez` file to the appropriate location
- Restart Power BI Desktop to see the latest changes
## About Speckle
# About Speckle
What is Speckle? Check our ![YouTube Video Views](https://img.shields.io/youtube/views/B9humiSpHzM?label=Speckle%20in%201%20minute%20video&style=social)
@@ -127,10 +91,12 @@ What is Speckle? Check our ![YouTube Video Views](https://img.shields.io/youtube
Give Speckle a try in no time by:
- [![app.speckle.systems](https://img.shields.io/badge/https://-app.speckle.systems-0069ff?style=flat-square&logo=hackthebox&logoColor=white)](https://app.speckle.systems) ⇒ creating an account at our public server
- [![app.speckle.systems](https://img.shields.io/badge/https://-speckle.xyz-0069ff?style=flat-square&logo=hackthebox&logoColor=white)](https://app.speckle.systems) ⇒ creating an account at our public server
### Resources
- [![Community forum users](https://img.shields.io/badge/community-forum-green?style=for-the-badge&logo=discourse&logoColor=white)](https://speckle.community) for help, feature requests or just to hang with other speckle enthusiasts, check out our community forum!
- [![website](https://img.shields.io/badge/tutorials-speckle.systems-royalblue?style=for-the-badge&logo=youtube)](https://speckle.systems) our tutorials portal is full of resources to get you started using Speckle
- [![docs](https://img.shields.io/badge/docs-speckle.systems-orange?style=for-the-badge&logo=read-the-docs&logoColor=white)](https://docs.speckle.systems) reference on almost any end-user and developer functionality
- [![docs](https://img.shields.io/badge/docs-speckle.guide-orange?style=for-the-badge&logo=read-the-docs&logoColor=white)](https://speckle.guide/dev/) reference on almost any end-user and developer functionality
![Untitled](https://user-images.githubusercontent.com/2679513/132021739-15140299-624d-4410-98dc-b6ae6d9027ab.png)
+29 -171
View File
@@ -4,79 +4,6 @@ section Speckle;
AuthAppId = "spklpwerbi";
AuthAppSecret = "spklpwerbi";
// PKCE helper functions for enhanced OAuth2 security
Base64UrlEncode = (binaryData as binary) =>
let
// Convert binary to base64
base64 = Binary.ToText(binaryData, BinaryEncoding.Base64),
// Convert to base64url by replacing characters and removing padding
base64url = Text.Replace(Text.Replace(Text.Replace(base64, "+", "-"), "/", "_"), "=", "")
in
base64url;
GeneratePKCEVerifier = () =>
let
// Generate cryptographically secure random string using allowed characters
// RFC 7636: [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~"
allowedChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~",
// Generate multiple GUIDs to create entropy
guid1 = Text.Replace(Text.Replace(Text.NewGuid(), "-", ""), "{", ""),
guid2 = Text.Replace(Text.Replace(Text.NewGuid(), "-", ""), "}", ""),
guid3 = Text.Replace(Text.Replace(Text.NewGuid(), "-", ""), "{", ""),
guid4 = Text.Replace(Text.Replace(Text.NewGuid(), "-", ""), "}", ""),
// Combine and convert to allowed characters
combined = guid1 & guid2 & guid3 & guid4,
// Map hex characters to allowed PKCE characters
mapped = Text.Replace(
Text.Replace(
Text.Replace(
Text.Replace(
Text.Replace(
Text.Replace(combined, "0", "A"),
"1", "B"),
"2", "C"),
"3", "D"),
"4", "E"),
"5", "F"),
// Continue mapping remaining hex chars to allowed chars
verifier = Text.Replace(
Text.Replace(
Text.Replace(
Text.Replace(
Text.Replace(
Text.Replace(
Text.Replace(
Text.Replace(
Text.Replace(
Text.Replace(mapped, "6", "G"),
"7", "H"),
"8", "I"),
"9", "J"),
"a", "K"),
"b", "L"),
"c", "M"),
"d", "N"),
"e", "O"),
"f", "P"),
// Ensure length is between 43-128 characters as per RFC 7636
finalVerifier = Text.Start(verifier, 43)
in
finalVerifier;
GeneratePKCEChallenge = (verifier as text) =>
let
// Create SHA256 hash of the verifier as required by RFC 7636
hash = Crypto.CreateHash(CryptoAlgorithm.SHA256, Text.ToBinary(verifier, TextEncoding.Ascii)),
// Convert to base64url encoding
challenge = Base64UrlEncode(hash)
in
challenge;
// function to load `pqm` files - this is essential and must be kept
shared Speckle.LoadFunction = (fileName as text) =>
let
@@ -148,47 +75,6 @@ shared Speckle.GetWorkspace = Value.ReplaceType(
type function (url as Uri.Type) as record
);
shared Speckle.Objects.Properties = Value.ReplaceType(
Speckle.LoadFunction("Objects.Properties.pqm"),
type function (inputRecord as any, optional filterKeys as list, optional parentPath as text, optional existingFields as list) as record
);
shared Speckle.Utils.ExpandRecord = Value.ReplaceType(
Speckle.LoadFunction("Utils.ExpandRecord.pqm"),
type function (
table as table,
columnName as text,
optional FieldNames as list,
optional UseCombinedNames as logical
) as table
);
shared Speckle.Objects.Collections = Value.ReplaceType(
Speckle.LoadFunction("Objects.Collections.pqm"),
type function (inputData as table) as table
);
shared Speckle.Objects.CompositeStructure = Value.ReplaceType(
Speckle.LoadFunction("Objects.CompositeStructure.pqm"),
type function (objectRecord as record, optional outputAsList as nullable logical) as any
);
shared Speckle.Objects.MaterialQuantities = Value.ReplaceType(
Speckle.LoadFunction("Objects.MaterialQuantities.pqm"),
type function (objectRecord as record, optional outputAsList as logical) as any
);
shared Speckle.Models.Federate = Value.ReplaceType(
Speckle.LoadFunction("Models.Federate.pqm"),
type function (tables as list, optional excludeData as logical) as table
);
shared Speckle.Models.MaterialQuantities = Value.ReplaceType(
Speckle.LoadFunction("Models.MaterialQuantities.pqm"),
type function (inputTable as table, optional addPrefix as logical) as table
);
[DataSource.Kind = "Speckle", Publish="GetByUrl.Publish"]
shared Speckle.GetByUrl = Value.ReplaceType(
Speckle.LoadFunction("GetByUrl.pqm"),
@@ -247,7 +133,7 @@ GetByUrl.Icons = [
Speckle = [
// This is used when running the connector on an on-premises data gateway
TestConnection = (path) => {"Speckle.GetUser", path},
// Authentication strategy - OAuth only
// Authentication strategy
Authentication = [
OAuth = [
Label = "Speckle Account",
@@ -255,21 +141,14 @@ Speckle = [
let
server = Text.Combine(
{Uri.Parts(dataSourcePath)[Scheme], "://", Uri.Parts(dataSourcePath)[Host]}
),
// Generate PKCE parameters for enhanced security
codeVerifier = GeneratePKCEVerifier(),
codeChallenge = GeneratePKCEChallenge(codeVerifier),
// Build authorization URL with PKCE parameters
authUrl = Text.Combine({server, "authn", "verify", AuthAppId, state}, "/") &
"?code_challenge=" & codeChallenge &
"&code_challenge_method=S256"
)
in
[
LoginUri = authUrl,
LoginUri = Text.Combine({server, "authn", "verify", AuthAppId, state}, "/"),
CallbackUri = "https://oauth.powerbi.com/views/oauthredirect.html",
WindowHeight = 800,
WindowWidth = 600,
Context = [code_verifier = codeVerifier]
Context = null
],
FinishLogin = (clientApplication, dataSourcePath, context, callbackUri, state) =>
let
@@ -277,22 +156,20 @@ Speckle = [
{Uri.Parts(dataSourcePath)[Scheme], "://", Uri.Parts(dataSourcePath)[Host]}
),
Parts = Uri.Parts(callbackUri)[Query],
// Extract code verifier from context for PKCE
codeVerifier = if context <> null then context[code_verifier] else null,
// Build token request with PKCE parameters
tokenRequest = [
accessCode = Parts[access_code],
appId = AuthAppId,
appSecret = AuthAppSecret,
challenge = state
] & (if codeVerifier <> null then [code_verifier = codeVerifier] else []),
Source = Web.Contents(
Text.Combine({server, "auth", "token"}, "/"),
[
Headers = [
#"Content-Type" = "application/json"
],
Content = Json.FromValue(tokenRequest)
Content = Json.FromValue(
[
accessCode = Parts[access_code],
appId = AuthAppId,
appSecret = AuthAppSecret,
challenge = state
]
)
]
),
json = Json.Document(Source)
@@ -308,8 +185,7 @@ Speckle = [
server = Text.Combine(
{Uri.Parts(dataSourcePath)[Scheme], "://", Uri.Parts(dataSourcePath)[Host]}
),
// Enhanced refresh with error handling for gateway compatibility
Source = try Web.Contents(
Source = Web.Contents(
Text.Combine({server, "auth", "token"}, "/"),
[
Headers = [
@@ -321,42 +197,24 @@ Speckle = [
appId = AuthAppId,
appSecret = AuthAppSecret
]
),
ManualStatusHandling = {400, 401, 403, 500, 502, 503, 504}
]
) otherwise null,
// Check if request was successful
IsSuccess = Source <> null,
// If successful, parse the response
json = if IsSuccess then
try Json.Document(Source) otherwise null
else
null,
// Validate the response contains expected fields
IsValidResponse = json <> null and Record.HasFields(json, {"token"}),
// Return result with enhanced error handling
result = if IsValidResponse then
[
access_token = json[token],
scope = null,
token_type = "bearer",
refresh_token = json[refreshToken]
]
else
error [
Reason = "TokenRefreshFailed",
Message = "Failed to refresh OAuth token - please re-authenticate",
Detail = [
Server = server,
RefreshToken = if refreshToken = null then "null" else "present"
]
)
]
),
json = Json.Document(Source)
in
result
[
access_token = json[token],
scope = null,
token_type = "bearer",
refresh_token = json[refreshToken]
]
],
Key = [
KeyLabel = "Personal Access Token",
Label = "Private Project"
],
Implicit = [
Label = "Public Project"
]
],
Label = "Speckle"
@@ -1,8 +1,6 @@
(server as text, optional query as text, optional variables as record) as record =>
let
// Enhanced credential retrieval with OAuth2 support
apiKey = try Extension.CurrentCredential()[access_token] otherwise null,
apiKey = try Extension.CurrentCredential()[Key] otherwise null,
defaultQuery = "query {
activeUser {
email
@@ -14,9 +12,7 @@
version
}
}",
// Enhanced API call with comprehensive error handling
Source = try Web.Contents(
Source = Web.Contents(
Text.Combine({server, "graphql"}, "/"),
[
Headers = [
@@ -24,56 +20,14 @@
#"Content-Type" = "application/json",
#"Authorization" = if apiKey = null then "" else Text.Format("Bearer #{0}", {apiKey})
],
ManualStatusHandling = {400, 401, 403, 404, 500, 502, 503, 504},
ManualStatusHandling = {400},
Content = Json.FromValue([query = Text.From(query ?? defaultQuery), variables = variables])
]
) otherwise null,
// Check if the HTTP request was successful
IsHttpSuccess = Source <> null,
// Get HTTP status code for detailed error handling
StatusCode = if IsHttpSuccess then Value.Metadata(Source)[Response.Status] else null,
// Parse JSON response if HTTP request was successful
#"JSON" = if IsHttpSuccess then
try Json.Document(Source) otherwise null
else
null,
// Comprehensive error handling
// Comprehensive error handling
result = if not IsHttpSuccess then
error [
Reason = "HttpRequestFailed",
Message = "Failed to connect to Speckle server",
Detail = [Server = server, StatusCode = StatusCode]
]
else if StatusCode = 401 then
error [
Reason = "AuthenticationFailed",
Message = "Invalid or expired authentication token",
Detail = [Server = server, HasToken = apiKey <> null]
]
else if StatusCode = 403 then
error [
Reason = "AuthorizationFailed",
Message = "Insufficient permissions for this operation",
Detail = [Server = server]
]
else if #"JSON" = null then
error [
Reason = "InvalidJsonResponse",
Message = "Server returned invalid JSON response",
Detail = [Server = server, StatusCode = StatusCode]
]
else if Record.HasFields(#"JSON", {"errors"}) then
error [
Reason = "GraphQLError",
Message = #"JSON"[errors]{0}[message],
Detail = [Server = server, Errors = #"JSON"[errors]]
]
else
#"JSON"[data]
),
#"JSON" = Json.Document(Source)
in
result
// Check if response contains errors, if so, return first error.
if Record.HasFields(#"JSON", {"errors"}) then
error #"JSON"[errors]{0}[message]
else
#"JSON"[data]
@@ -29,7 +29,7 @@
versionId = parsedUrl[versionId],
// get API key if available
apiKey = try Extension.CurrentCredential()[access_token] otherwise null,
apiKey = try Extension.CurrentCredential()[Key] otherwise null,
// graphql query to get model info including root object id
// includes specific version if provided
@@ -39,9 +39,8 @@
),
// fields to remove from data record
FieldsToRemove = {"__closure", "totalChildrenCount", "renderMaterialProxies"},
// create basic table with cleaned data records (no properties column yet)
BasicTable = Table.FromRecords(
// create the final table with cleaned data records
FinalTable = Table.FromRecords(
List.Transform(
TableFromList[Column1],
each let
@@ -49,68 +48,27 @@
fieldsToRemoveForThisRecord = List.Select(
FieldsToRemove,
each Record.HasFields(record, {_})
),
cleanedRecord = Record.RemoveFields(record, fieldsToRemoveForThisRecord)
)
in
[
#"Object IDs" = record[id], // Object IDs
#"Speckle Type" = record[speckle_type], // Speckle Type
#"Version Object ID" = rootId,
data = cleanedRecord // Data
data = Record.RemoveFields(record, fieldsToRemoveForThisRecord) // Data
]
)
),
// function to check if a row should be excluded based on speckle type
ShouldExcludeRow = (row as record) as logical =>
let
speckleType = Record.FieldOrDefault(row[data], "speckle_type", "")
in
speckleType = "Speckle.Core.Models.DataChunk" or
Text.Contains(speckleType, "Objects.Other.RawEncoding"),
// Filtering logic here
// If model data contains any DataObject -> fetch only data objects (excluding unwanted types)
// If there are no data objects in the data -> fetch everything but exclude DataChunks and RawEncoding
// Check if model contains any DataObject
// If, model data contains any DataObject -> fetch only data objects
// If there are no data objects in the data -> fetch everything but DataChunks
HasDataObjects = Table.RowCount(
Table.SelectRows(
BasicTable,
each Text.Contains(Record.FieldOrDefault([data], "speckle_type", ""), "DataObject")
and not ShouldExcludeRow(_)
)
Table.SelectRows(FinalTable, each Text.Contains(Record.FieldOrDefault([data], "speckle_type", ""), "DataObject"))
) > 0,
// load the Objects.Properties function only if we have DataObjects
ObjectsProperties = if HasDataObjects then Extension.LoadFunction("Objects.Properties.pqm") else null,
// Add properties column only if model has DataObjects
FinalTable = if HasDataObjects then
Table.AddColumn(
BasicTable,
"properties",
each let
dataRecord = [data],
isDataObject = Text.Contains(Record.FieldOrDefault(dataRecord, "speckle_type", ""), "DataObject"),
hasProperties = Record.HasFields(dataRecord, {"properties"}),
extractedProperties = if hasProperties and isDataObject then
try ObjectsProperties(dataRecord) otherwise []
else
[]
in
if Record.FieldCount(extractedProperties) > 0 then extractedProperties else null
)
else
BasicTable,
// Apply the same filtering logic as before
FilteredTable = if HasDataObjects then
Table.SelectRows(
FinalTable,
each Text.Contains(Record.FieldOrDefault([data], "speckle_type", ""), "DataObject")
and not ShouldExcludeRow(_)
)
Table.SelectRows(FinalTable, each Text.Contains(Record.FieldOrDefault([data], "speckle_type", ""), "DataObject"))
else
Table.SelectRows(FinalTable, each not ShouldExcludeRow(_))
Table.SelectRows(FinalTable, each Record.FieldOrDefault([data], "speckle_type", "") <> "Speckle.Core.Models.DataChunk")
in
FilteredTable
@@ -26,7 +26,7 @@ in
parsedUrl = Parser(url),
server = parsedUrl[baseUrl],
apiKey = try Extension.CurrentCredential()[access_token] otherwise "",
apiKey = try Extension.CurrentCredential()[Key] otherwise try Extension.CurrentCredential()[access_token] otherwise "",
query = "query {
activeUser {
@@ -39,38 +39,29 @@
projectResult = ApiFetch(server, projectQuery, projectVariables),
workspaceId = projectResult[data][workspaceId],
// check if workspaceId is null (personal project)
workspaceInfo = if workspaceId = null then
[
workspaceId = null,
workspaceLogo = null,
workspaceName = null,
canHideBranding = false
]
else
// query workspace only if workspaceId exists
let
workspaceQuery = "query Workspace($workspaceId: String!, $featureName: WorkspaceFeatureName!) {
data:workspace(id: $workspaceId) {
logo
name
hasAccessToFeature(featureName: $featureName)
}
}",
// query to get workspace
workspaceQuery = "query Workspace($workspaceId: String!, $featureName: WorkspaceFeatureName!) {
data:workspace(id: $workspaceId) {
logo
name
hasAccessToFeature(featureName: $featureName)
}
}",
workspaceVariables = [
workspaceId = workspaceId,
featureName = "hideSpeckleBranding"
],
workspaceResult = ApiFetch(server, workspaceQuery, workspaceVariables),
workspaceVariables = [
workspaceId = workspaceId,
featureName = "hideSpeckleBranding"
],
workspaceResult = ApiFetch(server, workspaceQuery, workspaceVariables),
workspace = workspaceResult[data]
in
[
workspaceId = workspaceId,
workspaceLogo = workspace[logo],
workspaceName = workspace[name],
canHideBranding = workspace[hasAccessToFeature]
]
workspace = workspaceResult[data],
workspaceInfo = [
workspaceId = workspaceId,
workspaceLogo = workspace[logo],
workspaceName = workspace[name],
canHideBranding = workspace[hasAccessToFeature]
]
in
workspaceInfo
@@ -1,30 +0,0 @@
// function for federating multiple tables by combining them and creating a concatenated Version Object ID
(tables as list, optional excludeData as logical) as table =>
let
ViewerOnly = if excludeData = null then false else excludeData,
// filter columns from each table if excludeData is true
ProcessedTables = List.Transform(
tables,
each
if ViewerOnly then
Table.SelectColumns(_, {"Version Object ID", "Object IDs"}, MissingField.Ignore)
else
_
),
CombinedTable = Table.Combine(ProcessedTables),
DistinctVersionObjectIDs = List.Distinct(CombinedTable[Version Object ID]),
ConcatenatedVersionObjectIDs = Text.Combine(DistinctVersionObjectIDs, ","),
// Replace all Version Object ID values with the concatenated string
FederatedTable = Table.ReplaceValue(
CombinedTable,
each [Version Object ID],
ConcatenatedVersionObjectIDs,
Replacer.ReplaceText,
{"Version Object ID"}
)
in
FederatedTable
@@ -1,22 +0,0 @@
// function for transforming a table to extract and expand Material Quantities data
(inputTable as table, optional addPrefix as logical) as table =>
let
// Default addPrefix to false if not provided
UsePrefix = if addPrefix = null then false else addPrefix,
// Add mq column using existing MaterialQuantities function with list output
AddedMQ = Table.AddColumn(inputTable, "mq", each Speckle.Objects.MaterialQuantities([data], true)),
// Expand the mq list column
ExpandMQ = Table.ExpandListColumn(AddedMQ, "mq"),
// Add MQProperties column using Properties function with error handling
AddedMQProperties = Table.AddColumn(ExpandMQ, "MQ", each try Speckle.Objects.Properties([mq]) otherwise null),
// Expand the MQProperties record using Utils.ExpandRecord
ExpandMQProperties = Speckle.Utils.ExpandRecord(AddedMQProperties, "MQ", null, UsePrefix),
// Remove the temporary mq and MQProperties columns
FinalTable = Table.RemoveColumns(ExpandMQProperties, {"mq", "MQ"}, MissingField.Ignore)
in
FinalTable
@@ -1,163 +0,0 @@
// function for mapping collection names to referenced elements in Speckle data
(inputData as table) as table =>
let
// Helper function to safely get field value
SafeFieldValue = (record as record, fieldName as text) as any =>
if Record.HasFields(record, {fieldName}) then
Record.Field(record, fieldName)
else
null,
// Helper function to safely get nested field value
SafeNestedValue = (record as record, path as list) as any =>
List.Accumulate(
path,
record,
(current, fieldName) =>
if current <> null and Value.Is(current, type record) and Record.HasFields(current, {fieldName}) then
Record.Field(current, fieldName)
else
null
),
// Step 1: Identify Collection Objects
CollectionObjects = Table.SelectRows(
inputData,
each
let
speckleType = SafeFieldValue(_, "Speckle Type")
in
speckleType <> null and Text.Contains(speckleType, "Collection")
),
// Step 2: Extract Collection Metadata
CollectionMetadata = Table.AddColumn(
CollectionObjects,
"CollectionInfo",
each
let
objectId = SafeFieldValue(_, "Object IDs"),
collectionName = SafeNestedValue(_, {"data", "name"}),
elements = SafeNestedValue(_, {"data", "elements"})
in
[
ObjectId = objectId,
CollectionName = if collectionName <> null then collectionName else "Unnamed Collection",
Elements = if elements <> null and Value.Is(elements, type list) then elements else {}
]
),
// Step 3: Build Collection Hierarchy Mapping
CollectionHierarchy = Table.AddColumn(
CollectionMetadata,
"CollectionReferences",
each
let
info = [CollectionInfo],
collectionName = info[CollectionName],
elements = info[Elements]
in
List.Transform(
elements,
(element) =>
let
referencedId = if Value.Is(element, type record) and Record.HasFields(element, {"referencedId"}) then
element[referencedId]
else
null
in
if referencedId <> null then
[
ReferencedId = referencedId,
CollectionName = collectionName,
ParentCollectionId = info[ObjectId]
]
else
null
)
),
// Step 4: Flatten Reference Mapping
FlattenedReferences = Table.SelectRows(
Table.ExpandListColumn(
Table.SelectColumns(CollectionHierarchy, {"CollectionReferences"}),
"CollectionReferences"
),
each [CollectionReferences] <> null
),
ReferenceTable = Table.ExpandRecordColumn(
FlattenedReferences,
"CollectionReferences",
{"ReferencedId", "CollectionName", "ParentCollectionId"},
{"ReferencedId", "CollectionName", "ParentCollectionId"}
),
// Step 5: Build Hierarchical Collection Paths
BuildCollectionPath = (objectId as text, visited as list) as text =>
let
// Prevent infinite loops
_ = if List.Contains(visited, objectId) then
error "Circular reference detected in collection hierarchy"
else
null,
newVisited = List.InsertRange(visited, 0, {objectId}),
// Find if this object is referenced by any collection
parentReferences = Table.SelectRows(ReferenceTable, each [ReferencedId] = objectId),
result = if Table.RowCount(parentReferences) = 0 then
// No parent collection found
""
else
let
parentRef = parentReferences{0},
parentCollectionId = parentRef[ParentCollectionId],
currentCollectionName = parentRef[CollectionName],
// Recursively get parent path
parentPath = @BuildCollectionPath(parentCollectionId, newVisited),
// Build full path
fullPath = if parentPath = "" then
currentCollectionName
else
parentPath & "::" & currentCollectionName
in
fullPath
in
result,
// Step 6: Add Collection Paths to data field
FinalData = Table.TransformColumns(
inputData,
{
"data", each
let
currentData = _,
currentRow = Table.SelectRows(inputData, each [data] = currentData){0},
objectId = SafeFieldValue(currentRow, "Object IDs"),
collectionPath = if objectId <> null then
try
BuildCollectionPath(objectId, {})
otherwise
""
else
"",
// Add CollectionPath field to the data record, set to null if empty
enhancedData = if Value.Is(currentData, type record) then
Record.AddField(
currentData,
"collectionPath",
if collectionPath = "" then null else collectionPath
)
else
currentData
in
enhancedData
}
)
in
FinalData
@@ -1,18 +0,0 @@
(objectRecord as record, optional outputAsList as nullable logical) as any =>
let
compositeStructure =
if Record.HasFields(objectRecord[properties], "Composite Structure") then
objectRecord[properties][Composite Structure]
else if Record.HasFields(objectRecord[properties], "Parameters") and
Record.HasFields(objectRecord[properties][Parameters], "Type Parameters") and
Record.HasFields(objectRecord[properties][Parameters][Type Parameters], "Structure") then
objectRecord[properties][Parameters][Type Parameters][Structure]
else
null,
result =
if outputAsList = true then
if compositeStructure <> null then Record.ToList(compositeStructure) else null
else
compositeStructure
in
result
@@ -1,15 +0,0 @@
// Helper function to extract [properties][Material Quantities] and optionally output as list
(objectRecord as record, optional outputAsList as logical) as any =>
let
// Ensure outputAsList is logical and defaults to false if not provided
OutputAsList = if outputAsList = null then false else outputAsList,
// Check if 'properties' and 'Material Quantities' exist
HasMaterialQuantities = Record.HasFields(objectRecord, {"properties"}) and Record.HasFields(Record.Field(objectRecord, "properties"), {"Material Quantities"}),
MaterialQuantities = if HasMaterialQuantities then Record.Field(Record.Field(objectRecord, "properties"), "Material Quantities") else null,
Result = if MaterialQuantities = null then null else
if OutputAsList then
Record.ToList(MaterialQuantities)
else
MaterialQuantities
in
Result
@@ -1,257 +0,0 @@
// function for extracting and flattening properties from Speckle objects
(inputRecord as any, optional filterKeys as list, optional parentPath as text, optional existingFields as list) as record =>
let
// Define excluded paths
ExcludedPaths = {
"Composite Structure",
"Material Quantities",
"Parameters.Type Parameters.Structure"
},
// Helper function to check if a path should be excluded
IsPathExcluded = (currentPath as text) as logical =>
List.AnyTrue(List.Transform(ExcludedPaths, each Text.Contains(currentPath, _))),
// Helper function to resolve naming conflicts
ResolveFieldName = (fieldName as text, parentPathParam as nullable text, existingFieldsParam as nullable list) as text =>
let
// Ensure we have valid inputs
parentPath = if parentPathParam = null then "" else parentPathParam,
existingFields = if existingFieldsParam = null then {} else existingFieldsParam,
// Try original field name first
candidateName = fieldName,
// If no conflict, return original name
finalName = if not List.Contains(existingFields, candidateName) then
candidateName
else if parentPath = "" then
fieldName // No parent path available, keep original
else
let
// Split parent path and try adding parents one by one
pathParts = Text.Split(parentPath, "."),
reversedParts = List.Reverse(pathParts), // Start with immediate parent
// Use iteration instead of recursion
ResolveWithIteration = () =>
let
// Generate all possible candidates
candidates = List.Generate(
() => [depth = 1, candidate = fieldName & "." & List.First(reversedParts)],
each [depth] <= List.Count(reversedParts),
each [
depth = [depth] + 1,
candidate = fieldName & "." & Text.Combine(List.FirstN(reversedParts, [depth]), ".")
],
each [candidate]
),
// Find first non-conflicting candidate
firstNonConflicting = List.First(
List.Select(candidates, each not List.Contains(existingFields, _)),
// If all conflict, use full path
fieldName & "." & Text.Combine(reversedParts, ".")
)
in
firstNonConflicting,
resolvedName = ResolveWithIteration()
in
resolvedName
in
finalName,
// Create the main flattening function with self-reference capability
FlattenRecordImpl = (
flattenFn as function,
inputRecord as any,
filterKeys as nullable list,
parentPathParam as nullable text,
existingFieldsParam as nullable list
) as record =>
let
// Ensure non-null values for internal use
currentParentPath = if parentPathParam = null then "" else parentPathParam,
currentExistingFields = if existingFieldsParam = null then {} else existingFieldsParam,
currentfilterKeys = filterKeys,
// Check if record has "properties" field and use it instead of the root record
recordToProcess = if inputRecord = null then
null
else if Value.Is(inputRecord, type record) and Record.HasFields(inputRecord, {"properties"}) then
Record.Field(inputRecord, "properties")
else
inputRecord,
// Helper function to check if a field should be included
ShouldIncludeField = (fieldName as text) as logical =>
if currentfilterKeys = null then true
else List.Contains(currentfilterKeys, fieldName),
// Handle different input types
result = if recordToProcess = null then
[]
else if Value.Is(recordToProcess, type record) then
let
fieldNames = Record.FieldNames(recordToProcess),
// Process each field
processedFields = List.Accumulate(
fieldNames,
[FlattenedRecord = [], ExistingFieldsList = currentExistingFields],
(state, fieldName) =>
let
fieldValue = Record.Field(recordToProcess, fieldName),
newPath = if currentParentPath = "" then fieldName else currentParentPath & "." & fieldName,
// Skip if path is excluded
shouldProcess = not IsPathExcluded(newPath),
processResult = if not shouldProcess then
state
else
let
// Check if this is a name/value record
hasNameValue = Value.Is(fieldValue, type record) and
Record.HasFields(fieldValue, {"name", "value"}),
finalResult = if hasNameValue then
let
nameField = Record.Field(fieldValue, "name"),
valueField = Record.Field(fieldValue, "value"),
// Check if this name field should be included
shouldInclude = if nameField = null then false else ShouldIncludeField(nameField),
result = if shouldInclude and nameField <> null then
let
resolvedName = ResolveFieldName(nameField, currentParentPath, state[ExistingFieldsList]),
newRecord = Record.AddField(state[FlattenedRecord], resolvedName, valueField),
newFieldsList = state[ExistingFieldsList] & {resolvedName}
in
[FlattenedRecord = newRecord, ExistingFieldsList = newFieldsList]
else
state
in
result
else if fieldValue = null then
let
shouldInclude = ShouldIncludeField(fieldName),
result = if shouldInclude then
let
resolvedName = ResolveFieldName(fieldName, currentParentPath, state[ExistingFieldsList]),
newRecord = Record.AddField(state[FlattenedRecord], resolvedName, null),
newFieldsList = state[ExistingFieldsList] & {resolvedName}
in
[FlattenedRecord = newRecord, ExistingFieldsList = newFieldsList]
else
state
in
result
else if Value.Is(fieldValue, type record) then
let
// Skip empty records
fieldCount = Record.FieldCount(fieldValue),
recursiveResult = if fieldCount = 0 then
state
else
let
// Call the function through the passed reference
// IMPORTANT: Pass the current state's existing fields list
flattened = flattenFn(flattenFn, fieldValue, currentfilterKeys, newPath, state[ExistingFieldsList]),
// Get all field names from the flattened result
flattenedFieldNames = Record.FieldNames(flattened),
// Merge the flattened record with the current state
combinedRecord = flattened & state[FlattenedRecord],
// Update the existing fields list with ALL fields from both records
allFieldNames = List.Distinct(state[ExistingFieldsList] & flattenedFieldNames)
in
[FlattenedRecord = combinedRecord, ExistingFieldsList = allFieldNames]
in
recursiveResult
else if Value.Is(fieldValue, type list) then
let
listLength = List.Count(fieldValue),
// Skip empty lists
listResult = if listLength = 0 then
state
else
List.Accumulate(
List.Positions(fieldValue),
state,
(listState, index) =>
let
listItem = fieldValue{index},
indexSuffix = Text.From(index + 1), // 1-based indexing
listFieldName = fieldName & "." & indexSuffix,
listPath = if currentParentPath = "" then listFieldName else currentParentPath & "." & listFieldName,
itemResult = if Value.Is(listItem, type record) then
let
itemFieldCount = Record.FieldCount(listItem),
itemFlattened = if itemFieldCount = 0 then
listState
else
let
// Call the function through the passed reference
flattened = flattenFn(flattenFn, listItem, currentfilterKeys, listPath, listState[ExistingFieldsList]),
// Get all field names from the flattened result
flattenedFieldNames = Record.FieldNames(flattened),
// Merge the flattened record with the current state
combinedRecord = flattened & listState[FlattenedRecord],
// Update the existing fields list with ALL fields
allFieldNames = List.Distinct(listState[ExistingFieldsList] & flattenedFieldNames)
in
[FlattenedRecord = combinedRecord, ExistingFieldsList = allFieldNames]
in
itemFlattened
else
let
shouldInclude = ShouldIncludeField(listFieldName),
result = if shouldInclude then
let
resolvedName = ResolveFieldName(listFieldName, currentParentPath, listState[ExistingFieldsList]),
newRecord = Record.AddField(listState[FlattenedRecord], resolvedName, listItem),
newFieldsList = listState[ExistingFieldsList] & {resolvedName}
in
[FlattenedRecord = newRecord, ExistingFieldsList = newFieldsList]
else
listState
in
result
in
itemResult
)
in
listResult
else
// Handle primitive values
let
shouldInclude = ShouldIncludeField(fieldName),
result = if shouldInclude then
let
resolvedName = ResolveFieldName(fieldName, currentParentPath, state[ExistingFieldsList]),
newRecord = Record.AddField(state[FlattenedRecord], resolvedName, fieldValue),
newFieldsList = state[ExistingFieldsList] & {resolvedName}
in
[FlattenedRecord = newRecord, ExistingFieldsList = newFieldsList]
else
state
in
result
in
finalResult
in
processResult
)
in
processedFields[FlattenedRecord]
else
// If input is not a record, return it as is in a record wrapper
[Value = recordToProcess]
in
result,
// Call the implementation with self-reference
result = FlattenRecordImpl(FlattenRecordImpl, inputRecord, filterKeys, parentPath, existingFields)
in
result
@@ -1,5 +1,5 @@
(url as text) as list =>
try let
let
// Import required functions
GetModel = Extension.LoadFunction("GetModel.pqm"),
Parser = Extension.LoadFunction("Parser.pqm"),
@@ -37,40 +37,16 @@
workspaceInfo = GetWorkspace(url),
// exchange powerful token for weak token via ds
tokenExchangeData = Json.FromValue([
PowerfulToken = apiKey,
Scopes = {"profile:read", "streams:read", "users:read"},
ProjectId = parsedUrl[projectId],
ServerUrl = parsedUrl[baseUrl]
]),
tokenExchangeResponse = Web.Contents(
"http://127.0.0.1:29364/auth/exchange-token",
[
Headers = [
#"Content-Type" = "application/json",
#"Method" = "POST"
],
Content = tokenExchangeData,
ManualStatusHandling = {400, 401, 403, 404, 500}
]
),
tokenExchangeJson = Json.Document(tokenExchangeResponse),
weakToken = tokenExchangeJson[token],
// prepare request data with weak token
// Prepare request data
requestData = Json.FromValue([
Url = url,
Server = parsedUrl[baseUrl],
Email = userEmail,
ProjectId = parsedUrl[projectId],
RootObjectId = modelInfo[rootObjectId],
ObjectId = modelInfo[rootObjectId],
SourceApplication = modelInfo[sourceApplication],
Token = weakToken,
Token = apiKey,
Version = connectorVersion,
VersionId = parsedUrl[versionId],
WorkspaceId = workspaceInfo[workspaceId],
WorkspaceName = workspaceInfo[workspaceName],
WorkspaceLogo = workspaceInfo[workspaceLogo],
@@ -94,10 +70,4 @@
JsonResponse = Json.Document(Response)
in
JsonResponse
otherwise
error [
Reason = "Desktop Service Not Available",
Message = "Cannot connect to Speckle Desktop Service. Please ensure the Desktop Service is running and try again.",
Detail = "The Speckle Desktop Service must be running to load data from Speckle. Please start the Desktop Service application and refresh your data connection."
]
JsonResponse
@@ -1,31 +0,0 @@
// Expands a record column in a table, adding new columns for each field in the record.
// If UseCombinedNames is true, columns are named as ColumnName.FieldName, otherwise just FieldName.
// If FieldNames is provided (list), only those fields are expanded.
(table as table, columnName as text, optional FieldNames as list, optional UseCombinedNames as logical) as table =>
let
useCombined = if UseCombinedNames = null then false else UseCombinedNames,
// Determine which field names to expand
allFieldNames = if FieldNames <> null then FieldNames else List.Distinct(
List.Combine(
List.Transform(
Table.Column(table, columnName),
each if _ is record then Record.FieldNames(_) else {}
)
)
),
// Add each field as a new column
addColumns = List.Accumulate(
allFieldNames,
table,
(state, field) =>
Table.AddColumn(
state,
if useCombined then columnName & "." & field else field,
(row) =>
if Record.HasFields(row, columnName) and Record.Field(row, columnName) is record and Record.HasFields(Record.Field(row, columnName), field)
then Record.Field(Record.Field(row, columnName), field)
else null
)
)
in
addColumns
+21 -25
View File
@@ -78,20 +78,10 @@
}
}
},
"workspace": {
"properties": {
"brandingHidden": {
"type": { "bool": true }
}
}
},
"viewMode": {
"properties": {
"defaultViewMode": {
"type": { "text": true }
},
"navbarHidden": {
"type": { "bool": true }
}
}
},
@@ -100,14 +90,29 @@
"defaultView": {
"type": { "text": true }
},
"isOrtho": {
"type": { "bool": true }
"allowCameraUnder": {
"type": {
"bool": true
}
},
"isGhost": {
"type": { "bool": true }
"zoomOnDataChange": {
"type": {
"bool": true
}
},
"zoomOnFilter": {
"type": { "bool": true }
"projection": {
"type": {
"enumeration": [
{
"displayName": "Perspective",
"value": "perspective"
},
{
"displayName": "Orthographic",
"value": "orthographic"
}
]
}
}
}
},
@@ -133,15 +138,6 @@
}
}
},
"dataLoading": {
"properties": {
"internalizeData": {
"type": {
"bool": true
}
}
}
},
"color": {
"properties": {
"enabled": {
+19 -344
View File
@@ -13,8 +13,7 @@
"@babel/runtime-corejs3": "^7.21.5",
"@headlessui/vue": "^1.7.13",
"@heroicons/vue": "^2.0.12",
"@speckle/objectloader": "^2.25.9",
"@speckle/objectloader2": "^2.25.9",
"@speckle/objectloader": "^2.23.8",
"@speckle/tailwind-theme": "2.23.2",
"@speckle/ui-components": "2.23.2",
"@speckle/viewer": "2.23.23",
@@ -32,8 +31,7 @@
"powerbi-visuals-utils-formattingmodel": "^6.0.4",
"powerbi-visuals-utils-interactivityutils": "^6.0.4",
"powerbi-visuals-utils-tooltiputils": "^6.0.4",
"regenerator-runtime": "^0.13.11",
"vue-tippy": "^6.7.1"
"regenerator-runtime": "^0.13.11"
},
"devDependencies": {
"@babel/core": "^7.21.8",
@@ -2827,13 +2825,6 @@
"deprecated": "Use @eslint/object-schema instead",
"dev": true
},
"node_modules/@ioredis/commands": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.3.0.tgz",
"integrity": "sha512-M/T6Zewn7sDaBQEqIZ8Rb+i9y8qfGmq+5SDFSf9sA2lUZTmdDLVdOiQaeDp+Q4wElZ9HG1GAX5KhDaidp6LQsQ==",
"license": "MIT",
"peer": true
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -2987,90 +2978,6 @@
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
"dev": true
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true
},
"node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
"version": "5.1.1-v1",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
@@ -3144,13 +3051,12 @@
"peer": true
},
"node_modules/@speckle/objectloader": {
"version": "2.25.9",
"resolved": "https://registry.npmjs.org/@speckle/objectloader/-/objectloader-2.25.9.tgz",
"integrity": "sha512-ZSMinqrHm4Hx3x6dth2kfnJO2O1zI0i4E0eE3PS9hg1HAtCx9opIT/eKIzj5fYY/cUyfXxP00k7qXmZ3KAUl7w==",
"license": "Apache-2.0",
"version": "2.23.23",
"resolved": "https://registry.npmjs.org/@speckle/objectloader/-/objectloader-2.23.23.tgz",
"integrity": "sha512-k0qxk5M0Q57h+fth6GQq8N7SjeJnWHxjDlMDYC56lOZkH8vI0Y2RHG33+DiBvC7iHnTIRuZc0SyTRrJ64Cuhrg==",
"dependencies": {
"@babel/core": "^7.17.9",
"@speckle/shared": "^2.25.9",
"@speckle/shared": "^2.23.23",
"core-js": "^3.21.1",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
@@ -3160,25 +3066,11 @@
"node": ">=18.0.0"
}
},
"node_modules/@speckle/objectloader2": {
"version": "2.25.9",
"resolved": "https://registry.npmjs.org/@speckle/objectloader2/-/objectloader2-2.25.9.tgz",
"integrity": "sha512-fZYkVGBCNUcVMMZnIljmtqiyXpSlyWiuW+qbpfls5lX6P9ZLP6DC88AUjyJQ6cM9jZ6RGNk3/Sa+MeReTeZIvg==",
"license": "Apache-2.0",
"dependencies": {
"@speckle/shared": "^2.25.9"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@speckle/shared": {
"version": "2.25.9",
"resolved": "https://registry.npmjs.org/@speckle/shared/-/shared-2.25.9.tgz",
"integrity": "sha512-7hK6v55tSu8OTxza9UakettBpzlbyIWq17jtzjf3ZSnqqjiasayhG3O0/uuRI2GoeingoOqUMLvybatjDu4ang==",
"license": "Apache-2.0",
"version": "2.23.23",
"resolved": "https://registry.npmjs.org/@speckle/shared/-/shared-2.23.23.tgz",
"integrity": "sha512-5laonEcP7FsG5CPn/EXq0tpkA135Bxq3TndZ+OJGzuaY9L6ZHLtSURltZ6YpjMo6CHmAiFEVG62laP6UHwIL4w==",
"dependencies": {
"dayjs": "^1.11.13",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"nanoid": "^5.1.5",
@@ -3190,7 +3082,6 @@
},
"peerDependencies": {
"@tiptap/core": "^2.0.0-beta.176",
"bull": "*",
"knex": "*",
"mixpanel": "^0.17.0",
"pino": "^8.7.0",
@@ -5379,38 +5270,6 @@
"integrity": "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==",
"dev": true
},
"node_modules/bull": {
"version": "4.16.5",
"resolved": "https://registry.npmjs.org/bull/-/bull-4.16.5.tgz",
"integrity": "sha512-lDsx2BzkKe7gkCYiT5Acj02DpTwDznl/VNN7Psn7M3USPG7Vs/BaClZJJTAG+ufAR9++N1/NiUTdaFBWDIl5TQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"cron-parser": "^4.9.0",
"get-port": "^5.1.1",
"ioredis": "^5.3.2",
"lodash": "^4.17.21",
"msgpackr": "^1.11.2",
"semver": "^7.5.2",
"uuid": "^8.3.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/bull/node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"license": "ISC",
"peer": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/bundle-name": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
@@ -5507,9 +5366,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001726",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz",
"integrity": "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==",
"version": "1.0.30001689",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001689.tgz",
"integrity": "sha512-CmeR2VBycfa+5/jOfnp/NpWPGd06nf1XYiefUvhXFfZE4GkRc9jv+eGPS4nT558WS/8lYCzV8SlANCIPvbWP1g==",
"funding": [
{
"type": "opencollective",
@@ -5523,8 +5382,7 @@
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0"
]
},
"node_modules/chai": {
"version": "5.1.2",
@@ -5666,16 +5524,6 @@
"node": ">=6"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -5995,19 +5843,6 @@
"license": "MIT",
"peer": true
},
"node_modules/cron-parser": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"luxon": "^3.2.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -6239,12 +6074,6 @@
"node": "*"
}
},
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
"license": "MIT"
},
"node_modules/de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
@@ -6367,16 +6196,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -6414,17 +6233,6 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"engines": {
"node": ">=8"
}
},
"node_modules/detect-node": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
@@ -7931,19 +7739,6 @@
"node": ">=8.0.0"
}
},
"node_modules/get-port": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz",
"integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
@@ -8688,31 +8483,6 @@
"node": ">= 0.10"
}
},
"node_modules/ioredis": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.7.0.tgz",
"integrity": "sha512-NUcA93i1lukyXU+riqEyPtSEkyFq8tX90uL659J+qpCZ3rEdViB/APC58oAhIh3+bJln2hzdlZbBZsGNrlsR8g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@ioredis/commands": "^1.3.0",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.1.0",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/ipaddr.js": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
@@ -9490,14 +9260,8 @@
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT",
"peer": true
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"dev": true
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
@@ -9561,16 +9325,6 @@
"yallist": "^3.0.2"
}
},
"node_modules/luxon": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.1.tgz",
"integrity": "sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@@ -9897,39 +9651,6 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/msgpackr": {
"version": "1.11.5",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz",
"integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==",
"license": "MIT",
"peer": true,
"optionalDependencies": {
"msgpackr-extract": "^3.0.2"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
}
},
"node_modules/multicast-dns": {
"version": "7.2.5",
"resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz",
@@ -10050,22 +9771,6 @@
"node": ">= 6.13.0"
}
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/node-releases": {
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
@@ -12840,29 +12545,6 @@
"node": ">=8"
}
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"license": "MIT",
"peer": true,
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/regenerate": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
@@ -13699,13 +13381,6 @@
"node": ">= 10.x"
}
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT",
"peer": true
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@@ -14809,6 +14484,7 @@
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true,
"bin": {
"uuid": "dist/bin/uuid"
}
@@ -15038,10 +14714,9 @@
}
},
"node_modules/vue-tippy": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/vue-tippy/-/vue-tippy-6.7.1.tgz",
"integrity": "sha512-gdHbBV5/Vc8gH87hQHLA7TN1K4BlLco3MAPrTb70ZYGXxx+55rAU4a4mt0fIoP+gB3etu1khUZ6c29Br1n0CiA==",
"license": "MIT",
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/vue-tippy/-/vue-tippy-6.5.0.tgz",
"integrity": "sha512-U44UDETTLuZWZGosagslEwgimWQdt1JVSxfWStVPnVdeqo2jo9X5zW3SB04k7JaTFosdgrDhFsUDrd6n42Nh7Q==",
"dependencies": {
"tippy.js": "^6.3.7"
},
+2 -4
View File
@@ -17,8 +17,7 @@
"@babel/runtime-corejs3": "^7.21.5",
"@headlessui/vue": "^1.7.13",
"@heroicons/vue": "^2.0.12",
"@speckle/objectloader": "^2.25.9",
"@speckle/objectloader2": "^2.25.9",
"@speckle/objectloader": "^2.23.8",
"@speckle/tailwind-theme": "2.23.2",
"@speckle/ui-components": "2.23.2",
"@speckle/viewer": "2.23.23",
@@ -36,8 +35,7 @@
"powerbi-visuals-utils-formattingmodel": "^6.0.4",
"powerbi-visuals-utils-interactivityutils": "^6.0.4",
"powerbi-visuals-utils-tooltiputils": "^6.0.4",
"regenerator-runtime": "^0.13.11",
"vue-tippy": "^6.7.1"
"regenerator-runtime": "^0.13.11"
},
"devDependencies": {
"@babel/core": "^7.21.8",
-25
View File
@@ -1,19 +1,4 @@
<template>
<div
v-if="visualStore.loadingProgress"
class="absolute top-1/2 left-1/2 w-1/2 -translate-x-1/2 z-50 text-center text-sm"
>
<!-- Progress Bar -->
<LoadingBar :progress="visualStore.loadingProgress"></LoadingBar>
</div>
<div
v-if="visualStore.commonError"
class="absolute top-11 left-1/2 -translate-x-1/2 z-100 bg-white bg-opacity-70 text-black text-center text-sm px-4 py-1 rounded shadow font-medium cursor-default"
>
{{ visualStore.commonError }}
</div>
<ViewerView v-if="visualStore.isViewerReadyToLoad" />
<HomeView v-else />
</template>
@@ -23,7 +8,6 @@ import HomeView from './views/HomeView.vue'
import ViewerView from './views/ViewerView.vue'
import { onMounted } from 'vue'
import { useVisualStore } from './store/visualStore'
import LoadingBar from '@src/components/loading/LoadingBar.vue'
const visualStore = useVisualStore()
@@ -31,12 +15,3 @@ onMounted(() => {
console.log('App mounted')
})
</script>
<style>
.tippy-box[data-theme~='custom'] {
font-size: 10px;
padding: 0px 0px;
border-radius: 4px;
text-align: center;
}
</style>
@@ -2,33 +2,12 @@
<div class="space-y-2">
<ViewerControlsButtonGroup>
<!-- Zoom extend -->
<ViewerControlsButtonToggle flat tooltip="Zoom extends" @click="onZoomExtentsClicked">
<ViewerControlsButtonToggle v-tippy="'Zoom extends'" flat @click="onZoomExtentsClicked">
<ArrowsPointingOutIcon class="h-4 w-4 md:h-5 md:w-5" />
</ViewerControlsButtonToggle>
<!-- Zoom on Filter -->
<ViewerControlsButtonToggle
:tooltip="
visualStore.isZoomOnFilterActive
? 'Move camera on filter'
: 'Keep camera position on filter'
"
flat
@click="toggleZoomOnFilter"
>
<ZoomToFit v-if="visualStore.isZoomOnFilterActive" class="h-5 w-5" />
<ZoomToFit v-else class="h-5 w-5 opacity-30" />
</ViewerControlsButtonToggle>
<!-- Ghost / Hidden -->
<ViewerControlsButtonToggle
:tooltip="
visualStore.isGhostActive
? 'Hide ghosted objects on filter'
: 'Show ghosted objects on filter'
"
flat
@click="toggleGhostHidden"
>
<Ghost v-if="visualStore.isGhostActive" class="h-5 w-5" />
<ViewerControlsButtonToggle flat @click="toggleGhostHidden">
<Ghost v-if="isGhost" class="h-5 w-5" />
<Ghost v-else class="h-5 w-5 opacity-30" />
</ViewerControlsButtonToggle>
</ViewerControlsButtonGroup>
@@ -52,11 +31,10 @@
<ViewerControlsButtonToggle
flat
secondary
tooltip="Projection"
:active="visualStore.isOrthoProjection"
:active="isOrthoProjection"
@click="toggleProjection"
>
<Perspective v-if="visualStore.isOrthoProjection" class="h-3.5 md:h-4 w-4" />
<Perspective v-if="isOrthoProjection" class="h-3.5 md:h-4 w-4" />
<PerspectiveMore v-else class="h-3.5 md:h-4 w-4" />
</ViewerControlsButtonToggle>
</ViewerControlsButtonGroup>
@@ -78,7 +56,6 @@ import Perspective from '../components/global/icon/Perspective.vue'
import PerspectiveMore from '../components/global/icon/PerspectiveMore.vue'
import Ghost from '../components/global/icon/Ghost.vue'
import ZoomToFit from '../components/global/icon/ZoomToFit.vue'
const visualStore = useVisualStore()
@@ -93,6 +70,9 @@ withDefaults(defineProps<{ sectionBox: boolean; views: SpeckleView[] }>(), {
sectionBox: false
})
const isOrthoProjection = ref(false)
const isGhost = ref(true)
type ActiveControl =
| 'none'
| 'viewModes'
@@ -114,20 +94,13 @@ const toggleActiveControl = (control: ActiveControl) => {
}
const toggleProjection = () => {
isOrthoProjection.value = !isOrthoProjection.value
visualStore.viewerEmit('toggleProjection')
visualStore.setIsOrthoProjection(!visualStore.isOrthoProjection)
visualStore.writeIsOrthoToFile()
}
const toggleGhostHidden = () => {
visualStore.setIsGhost(!visualStore.isGhostActive)
visualStore.viewerEmit('toggleGhostHidden', visualStore.isGhostActive)
visualStore.writeIsGhostToFile()
}
const toggleZoomOnFilter = () => {
visualStore.setIsZoomOnFilterActive(!visualStore.isZoomOnFilterActive)
visualStore.writeZoomOnFilterToFile()
isGhost.value = !isGhost.value
visualStore.viewerEmit('toggleGhostHidden', isGhost.value)
}
const viewModesOpen = computed({
@@ -1,130 +1,76 @@
<template>
<div class="border">
<transition name="slide-fade">
<nav
v-show="!visualStore.isNavbarHidden"
class="fixed top-0 h-9 flex items-center bg-foundation border border-outline-2 w-full transition z-20 cursor-default"
>
<div class="flex items-center transition-all justify-between w-full">
<div
v-if="visualStore.receiveInfo.workspaceName"
class="flex items-center gap-2 p-0.5 pr-1.5 hover:bg-highlight-2 rounded ml-2"
<transition name="slide-fade">
<nav
v-show="!isNavbarCollapsed"
class="fixed top-0 h-9 flex items-center bg-foundation border-b border-outline-2 w-full transition z-20 shadow-sm hover:shadow cursor-default"
>
<div class="flex items-center transition-all justify-between w-full">
<div class="flex items-center hover:cursor-pointer" @click="goToSpeckleWebsite">
<div class="max-[200px]:hidden block ml-2">
<img class="w-6 h-auto ml-1 mr-2 my-1" src="@assets/logo-big.png" />
</div>
<div class="font-sans font-medium">Speckle</div>
</div>
<div class="flex items-center">
<div class="font-thin text-xs mr-2 text-gray-400">v1.0.0</div>
<button
class="text-gray-400 hover:text-gray-700 transition"
title="Hide navbar"
@click="isNavbarCollapsed = true"
>
<WorkspaceAvatar
:name="visualStore.receiveInfo.workspaceName"
:logo="visualStore.receiveInfo.workspaceLogo"
></WorkspaceAvatar>
<div class="min-w-0 truncate flex-grow text-left text-xs">
<span>{{ visualStore.receiveInfo.workspaceName }}</span>
</div>
</div>
<div v-else>
<div class="flex items-center hover:cursor-pointer" @click="goToSpeckleWebsite">
<div class="max-[200px]:hidden block ml-2">
<img class="w-6 h-auto ml-1 mr-2 my-1" src="@assets/logo-big.png" />
</div>
<div class="font-sans font-medium">Speckle</div>
</div>
</div>
<div class="flex items-center space-x-2">
<FormButton
v-if="visualStore.latestAvailableVersion && !visualStore.isConnectorUpToDate"
v-tippy="{
content: 'New connector version is available.<br>Click to download.',
allowHTML: true
}"
color="outline"
size="sm"
@click="visualStore.downloadLatestVersion"
>
Update
</FormButton>
<div class="font-thin text-xs text-gray-400">
v{{ visualStore.receiveInfo.version }}
</div>
<button
class="text-gray-400 hover:text-gray-700 transition"
title="Hide navbar"
@click="visualStore.toggleNavbar()"
>
<ChevronUpIcon class="w-4 h-4" />
</button>
</div>
<ChevronUpIcon class="w-4 h-4" />
</button>
</div>
</nav>
</transition>
</div>
</nav>
</transition>
<div
v-if="!isInteractive"
class="absolute left-1/2 -translate-x-1/2 z-20 bg-white bg-opacity-70 text-black text-center text-xs px-4 py-1 rounded shadow font-medium cursor-default transition-all duration-300"
:class="visualStore.isNavbarHidden ? 'top-1' : 'top-11'"
>
<strong>Object IDs</strong>
field is needed for interactivity with other visuals.
</div>
<!-- TODO: another transition here needed that below components - but this time it will move to left -->
<div v-if="visualStore.isNavbarHidden" class="fixed top-0 right-0 z-20">
<button
class="transition opacity-50 hover:opacity-100"
title="Show navbar"
@click="visualStore.toggleNavbar()"
>
<ChevronDownIcon class="w-4 h-4 text-gray-400" />
</button>
</div>
<transition name="slide-left">
<ViewerControls
v-show="!visualStore.isNavbarHidden"
v-model:section-box="bboxActive"
:views="views"
class="fixed top-11 left-2 z-30"
@view-clicked="(view) => viewerHandler.setView(view)"
@view-mode-clicked="(viewMode) => viewerHandler.setViewMode(viewMode)"
/>
</transition>
<div v-if="visualStore.isFilterActive" class="absolute bottom-5 left-1/2 -translate-x-1/2 z-50">
<FormButton size="sm" @click="visualStore.resetFilters(), selectionHandler.reset()">
Reset filters
</FormButton>
</div>
<div
class="absolute z-10 flex items-center text-xs cursor-pointer"
:class="visualStore.isBrandingHidden ? 'bottom-0 right-0' : 'bottom-2 right-2'"
@click.stop="goToSpeckleWebsite"
>
<!-- TODO: fade bottom here as transition -->
<transition name="fade-bottom">
<div
v-if="!visualStore.isBrandingHidden"
class="flex items-center justify-center font-thin"
>
<div class="">Powered by</div>
<img class="w-4 h-auto mx-1" src="@assets/logo-big.png" />
<div class="font-medium">Speckle</div>
</div>
</transition>
<button
v-if="visualStore.receiveInfo && visualStore.receiveInfo.canHideBranding"
class="transition opacity-50 hover:opacity-100 ml-1"
:title="visualStore.isBrandingHidden ? '' : 'Hide branding'"
@click.stop="visualStore.toggleBranding()"
>
<ChevronUpIcon v-if="visualStore.isBrandingHidden" class="w-4 h-4 text-gray-400" />
<ChevronDownIcon v-else class="w-4 h-4" />
</button>
</div>
<div
ref="container"
class="fixed h-full w-full z-0 cursor-default"
@click="onCanvasClick"
@auxclick="onCanvasAuxClick"
/>
<div
v-if="!isInteractive"
class="absolute top-1 left-1/2 -translate-x-1/2 z-20 bg-white bg-opacity-70 text-black text-center text-xs px-4 py-1 rounded shadow font-medium"
>
<strong>Object IDs</strong>
field is needed for interactivity with other visuals.
</div>
<div v-if="isNavbarCollapsed" class="fixed top-2 right-0 z-20">
<button
class="transition opacity-50 hover:opacity-100"
title="Show navbar"
@click="isNavbarCollapsed = false"
>
<ChevronDownIcon class="w-4 h-4 text-gray-400" />
</button>
</div>
<!-- till here -->
<transition name="slide-left">
<ViewerControls
v-show="!isNavbarCollapsed"
v-model:section-box="bboxActive"
:views="views"
class="fixed top-11 left-1 z-30"
@view-clicked="(view) => viewerHandler.setView(view)"
@view-mode-clicked="(viewMode) => viewerHandler.setViewMode(viewMode)"
/>
</transition>
<div v-if="visualStore.isFilterActive" class="absolute bottom-5 left-1/2 -translate-x-1/2 z-50">
<FormButton size="sm" @click="visualStore.resetFilters(), selectionHandler.reset()">
Reset filters
</FormButton>
</div>
<div
ref="container"
class="fixed h-full w-full z-0"
@click="onCanvasClick"
@auxclick="onCanvasAuxClick"
/>
</template>
<script async setup lang="ts">
@@ -138,7 +84,6 @@ import { useVisualStore } from '@src/store/visualStore'
import { ViewerHandler } from '@src/plugins/viewer'
import { selectionHandlerKey, tooltipHandlerKey } from '@src/injectionKeys'
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/vue/24/outline'
import WorkspaceAvatar from './workspace/WorkspaceAvatar.vue'
const visualStore = useVisualStore()
const { dragged } = useClickDragged()
@@ -152,6 +97,8 @@ const container = ref<HTMLElement>()
let bboxActive = ref(false)
let views: Ref<SpeckleView[]> = ref([])
const isNavbarCollapsed = ref(false)
const isInteractive = computed(
() => visualStore.fieldInputState.rootObjectId && visualStore.fieldInputState.objectIds
)
@@ -162,10 +109,6 @@ onMounted(async () => {
console.log('Viewer Wrapper mounted')
viewerHandler = new ViewerHandler()
await viewerHandler.init(container.value)
// Set up event listener for object clicks from the FilteredSelectionExtension
viewerHandler.emitter.on('objectClicked', handleObjectClicked)
visualStore.setViewerEmitter(viewerHandler.emit)
})
@@ -173,59 +116,43 @@ onBeforeUnmount(async () => {
await viewerHandler.dispose()
})
async function handleObjectClicked(hit: any, isMultiSelect: boolean, mouseEvent?: PointerEvent) {
// Skip if dragging occurred
function isMultiSelect(e: MouseEvent) {
if (!e) return false
if (currentOS === OS.MacOS) return e.metaKey || e.shiftKey
else return e.ctrlKey || e.shiftKey
}
async function onCanvasClick(ev: MouseEvent) {
if (dragged.value) return
console.log('🎯 Object clicked in ViewerWrapper:', hit, isMultiSelect)
const intersectResult = await viewerHandler.intersect({ x: ev.clientX, y: ev.clientY })
const multi = isMultiSelect(ev)
const hit = intersectResult?.hit
if (hit) {
visualStore.setPostClickSkipNeeded(true)
const id = hit.object.id as string
if (isMultiSelect || !selectionHandler.isSelected(id)) {
await selectionHandler.select(id, isMultiSelect)
if (multi || !selectionHandler.isSelected(id)) {
await selectionHandler.select(id, multi)
}
// Show tooltip if we have mouse coordinates
if (mouseEvent) {
tooltipHandler.show(hit, { x: mouseEvent.clientX, y: mouseEvent.clientY })
}
tooltipHandler.show(hit, { x: ev.clientX, y: ev.clientY })
const selection = selectionHandler.getCurrentSelection()
const ids = selection.map((s) => s.id)
await viewerHandler.selectObjects(ids)
} else {
visualStore.setPostClickSkipNeeded(false)
tooltipHandler.hide()
if (!isMultiSelect) {
if (!multi) {
selectionHandler.clear()
await viewerHandler.selectObjects(null)
}
}
}
function onCanvasClick(ev: MouseEvent) {
// This click handler allows the viewer's built-in input system to handle clicks
// The viewer will emit ViewerEvent.ObjectClicked events which the SelectionExtension handles
console.log('🖱️ Canvas click detected:', ev.clientX, ev.clientY)
// Let the event propagate to the viewer's input system
// The viewer should handle the click and emit ViewerEvent.ObjectClicked
}
async function onCanvasAuxClick(ev: MouseEvent) {
if (ev.button !== 2 || dragged.value) return
// For right-clicks, we need to get the object at the click position
// Since FilteredSelectionExtension doesn't handle right-clicks, we'll ask it for current selection
const selectedObjects = viewerHandler.selection.getSelectedObjects()
const hit = selectedObjects.length > 0 ? {
guid: selectedObjects[0].id,
object: selectedObjects[0],
point: { x: 0, y: 0, z: 0 } // We don't have exact point for context menu
} : null
await selectionHandler.showContextMenu(ev, hit)
if (ev.button != 2 || dragged.value) return
const intersectResult = await viewerHandler.intersect({ x: ev.clientX, y: ev.clientY })
await selectionHandler.showContextMenu(ev, intersectResult?.hit)
}
</script>
@@ -254,19 +181,4 @@ async function onCanvasAuxClick(ev: MouseEvent) {
opacity: 0;
transform: translateX(-20px);
}
.fade-bottom-enter-active,
.fade-bottom-leave-active {
transition: all 0.3s ease;
}
.fade-bottom-enter-from,
.fade-bottom-leave-to {
opacity: 0;
transform: translateY(10px);
}
.fade-bottom-enter-to,
.fade-bottom-leave-from {
opacity: 1;
transform: translateY(0);
}
</style>
@@ -1,24 +0,0 @@
<template>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3.75 3.75V8.25M3.75 3.75H8.25M3.75 3.75L9 9M20.25 3.75H15.75M20.25 3.75V8.25M20.25 3.75L15 9"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M15.75 15.4028L18.8093 12.3435C18.8772 12.2756 18.9638 12.2294 19.0581 12.2107C19.1523 12.1919 19.25 12.2016 19.3387 12.2383C19.4275 12.2751 19.5034 12.3373 19.5568 12.4172C19.6102 12.4971 19.6388 12.591 19.6389 12.687V20.063C19.6388 20.159 19.6102 20.2529 19.5568 20.3328C19.5034 20.4127 19.4275 20.4749 19.3387 20.5117C19.25 20.5484 19.1523 20.5581 19.0581 20.5393C18.9638 20.5206 18.8772 20.4744 18.8093 20.4065L15.75 17.3472M8.45833 20.75H14.2917C14.6784 20.75 15.0494 20.5964 15.3229 20.3229C15.5964 20.0494 15.75 19.6784 15.75 19.2917V13.4583C15.75 13.0716 15.5964 12.7006 15.3229 12.4271C15.0494 12.1536 14.6784 12 14.2917 12H8.45833C8.07156 12 7.70063 12.1536 7.42714 12.4271C7.15365 12.7006 7 13.0716 7 13.4583V19.2917C7 19.6784 7.15365 20.0494 7.42714 20.3229C7.70063 20.5964 8.07156 20.75 8.45833 20.75Z"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
@@ -1,46 +1,22 @@
<template>
<div class="w-full text-xs text-foreground-on-primary space-y-1">
<!-- Bar container -->
<div
:class="[
'w-full h-1 overflow-hidden rounded-xl bg-blue-500/30',
showBar ? 'opacity-100' : 'opacity-0'
]"
>
<!-- Swooshing animation -->
<div v-if="isIndeterminate" class="swoosher top-0 left-0 h-full bg-blue-500/50"></div>
<!-- Determinate progress bar -->
<div
v-else
class="top-0 left-0 h-full bg-blue-500 transition-all duration-300 ease-linear"
:style="{ width: `${progressPercent + 20}%` }"
></div>
</div>
<!-- Progress text below -->
<div v-if="isIndeterminate" class="text-[13px] text-center text-foreground-2">
{{ props.progress.summary }}
</div>
<div v-else class="text-[13px] text-center text-foreground-2">
{{ progressPercent.toFixed(0) }}% ({{ props.progress.summary }})
</div>
<div
:class="[
'relative w-full h-1 bg-blue-500/30 text-xs text-foreground-on-primary overflow-hidden rounded-xl',
showBar ? 'opacity-100' : 'opacity-0'
]"
>
<div class="swoosher relative top-0 bg-blue-500/50"></div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useMounted } from '@vueuse/core'
import { LoadingProgress } from '@src/store/visualStore'
import { computed } from 'vue'
const props = defineProps<{ progress: LoadingProgress; clientOnly?: boolean }>()
const props = defineProps<{ loading: boolean; clientOnly?: boolean }>()
const mounted = useMounted()
const showBar = computed(() => (mounted.value || !props.clientOnly) && !!props.progress)
const isIndeterminate = computed(() => props.progress.progress == null)
const progressPercent = computed(() => (props.progress.progress ?? 0) * 100)
const showBar = computed(() => (mounted.value || !props.clientOnly) && props.loading)
</script>
<style scoped>
.swoosher {
width: 100%;
@@ -53,9 +29,11 @@ const progressPercent = computed(() => (props.progress.progress ?? 0) * 100)
0% {
transform: translateX(0) scaleX(0);
}
40% {
transform: translateX(0) scaleX(0.4);
}
100% {
transform: translateX(100%) scaleX(0.5);
}
@@ -1,6 +1,5 @@
<template>
<button
:title="tooltip"
:class="`transition rounded-lg w-8 md:w-10 h-8 md:h-10 shrink-0 flex items-center justify-center ${colorClasses} outline-none ${
props.flat ? '!w-7 md:!w-9' : 'border border-outline-2 w-8 md:w-10 shadow'
}`"
@@ -16,7 +15,6 @@ const props = defineProps<{
active?: boolean
flat?: boolean
secondary?: boolean
tooltip?: string
}>()
const colorClasses = computed(() => {
@@ -1,6 +1,6 @@
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
<template>
<ViewerMenu v-model:open="open" title="View modes">
<ViewerMenu v-model:open="open" tooltip="View modes">
<template #trigger-icon>
<ViewModes class="h-5 w-5" />
</template>
@@ -1,6 +1,6 @@
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
<template>
<ViewerMenu v-model:open="open" title="Views">
<ViewerMenu v-model:open="open" tooltip="Views">
<template #trigger-icon>
<Views class="w-5 h-5" />
</template>
@@ -1,7 +1,7 @@
<template>
<div class="flex shrink-0 overflow-hidden rounded-md border border-outline-2 bg-foundation-2">
<div
class="w-6 h-6 bg-center bg-contain bg-no-repeat flex items-center justify-center"
class="h-full w-full bg-cover bg-center bg-no-repeat flex items-center justify-center"
:style="logo ? { backgroundImage: `url('${logo}')` } : {}"
>
<span v-if="!logo" class="text-foreground-3 uppercase leading-none">
@@ -1,55 +0,0 @@
import { useVisualStore } from '@src/store/visualStore'
import { ref } from 'vue'
type Versions = {
Versions: Version[]
}
export type Version = {
Number: string
Url: string
Os: number
Architecture: number
Date: string
Prerelease: boolean
}
export function useUpdateConnector() {
const versions = ref<Version[]>([])
const latestAvailableVersion = ref<Version | null>(null)
async function checkUpdate() {
try {
await getVersions()
} catch (e) {
console.error(e)
}
}
async function getVersions() {
const visualStore = useVisualStore()
const response = await fetch(`https://releases.speckle.dev/manager2/feeds/powerbi-v3.json`, {
method: 'GET'
})
if (!response.ok) {
throw new Error('Failed to fetch versions')
}
const data = (await response.json()) as unknown as Versions
const sortedVersions = data.Versions.sort(function (a: Version, b: Version) {
return new Date(b.Date).getTime() - new Date(a.Date).getTime()
})
versions.value = sortedVersions
const sanitizedVersion = sanitizeVersion(sortedVersions[0].Number)
latestAvailableVersion.value = { ...sortedVersions[0], Number: sanitizedVersion }
visualStore.setLatestAvailableVersion(latestAvailableVersion.value)
}
function sanitizeVersion(version: string): string {
const match = version.match(/\d+\.\d+\.\d+/)
return match ? match[0] : version // fallback to original version
}
return { checkUpdate }
}
@@ -1,155 +0,0 @@
import {
CameraController,
FilteringExtension,
NodeRenderView,
SelectionEvent,
SelectionExtension,
TreeNode,
ObjectLayers,
IViewer,
ExtendedIntersection
} from '@speckle/viewer'
import { Vector2, Vector3 } from 'three'
export enum FilteredSelectionEvent {
FilteredObjectClicked = 'filtered-object-clicked'
}
export interface FilteredSelectionEventPayload {
[FilteredSelectionEvent.FilteredObjectClicked]: SelectionEvent | null
}
export class FilteredSelectionExtension extends SelectionExtension {
// We're adding the Filtering Extension
public get inject(): Array<new (viewer: IViewer, ...args: any[]) => any> {
return [...super.inject, FilteringExtension]
}
public constructor(
viewer: IViewer,
protected cameraProvider: CameraController,
protected filtering: FilteringExtension
) {
super(viewer, cameraProvider)
}
public on<T extends FilteredSelectionEvent>(
eventType: T,
listener: (arg: FilteredSelectionEventPayload[T]) => void
): void {
super.on(eventType, listener)
}
protected isVisibleForSelection(id: string): boolean
protected isVisibleForSelection(rv: NodeRenderView): boolean
protected isVisibleForSelection(input: string | NodeRenderView): boolean {
if (input instanceof NodeRenderView) return this.isVisibleForSelectionRv(input)
else if (typeof input === 'string') return this.isVisibleForSelectionId(input)
return false
}
protected isVisibleForSelectionId(id: string): boolean {
// The current filtering state
const filteringState = this.filtering.filteringState
// If there are no isolated objects, all objects are visible for selection
if (!filteringState.isolatedObjects || filteringState.isolatedObjects.length === 0) {
return true
}
// If there are isolated objects, only those objects are visible for selection
return filteringState.isolatedObjects.includes(id)
}
protected isVisibleForSelectionRv(rv: NodeRenderView): boolean {
// The current filtering state
const filteringState = this.filtering.filteringState
// If there are no isolated objects, all objects are visible for selection
if (!filteringState.isolatedObjects || filteringState.isolatedObjects.length === 0) {
return true
}
// Check if this render view belongs to any of the isolated objects
for (let k = 0; k < filteringState.isolatedObjects.length; k++) {
const rvs = this.viewer
.getWorldTree()
.getRenderTree()
.getRenderViewsForNodeId(filteringState.isolatedObjects[k])
if (rvs.includes(rv)) return true
}
return false
}
protected onObjectClicked(selection: SelectionEvent | null) {
console.log('🎯 FilteredSelectionExtension.onObjectClicked called with:', selection)
if (!selection) {
console.log('🎯 No selection, calling super with null')
super.onObjectClicked(selection)
return
}
const filteredHits = []
const filteredSelection = selection
? {
event: selection.event,
hits: filteredHits,
multiple: selection.multiple
}
: null
if (filteredSelection) {
for (const hit of selection.hits) {
console.log('🎯 Checking hit:', hit.node.model.id, 'isVisible:', this.isVisibleForSelection(hit.node.model.id))
if (this.isVisibleForSelection(hit.node.model.id)) {
filteredHits.push(hit)
}
}
}
console.log('🎯 Filtered hits:', filteredHits.length)
// Call base class with the filtered selection
if (filteredSelection && filteredSelection.hits.length) {
super.onObjectClicked(filteredSelection)
this.emit(FilteredSelectionEvent.FilteredObjectClicked, filteredSelection)
} else {
// If no valid hits, treat as empty selection
super.onObjectClicked(null)
}
}
protected onPointerMove(e: Vector2 & { event: Event }) {
if (!this._enabled) return
const camera = this.viewer.getRenderer().renderingCamera
if (!camera) return
if (!this.options.hoverMaterialData) return
const result =
(this.viewer
.getRenderer()
.intersections.intersect(
this.viewer.getRenderer().scene,
camera,
e,
[
ObjectLayers.STREAM_CONTENT_MESH,
ObjectLayers.STREAM_CONTENT_POINT,
ObjectLayers.STREAM_CONTENT_LINE,
ObjectLayers.STREAM_CONTENT_TEXT
],
true,
this.viewer.getRenderer().clippingVolume
) as ExtendedIntersection[]) || []
let rv = null
for (let k = 0; k < result.length; k++) {
rv = this.viewer.getRenderer().renderViewFromIntersection(result[k])
if (this.isVisibleForSelection(rv)) break
else rv = null
}
this.applyHover(rv)
}
}
@@ -1,103 +0,0 @@
import { useVisualStore } from '@src/store/visualStore'
import ObjectLoader from '@speckle/objectloader' // Default import for v1
interface SpeckleObject {
id: string
speckle_type?: string
[key: string]: any
}
export class SpeckleApiLoader {
private serverUrl: string
private token: string
private projectId: string
private headers: Record<string, string>
constructor(serverUrl: string, projectId: string, token: string) {
this.serverUrl = serverUrl.replace(/\/$/, '')
this.projectId = projectId
this.token = token
this.headers = {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
}
async downloadObjectsWithChildren(objectId: string, onProgress?: (loaded: number, total: number) => void): Promise<SpeckleObject[]> {
const visualStore = useVisualStore()
visualStore.setLoadingProgress('Initializing object loader', 0)
console.log('Creating ObjectLoader v1 for Power BI environment')
// Create ObjectLoader v1 instance - use 'token' not 'authToken'
const loader = new ObjectLoader({
serverUrl: this.serverUrl,
streamId: this.projectId,
objectId: objectId,
token: this.token,
options: {
enableCaching: false, // Disable caching for Power BI environment
}
})
try {
// Get total count for progress tracking
const totalCount = await loader.getTotalObjectCount()
console.log(`Loading ${totalCount} objects using ObjectLoader v1`)
const objects: SpeckleObject[] = []
let loadedCount = 0
// Stream all objects using the async iterator
for await (const obj of loader.getObjectIterator()) {
objects.push(obj as SpeckleObject) // Type assertion since ObjectLoader v1 has different type
loadedCount++
// Update progress
if (onProgress) {
onProgress(loadedCount, totalCount)
}
const progress = totalCount > 0 ? loadedCount / totalCount : 0
visualStore.setLoadingProgress('🌍 Loading from Speckle', progress)
// Log progress every 100 objects
if (loadedCount % 100 === 0) {
console.log(`Loaded ${loadedCount}/${totalCount} objects`)
}
}
console.log(`Downloaded ${objects.length} objects using ObjectLoader v1`)
visualStore.setLoadingProgress('Download complete', 1)
return objects
} catch (error) {
console.error('Error loading objects:', error)
throw error
} finally {
// ObjectLoader v1 cleanup
if (loader.dispose) {
loader.dispose()
}
}
}
async downloadFromVersionId(versionId: string): Promise<SpeckleObject[]> {
// For version IDs, we can't avoid GraphQL entirely as we need to resolve the referenced object
// However, this method might not be used if we're getting object IDs directly from the data connector
throw new Error('Version ID downloads not supported with weak tokens. Use object IDs directly.')
}
async downloadMultipleModels(objectIds: string[]): Promise<SpeckleObject[][]> {
const allObjects: SpeckleObject[][] = []
for (const objectId of objectIds) {
const objects = await this.downloadObjectsWithChildren(objectId)
allObjects.push(objects)
}
return allObjects
}
}
+69 -97
View File
@@ -1,6 +1,7 @@
import {
DefaultViewerParams,
FilteringState,
IntersectionQuery,
CameraController,
CanonicalView,
ViewModes,
@@ -10,12 +11,8 @@ import {
Viewer,
HybridCameraController,
SelectionExtension,
FilteringExtension,
UpdateFlags,
ViewerEvent,
SelectionEvent
FilteringExtension
} from '@speckle/viewer'
import { FilteredSelectionExtension, FilteredSelectionEvent } from '@src/extensions/FilteredSelectionExtension'
import { SpeckleObjectsOfflineLoader } from '@src/laoder/SpeckleObjectsOfflineLoader'
import { useVisualStore } from '@src/store/visualStore'
import { Tracker } from '@src/utils/mixpanel'
@@ -38,8 +35,8 @@ export interface Hit {
export interface IViewerEvents {
ping: (message: string) => void
setSelection: (objectIds: string[]) => void
resetFilter: (objectIds: string[], ghost: boolean, zoom: boolean) => void
filterSelection: (objectIds: string[], ghost: boolean, zoom: boolean) => void
resetFilter: (objectIds: string[]) => void
filterSelection: (objectIds: string[], ghost: boolean) => void
setViewMode: (viewMode: ViewMode) => void
colorObjectsByGroup: (
colorById: {
@@ -53,8 +50,6 @@ export interface IViewerEvents {
toggleProjection: () => void
toggleGhostHidden: (ghost: boolean) => void
loadObjects: (objects: object[]) => void
objectsLoaded: () => void
objectClicked: (hit: Hit | null, isMultiSelect: boolean, mouseEvent?: PointerEvent) => void
}
export type ColorBy = {
@@ -67,7 +62,7 @@ export class ViewerHandler {
public viewer: Viewer
public cameraControls: CameraController
public filtering: FilteringExtension
public selection: FilteredSelectionExtension
public selection: SelectionExtension
private filteringState: FilteringState
constructor() {
@@ -84,7 +79,6 @@ export class ViewerHandler {
this.emitter.on('zoomExtends', this.zoomExtends)
this.emitter.on('zoomObjects', this.zoomObjects)
this.emitter.on('loadObjects', this.loadObjects)
this.emitter.on('objectsLoaded', this.handleObjectsLoaded)
this.emitter.on('toggleProjection', this.toggleProjection)
this.emitter.on('toggleGhostHidden', this.toggleGhostHidden)
}
@@ -93,24 +87,17 @@ export class ViewerHandler {
this.viewer = await createViewer(parent)
this.cameraControls = this.viewer.getExtension(CameraController)
this.filtering = this.viewer.getExtension(FilteringExtension)
this.selection = this.viewer.getExtension(FilteredSelectionExtension)
this.selection = this.viewer.getExtension(SelectionExtension)
const store = useVisualStore()
if (store.isOrthoProjection) {
this.cameraControls.toggleCameras()
}
this.viewer.on(ViewerEvent.LoadComplete, (arg: string) => {
store.clearLoadingProgress()
})
// Set up event listener for viewer's built-in object clicked events
this.viewer.on(ViewerEvent.ObjectClicked, (selection: SelectionEvent | null) => {
console.log('🎯 Viewer ObjectClicked event received:', selection)
})
// Set up event listener for filtered selection events
this.selection.on(FilteredSelectionEvent.FilteredObjectClicked, this.handleFilteredSelection)
// NOTE: storing camera position into file triggers `update` function. even if I early return according to flag - it slows down the usage a lot.
// this.cameraControls.on(CameraEvent.Stationary, () => {
// console.log('🎬 Storing the camera position into file')
// const cameraController = this.viewer.getExtension(CameraController)
// const position = cameraController.getPosition()
// const target = cameraController.getTarget()
// const store = useVisualStore()
// store.writeCameraPositionToFile(position, target)
// })
}
emit<E extends keyof IViewerEvents>(event: E, ...payload: Parameters<IViewerEvents[E]>): void {
@@ -122,16 +109,10 @@ export class ViewerHandler {
this.cameraControls.setCameraView(objectIds, animate)
}
public zoomExtends = () => {
this.cameraControls.setCameraView(undefined, true)
this.viewer.requestRender(UpdateFlags.RENDER_RESET)
}
public zoomExtends = () => this.cameraControls.setCameraView(undefined, false)
public toggleProjection = () => this.cameraControls.toggleCameras()
public setView = (view: CanonicalView) => {
this.cameraControls.setCameraView(view, false)
this.snapshotCameraPositionAndStore()
}
public setView = (view: CanonicalView) => this.cameraControls.setCameraView(view, false)
public setSectionBox = (bboxActive: boolean, objectIds: string[]) => {
// TODO
@@ -143,15 +124,6 @@ export class ViewerHandler {
viewModes.setViewMode(viewMode)
}
public snapshotCameraPositionAndStore = () => {
console.log('🎬 Storing the camera position into file')
const cameraController = this.viewer.getExtension(CameraController)
const position = cameraController.getPosition()
const target = cameraController.getTarget()
const store = useVisualStore()
store.writeCameraPositionToFile(position, target)
}
public selectObjects = (objectIds: string[]) => {
console.log('🔗 Handling setSelection inside ViewerHandler:', objectIds)
if (objectIds) {
@@ -159,24 +131,20 @@ export class ViewerHandler {
}
}
public filterSelection = (objectIds: string[], ghost: boolean, zoom: boolean = true) => {
public filterSelection = (objectIds: string[], ghost: boolean) => {
console.log('🔗 Handling filterSelection inside ViewerHandler')
if (objectIds) {
this.unIsolateObjects()
this.filteringState = this.filtering.isolateObjects(objectIds, 'powerbi', true, ghost)
if (zoom) {
this.zoomObjects(objectIds, true)
}
this.zoomObjects(objectIds, true)
}
}
public resetFilter = (objectIds: string[], ghost: boolean, zoom: boolean = true) => {
public resetFilter = (objectIds: string[]) => {
console.log('🔗 Handling filterSelection inside ViewerHandler')
if (objectIds) {
this.isolateObjects(objectIds, ghost)
if (zoom) {
this.zoomObjects(objectIds, true)
}
this.isolateObjects(objectIds, true)
this.zoomObjects(objectIds, true)
}
}
@@ -208,7 +176,25 @@ export class ViewerHandler {
}
}
public intersect = (coords: { x: number; y: number }) => {
const point = this.viewer.Utils.screenToNDC(coords.x, coords.y)
const intQuery: IntersectionQuery = {
operation: 'Pick',
point
}
const res = this.viewer.query(intQuery)
if (!res) {
this.selection.clearSelection()
return
}
return {
hit: this.pickViewableHit(res.objects),
objects: res.objects
}
}
public loadObjects = async (modelObjects: object[][]) => {
await this.viewer.unloadAll()
@@ -217,8 +203,7 @@ export class ViewerHandler {
const store = useVisualStore()
const speckleViews = []
// Use for...of loop to properly handle async operations
for (const objects of modelObjects) {
modelObjects.forEach(async (objects) => {
//@ts-ignore
const loader = new SpeckleObjectsOfflineLoader(this.viewer.getWorldTree(), objects)
@@ -231,7 +216,7 @@ export class ViewerHandler {
// Since you are setting another camera position, maybe you want the second argument to false
await this.viewer.loadObject(loader, true)
this.viewer.getRenderer().shadowcatcher.shadowcatcherMesh.visible = false // works fine only right after loadObjects
}
})
store.setSpeckleViews(speckleViews)
if (store.defaultViewModeInFile) {
@@ -240,9 +225,9 @@ export class ViewerHandler {
Tracker.dataLoaded({
sourceHostApp: store.receiveInfo.sourceApplication,
workspace_id: store.receiveInfo.workspaceId,
core_version: store.receiveInfo.version
workspace_id: store.receiveInfo.workspaceId
})
// camera need to be set after objects loaded
if (store.cameraPosition) {
const position = new Vector3(
store.cameraPosition[0],
@@ -256,51 +241,38 @@ export class ViewerHandler {
)
this.cameraControls.setCameraView({ position, target }, true)
}
// Emit objects loaded event to trigger update
this.emit('objectsLoaded')
}
private handlePing = (message: string) => {
console.log(message)
}
private handleObjectsLoaded = () => {
console.log('🎯 Objects loaded - triggering update')
const store = useVisualStore()
// Handle state restoration after objects are loaded
store.handleObjectsLoadedComplete()
}
private handleFilteredSelection = (selection: SelectionEvent | null) => {
console.log('🎯 Filtered selection event received:', selection)
let hit: Hit | null = null
let isMultiSelect = false
let mouseEvent: PointerEvent | undefined = undefined
if (selection && selection.hits.length > 0) {
// Convert the first hit to the Hit format expected by ViewerWrapper
const firstHit = selection.hits[0]
hit = {
guid: firstHit.node.model.id,
object: firstHit.node.model.raw,
point: {
x: firstHit.point.x,
y: firstHit.point.y,
z: firstHit.point.z
}
}
isMultiSelect = selection.multiple
mouseEvent = selection.event
private pickViewableHit(hits: Hit[]): Hit | null {
// The current filtering state
const filteringState = this.filtering.filteringState
// Are there any objects isolated?
const hasIsolatedObjects =
!!filteringState.isolatedObjects && filteringState.isolatedObjects.length !== 0
// Are there any objects hidden?
const hasHiddenObjects =
!!filteringState.hiddenObjects && filteringState.hiddenObjects.length !== 0
// No isolated or hidden objects? Return the first hit
if (hasIsolatedObjects && !hasHiddenObjects) {
return hits.find((h) => filteringState.isolatedObjects.includes(h.guid))
}
for (let k = 0; k < hits.length; k++) {
/** Return the first one that's not hidden or isolated. */
if (
hasIsolatedObjects &&
filteringState.isolatedObjects?.includes(hits[k].guid) &&
hasHiddenObjects &&
filteringState.hiddenObjects?.includes(hits[k].guid)
)
return hits[k]
}
// Emit the objectClicked event for ViewerWrapper to handle
this.emit('objectClicked', hit, isMultiSelect, mouseEvent)
}
public dispose() {
this.viewer.getExtension(CameraController).dispose()
this.viewer.dispose()
@@ -316,11 +288,11 @@ const createViewer = async (parent: HTMLElement): Promise<Viewer> => {
await viewer.init()
viewer.createExtension(HybridCameraController) // camera controller
viewer.createExtension(FilteringExtension) // filtering - must be created before FilteredSelectionExtension
viewer.createExtension(FilteredSelectionExtension) // filtered selection helper - depends on FilteringExtension
viewer.createExtension(SelectionExtension) // selection helper
// viewer.createExtension(SectionTool) // section tool, possibly not needed for now?
// viewer.createExtension(SectionOutlines) // section tool, possibly not needed for now?
// viewer.createExtension(MeasurementsExtension) // measurements, possibly not needed for now?
viewer.createExtension(FilteringExtension) // filtering
viewer.createExtension(ViewModes) // view modes
console.log('🎥 Viewer is created!')
@@ -1,15 +0,0 @@
import { formattingSettings as fs } from 'powerbi-visuals-utils-formattingmodel'
export class DataLoadingSettings extends fs.SimpleCard {
name = 'dataLoading'
displayName = 'Data Management'
public internalizeData = new fs.ToggleSwitch({
name: 'internalizeData',
displayName: 'Internalize Data',
description: 'When enabled, objects are downloaded and stored in the Power BI file for offline access. When disabled, objects are loaded directly from Speckle servers (online mode).',
value: false
})
slices: fs.Slice[] = [this.internalizeData]
}
@@ -1,18 +1,17 @@
import { formattingSettings as fs } from 'powerbi-visuals-utils-formattingmodel'
import { ColorSettings } from 'src/settings/colorSettings'
import { ColorSelectorSettings, ColorSettings } from 'src/settings/colorSettings'
import { CameraSettings } from 'src/settings/cameraSettings'
import { LightingSettings } from 'src/settings/lightingSettings'
import { DataLoadingSettings } from 'src/settings/dataLoadingSettings'
export class SpeckleVisualSettingsModel extends fs.Model {
// Building my visual formatting settings card
public color: ColorSettings = new ColorSettings()
public dataLoading: DataLoadingSettings = new DataLoadingSettings()
public colorSelector: ColorSelectorSettings = new ColorSelectorSettings()
// public camera: CameraSettings = new CameraSettings()
// public lighting: LightingSettings = new LightingSettings()
cards = [this.color, this.dataLoading]
cards = [this.color]
}
+33 -311
View File
@@ -1,13 +1,12 @@
import { CanonicalView, SpeckleView, ViewMode } from '@speckle/viewer'
import { Version } from '@src/composables/useUpdateConnector'
import { ColorBy, IViewerEvents } from '@src/plugins/viewer'
import { SpeckleVisualSettingsModel } from '@src/settings/visualSettingsModel'
import { SpeckleDataInput } from '@src/types'
import { ReceiveInfo } from '@src/utils/matrixViewUtils'
import { zipModelObjects } from '@src/utils/compression'
import { ReceiveInfo } from '@src/utils/matrixViewUtils'
import { defineStore } from 'pinia'
import { Vector3 } from 'three'
import { computed, ref, shallowRef } from 'vue'
import { ref, shallowRef } from 'vue'
export type InputState = 'valid' | 'incomplete' | 'invalid'
@@ -18,30 +17,16 @@ export type FieldInputState = {
tooltipData: boolean
}
export type LoadingProgress = { summary: string; progress: number; step?: string }
export const useVisualStore = defineStore('visualStore', () => {
const latestAvailableVersion = ref<Version | null>(null)
const host = shallowRef<powerbi.extensibility.visual.IVisualHost>()
const formattingSettings = ref<SpeckleVisualSettingsModel>()
const loadingProgress = ref<LoadingProgress>(undefined)
const objectsFromStore = ref<object[][]>(undefined)
// State tracking for toggle reset prevention
const previousToggleState = ref<boolean | undefined>(undefined)
const loadingProgress = ref<{ summary: string; progress: number }>(undefined)
const objectsFromStore = ref<object[]>(undefined)
const postFileSaveSkipNeeded = ref<boolean>(false)
const postClickSkipNeeded = ref<boolean>(false)
const isFilterActive = ref<boolean>(false)
const isBrandingHidden = ref<boolean>(false)
const isOrthoProjection = ref<boolean>(false)
const isGhostActive = ref<boolean>(true)
const isNavbarHidden = ref<boolean>(false)
const isZoomOnFilterActive = ref<boolean>(true)
const commonError = ref<string>(undefined)
// once you see this shit, you might freak out and you are right. All of them needed because of "update" function trigger by API.
// most of the time we need to know what we are doing to treat operations accordingly. Ask for more to me (Ogu), but the answers will make both of us unhappy.
@@ -86,30 +71,7 @@ export const useVisualStore = defineStore('visualStore', () => {
host.value = hostToSet
}
const setReceiveInfo = (newReceiveInfo: ReceiveInfo) => {
receiveInfo.value = newReceiveInfo
// Always save receiveInfo to file for credentials persistence (contains token and metadata)
// This ensures weak tokens are available even when desktop service is unavailable
if (formattingSettings.value?.dataLoading.internalizeData.value && objectsFromStore.value) {
// If internalize is ON and we have objects, save both objects and receiveInfo together
writeObjectsToFile(objectsFromStore.value)
} else {
// Otherwise just save receiveInfo alone (credentials only)
writeReceiveInfoToFile()
}
}
const setLatestAvailableVersion = (version: Version | null) => {
latestAvailableVersion.value = version
}
const isConnectorUpToDate = computed(() => {
if (receiveInfo.value && receiveInfo.value.version) {
return receiveInfo.value.version === latestAvailableVersion.value?.Number
}
return false
})
const setReceiveInfo = (newReceiveInfo: ReceiveInfo) => (receiveInfo.value = newReceiveInfo)
/**
* Ideally one time set when onMounted of `ViewerWrapper.vue` component
@@ -128,7 +90,7 @@ export const useVisualStore = defineStore('visualStore', () => {
}
}
const setObjectsFromStore = (newObjectsFromStore: object[][]) => {
const setObjectsFromStore = (newObjectsFromStore: object[]) => {
objectsFromStore.value = newObjectsFromStore
}
@@ -139,9 +101,7 @@ export const useVisualStore = defineStore('visualStore', () => {
}
}
const clearLoadingProgress = () => {
loadingProgress.value = undefined
}
const clearLoadingProgress = () => (loadingProgress.value = undefined)
// MAKE TS HAPPY
type SpeckleObject = {
@@ -149,24 +109,18 @@ export const useVisualStore = defineStore('visualStore', () => {
}
const loadObjectsFromFile = async (objects: object[][]) => {
console.log('📁 loadObjectsFromFile called with:', objects.length, 'models')
const savedVersionObjectId = objects.map((o) => (o[0] as SpeckleObject).id).join(',')
lastLoadedRootObjectId.value = savedVersionObjectId
viewerReloadNeeded.value = false
console.log(`📦 Loading viewer from cached data with ${lastLoadedRootObjectId.value} id.`)
console.log('📁 About to call viewerEmit loadObjects...')
await viewerEmit.value('loadObjects', objects)
console.log('📁 viewerEmit loadObjects completed')
clearLoadingProgress()
objectsFromStore.value = objects
isViewerObjectsLoaded.value = true
viewerReloadNeeded.value = false
setIsLoadingFromFile(false)
console.log('📁 loadObjectsFromFile completed successfully')
}
const setIsLoadingFromFile = (newValue: boolean) => (isLoadingFromFile.value = newValue)
/**
* Sets upcoming data input into store to be able to pass it through viewer by evaluating the data.
* @param newValue new data input that user dragged and dropped to the fields in visual
@@ -179,32 +133,19 @@ export const useVisualStore = defineStore('visualStore', () => {
lastLoadedRootObjectId.value = modelIds
console.log(`🔄 Forcing viewer re-render for new root object id.`)
await viewerEmit.value('loadObjects', dataInput.value.modelObjects)
clearLoadingProgress()
viewerReloadNeeded.value = false
isViewerObjectsLoaded.value = true
// Store the model objects for potential internalization
if (dataInput.value.modelObjects && dataInput.value.modelObjects.length > 0) {
console.log('📦 Storing modelObjects in visualStore for internalization:', dataInput.value.modelObjects.length, 'models')
objectsFromStore.value = dataInput.value.modelObjects
}
// Note: Object internalization is now handled by toggle in visual.ts
loadingProgress.value = undefined
writeObjectsToFile(dataInput.value.modelObjects)
}
if (dataInput.value.selectedIds.length > 0) {
isFilterActive.value = true
viewerEmit.value('filterSelection', dataInput.value.selectedIds, isGhostActive.value, isZoomOnFilterActive.value)
viewerEmit.value('filterSelection', dataInput.value.selectedIds, true)
} else {
isFilterActive.value = false
latestColorBy.value = dataInput.value.colorByIds
// Only apply filtering if object IDs are available, otherwise show all objects normally
if (fieldInputState.value.objectIds && dataInput.value.objectIds && dataInput.value.objectIds.length > 0) {
viewerEmit.value('resetFilter', dataInput.value.objectIds, isGhostActive.value, isZoomOnFilterActive.value)
} else {
// No object IDs provided - show all objects without any filtering
viewerEmit.value('unIsolateObjects')
}
viewerEmit.value('resetFilter', dataInput.value.objectIds)
}
viewerEmit.value('colorObjectsByGroup', dataInput.value.colorByIds)
}
@@ -220,23 +161,6 @@ export const useVisualStore = defineStore('visualStore', () => {
objectName: 'storedData',
properties: {
speckleObjects: compressedChunks,
receiveInfo: JSON.stringify(receiveInfo.value) // Keep receiveInfo in sync when storing objects
},
selector: null
}
]
})
}
const writeReceiveInfoToFile = () => {
// NOTE: need skipping the update function, it resets the viewer state unneccessarily.
postFileSaveSkipNeeded.value = true
host.value.persistProperties({
merge: [
{
objectName: 'storedData',
properties: {
receiveInfo: JSON.stringify(receiveInfo.value)
},
selector: null
@@ -261,54 +185,6 @@ export const useVisualStore = defineStore('visualStore', () => {
})
}
const writeIsOrthoToFile = () => {
// NOTE: need skipping the update function, it resets the viewer state unneccessarily.
postFileSaveSkipNeeded.value = true
host.value.persistProperties({
merge: [
{
objectName: 'camera',
properties: {
isOrtho: isOrthoProjection.value
},
selector: null
}
]
})
}
const writeIsGhostToFile = () => {
// NOTE: need skipping the update function, it resets the viewer state unneccessarily.
postFileSaveSkipNeeded.value = true
host.value.persistProperties({
merge: [
{
objectName: 'camera',
properties: {
isGhost: isGhostActive.value
},
selector: null
}
]
})
}
const writeZoomOnFilterToFile = () => {
// NOTE: need skipping the update function, it resets the viewer state unneccessarily.
postFileSaveSkipNeeded.value = true
host.value.persistProperties({
merge: [
{
objectName: 'camera',
properties: {
zoomOnFilter: isZoomOnFilterActive.value
},
selector: null
}
]
})
}
const writeViewModeToFile = (viewMode: ViewMode) => {
// NOTE: need skipping the update function, it resets the viewer state unneccessarily.
postFileSaveSkipNeeded.value = true
@@ -325,73 +201,25 @@ export const useVisualStore = defineStore('visualStore', () => {
})
}
const writeHideBrandingToFile = (brandingHidden: boolean) => {
// NOTE: need skipping the update function, it resets the viewer state unneccessarily.
postFileSaveSkipNeeded.value = true
host.value.persistProperties({
merge: [
{
objectName: 'workspace',
properties: {
brandingHidden: brandingHidden
},
selector: null
}
]
})
}
const writeNavbarVisibilityToFile = (navbarHidden: boolean) => {
// NOTE: need skipping the update function, it resets the viewer state unneccessarily.
postFileSaveSkipNeeded.value = true
host.value.persistProperties({
merge: [
{
objectName: 'viewMode',
properties: {
navbarHidden: navbarHidden
},
selector: null
}
]
})
}
const writeDataLoadingModeToFile = (internalizeData: boolean) => {
// NOTE: need skipping the update function, it resets the viewer state unneccessarily.
postFileSaveSkipNeeded.value = true
host.value.persistProperties({
merge: [
{
objectName: 'dataLoading',
properties: {
internalizeData: internalizeData
},
selector: null
}
]
})
}
const writeCameraPositionToFile = (position: Vector3, target: Vector3) => {
// NOTE: need skipping the update function, it resets the viewer state unneccessarily.
postFileSaveSkipNeeded.value = true
host.value.persistProperties({
merge: [
{
objectName: 'cameraPosition',
properties: {
positionX: position.x,
positionY: position.y,
positionZ: position.z,
targetX: target.x,
targetY: target.y,
targetZ: target.z
},
selector: null
}
]
})
// postFileSaveSkipNeeded.value = true
// host.value.persistProperties({
// merge: [
// {
// objectName: 'cameraPosition',
// properties: {
// positionX: position.x,
// positionY: position.y,
// positionZ: position.z,
// targetX: target.x,
// targetY: target.y,
// targetZ: target.z
// },
// selector: null
// }
// ]
// })
}
const setFieldInputState = (newFieldInputState: FieldInputState) =>
@@ -399,41 +227,12 @@ export const useVisualStore = defineStore('visualStore', () => {
const clearDataInput = () => (dataInput.value = null)
const setIsLoadingFromFile = (newValue: boolean) => (isLoadingFromFile.value = newValue)
const setViewerReadyToLoad = (newValue: boolean) => (isViewerReadyToLoad.value = newValue)
const setViewerReadyToLoad = () => (isViewerReadyToLoad.value = true)
const setViewerReloadNeeded = () => (viewerReloadNeeded.value = true)
const toggleBranding = () => {
isBrandingHidden.value = !isBrandingHidden.value
writeHideBrandingToFile(isBrandingHidden.value)
}
const setBrandingHidden = (val: boolean) => {
isBrandingHidden.value = val
}
const setNavbarHidden = (val: boolean) => {
isNavbarHidden.value = val
}
const toggleNavbar = () => {
isNavbarHidden.value = !isNavbarHidden.value
writeNavbarVisibilityToFile(isNavbarHidden.value)
}
const setIsOrthoProjection = (val: boolean) => {
isOrthoProjection.value = val
}
const setIsGhost = (val: boolean) => {
isGhostActive.value = val
}
const setIsZoomOnFilterActive = (val: boolean) => {
isZoomOnFilterActive.value = val
}
const setPostFileSaveSkipNeeded = (newValue: boolean) => (postFileSaveSkipNeeded.value = newValue)
const setPostClickSkipNeeded = (newValue: boolean) => (postClickSkipNeeded.value = newValue)
@@ -445,63 +244,13 @@ export const useVisualStore = defineStore('visualStore', () => {
(formattingSettings.value = newFormattingSettings)
const resetFilters = () => {
// Only apply filtering if object IDs are available, otherwise show all objects normally
if (fieldInputState.value.objectIds && dataInput.value && dataInput.value.objectIds && dataInput.value.objectIds.length > 0) {
viewerEmit.value('resetFilter', dataInput.value.objectIds, isGhostActive.value, isZoomOnFilterActive.value)
} else {
// No object IDs provided - show all objects without any filtering
viewerEmit.value('unIsolateObjects')
}
viewerEmit.value('resetFilter', dataInput.value.objectIds)
if (latestColorBy.value !== null) {
viewerEmit.value('colorObjectsByGroup', latestColorBy.value)
}
isFilterActive.value = false
}
const downloadLatestVersion = () => {
host.value.launchUrl(latestAvailableVersion.value?.Url as string)
}
const setCommonError = (error: string) => {
commonError.value = error
}
const handleObjectsLoadedComplete = () => {
console.log('🔄 Objects loaded - handling state restoration')
// If we have current data input with selections, restore them
if (dataInput.value) {
console.log('🔄 Restoring selection state after object load')
// Restore selection filters if they exist
if (dataInput.value.selectedIds.length > 0) {
isFilterActive.value = true
viewerEmit.value('filterSelection', dataInput.value.selectedIds, isGhostActive.value, isZoomOnFilterActive.value)
} else {
isFilterActive.value = false
latestColorBy.value = dataInput.value.colorByIds
// Only apply filtering if object IDs are available, otherwise show all objects normally
if (fieldInputState.value.objectIds && dataInput.value.objectIds && dataInput.value.objectIds.length > 0) {
viewerEmit.value('resetFilter', dataInput.value.objectIds, isGhostActive.value, isZoomOnFilterActive.value)
} else {
// No object IDs provided - show all objects without any filtering
viewerEmit.value('unIsolateObjects')
}
}
// Restore color grouping
viewerEmit.value('colorObjectsByGroup', dataInput.value.colorByIds)
}
// Trigger host data refresh to synchronize with Power BI
host.value.refreshHostData()
}
// Toggle state tracking functions
const setPreviousToggleState = (state: boolean) => {
previousToggleState.value = state
}
return {
host,
receiveInfo,
@@ -525,23 +274,7 @@ export const useVisualStore = defineStore('visualStore', () => {
isFilterActive,
latestColorBy,
formattingSettings,
isBrandingHidden,
isOrthoProjection,
isGhostActive,
isNavbarHidden,
isZoomOnFilterActive,
latestAvailableVersion,
isConnectorUpToDate,
commonError,
previousToggleState,
setCommonError,
setLatestAvailableVersion,
setIsOrthoProjection,
setIsGhost,
setIsZoomOnFilterActive,
setFormattingSettings,
setBrandingHidden,
setNavbarHidden,
setPostClickSkipNeeded,
setPostFileSaveSkipNeeded,
setCameraPositionInFile,
@@ -554,16 +287,8 @@ export const useVisualStore = defineStore('visualStore', () => {
setObjectsFromStore,
writeObjectsToFile,
writeCameraViewToFile,
writeIsGhostToFile,
writeZoomOnFilterToFile,
writeIsOrthoToFile,
writeViewModeToFile,
writeCameraPositionToFile,
writeHideBrandingToFile,
writeNavbarVisibilityToFile,
writeDataLoadingModeToFile,
toggleBranding,
toggleNavbar,
setViewerEmitter,
setDataInput,
setFieldInputState,
@@ -572,9 +297,6 @@ export const useVisualStore = defineStore('visualStore', () => {
setLoadingProgress,
clearLoadingProgress,
setIsLoadingFromFile,
resetFilters,
downloadLatestVersion,
handleObjectsLoadedComplete,
setPreviousToggleState
resetFilters
}
})
+114 -185
View File
@@ -10,9 +10,6 @@ import { SpeckleVisualSettingsModel } from 'src/settings/visualSettingsModel'
import { FieldInputState, useVisualStore } from '@src/store/visualStore'
import { delay } from 'lodash'
import { getSlugFromHostAppNameAndVersion } from './hostAppSlug'
import { useUpdateConnector } from '@src/composables/useUpdateConnector'
import { SpeckleApiLoader } from '@src/loader/SpeckleApiLoader'
import { unzipModelObjects } from './compression'
export class AsyncPause {
private lastPauseTime = 0
@@ -160,8 +157,6 @@ export type ReceiveInfo = {
workspaceName?: string
canHideBranding: boolean
version?: string
token: string
projectId?: string
}
async function getReceiveInfo(id) {
@@ -176,42 +171,108 @@ async function getReceiveInfo(id) {
return await response.json()
} catch (error) {
console.log(error)
console.log("User info couldn't retrieved from local server.")
console.log("User infp couldn't retrieved from local server.")
}
}
async function fetchFromSpeckleApi(
objectIds: string,
serverUrl: string,
projectId: string,
token: string
): Promise<object[][]> {
const ids = objectIds.split(',')
async function fetchStreamedData(commaSeparatedModelIds: string) {
const modelIds = (commaSeparatedModelIds as string).split(',')
const modelObjects = []
for (const objectId of ids) {
try {
console.log(`Downloading from Speckle API: ${objectId}`)
const loader = new SpeckleApiLoader(serverUrl, projectId, token)
const objects = await loader.downloadObjectsWithChildren(objectId)
modelObjects.push(objects)
console.log(`Downloaded ${objects.length} objects from Speckle`)
} catch (error) {
console.error(`Failed to download objects from Speckle:`, error)
throw error
}
for await (const id of modelIds) {
const objects = await fetchStreamedDataForModel(id)
modelObjects.push(objects)
}
return modelObjects
}
async function fetchStreamedDataForModel(id) {
try {
const response = await fetch(`http://localhost:29364/get-objects/${id}`)
if (!response.body) {
console.error('No response body')
return
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
const objects = []
let buffer = ''
const start = performance.now()
console.log('Streaming started...')
for await (const chunk of readStream(reader)) {
// chucks.push(chuck)
buffer += decoder.decode(chunk, { stream: true })
let boundary
while ((boundary = buffer.indexOf('\n')) !== -1) {
const jsonString = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 1)
try {
const obj = JSON.parse(jsonString)
objects.push(obj)
// console.log('Received object:', jsonObject)
} catch (e) {
console.error('Invalid JSON chunk:', jsonString)
}
}
}
try {
const obj = JSON.parse(buffer)
objects.push(obj)
// console.log('Received object:', jsonObject)
} catch (e) {
console.error('Invalid JSON chunk:', buffer)
}
const end = performance.now()
console.log(`Objects streamed in: ${(end - start) / 1000} s`)
const startObjectCleanup = performance.now()
// Skips first element
for (let i = 1; i < objects.length; i++) {
const obj = objects[i]
if (obj.speckle_type) {
if (obj.speckle_type.includes('Objects.Data.DataObject')) {
delete obj.properties
}
}
delete obj.__closure
}
const endObjectCleanup = performance.now()
console.log(`Objects cleaned up in: ${(endObjectCleanup - startObjectCleanup) / 1000} s`)
const sizeInBytes = new TextEncoder().encode(JSON.stringify(objects)).length
const sizeInMB = sizeInBytes / (1024 * 1024)
console.log(`Size of objects: ${sizeInMB} MB`)
return objects
} catch (error) {
console.log(error)
console.log("Objects couldn't retrieved from local server.")
} finally {
console.log('Streaming finished!')
}
}
async function* readStream(reader) {
while (true) {
const { done, value } = await reader.read()
if (done) break
yield value
}
}
export async function processMatrixView(
matrixView: powerbi.DataViewMatrix,
host: powerbi.extensibility.visual.IVisualHost,
hasColorFilter: boolean,
settings: SpeckleVisualSettingsModel,
onSelectionPair: (objId: string, selectionId: powerbi.extensibility.ISelectionId) => void,
internalizedData?: string
onSelectionPair: (objId: string, selectionId: powerbi.extensibility.ISelectionId) => void
): Promise<SpeckleDataInput> {
const visualStore = useVisualStore()
const objectIds = [],
@@ -224,86 +285,10 @@ export async function processMatrixView(
const localMatrixView = matrixView.rows.root.children
let id = null
// Safety check for matrix data structure
if (!localMatrixView || localMatrixView.length === 0) {
throw new Error('Matrix view has no data rows')
}
try {
if (hasColorFilter) {
if (!localMatrixView[0].children || localMatrixView[0].children.length === 0 || !localMatrixView[0].children[0].values) {
throw new Error('Matrix view structure is incomplete for color filter mode')
}
id = localMatrixView[0].children[0].values[0].value as unknown as string
} else {
if (!localMatrixView[0].values || !localMatrixView[0].values[0]) {
throw new Error('Matrix view structure is incomplete for normal mode')
}
id = localMatrixView[0].values[0].value as unknown as string
}
} catch (error) {
console.error('Error accessing matrix data:', error)
throw new Error(`Failed to extract root object ID from matrix: ${error.message}`)
}
// Check for internalized data but ONLY if it matches current matrix data
let internalizedModelObjects: object[][] | undefined = undefined
if (settings.dataLoading.internalizeData.value && internalizedData) {
console.log('📁 Checking internalized data in processMatrixView')
try {
internalizedModelObjects = unzipModelObjects(internalizedData)
if (internalizedModelObjects && internalizedModelObjects.length > 0) {
// CRITICAL: Validate that internalized data matches current matrix data
const internalizedRootId = (internalizedModelObjects[0][0] as any).id
if (internalizedRootId !== id) {
console.log(`📁 Internalized data mismatch: stored=${internalizedRootId}, current=${id}. Using fresh data.`)
internalizedModelObjects = undefined // Clear internalized data - use fresh data instead
} else {
console.log(
'📁 Successfully validated internalized data matches current matrix:',
internalizedModelObjects.length,
'models'
)
}
}
if (internalizedModelObjects && internalizedModelObjects.length > 0) {
// Set dummy receiveInfo to prevent UI errors
if (!visualStore.receiveInfo) {
visualStore.setReceiveInfo({
userEmail: 'offline@speckle.systems',
serverUrl: 'offline',
sourceApplication: 'PowerBI Offline',
workspaceId: 'offline',
workspaceName: 'Offline Workspace',
workspaceLogo: '',
version: '1.0.0',
canHideBranding: false,
token: 'offline',
projectId: 'offline'
})
}
// Only reload if switching models or not already loaded
const needsReload = !visualStore.isViewerObjectsLoaded || visualStore.lastLoadedRootObjectId !== id
if (needsReload) {
console.log('🔄 Forcing viewer reload for internalized data (model switch or first load)')
visualStore.setViewerReloadNeeded()
visualStore.setViewerReadyToLoad(true)
visualStore.setLoadingProgress('📁 Loading from file', null)
} else {
console.log('📁 Internalized data already loaded, skipping reload')
}
visualStore.lastLoadedRootObjectId = id // Set to current ID to skip API calls
} else {
console.error('📁 Failed to unzip internalized data')
}
} catch (error) {
console.error('📁 Error processing internalized data:', error)
}
if (hasColorFilter) {
id = localMatrixView[0].children[0].values[0].value as unknown as string
} else {
id = localMatrixView[0].values[0].value as unknown as string
}
// const id = localMatrixView[0].values[0].value as unknown as string
@@ -312,91 +297,35 @@ export async function processMatrixView(
let modelObjects: object[][] = undefined
if (
visualStore.lastLoadedRootObjectId !== id &&
!visualStore.isLoadingFromFile &&
!internalizedModelObjects
) {
const start = performance.now()
if (visualStore.isLoadingFromFile) {
console.log('The data is loading from file, skipping the streaming it.')
}
if (visualStore.lastLoadedRootObjectId !== id && !visualStore.isLoadingFromFile) {
const start = performance.now()
visualStore.setViewerReadyToLoad()
visualStore.setLoadingProgress('Loading', null)
// stream data
modelObjects = await fetchStreamedData(id)
// Get receive info from desktop service to populate visual store
const receiveInfo = await getReceiveInfo(id)
if (receiveInfo) {
visualStore.setReceiveInfo({
userEmail: receiveInfo.email || receiveInfo.Email,
serverUrl: receiveInfo.server || receiveInfo.Server,
sourceApplication: getSlugFromHostAppNameAndVersion(
receiveInfo.sourceApplication || receiveInfo.SourceApplication
),
workspaceId: receiveInfo.workspaceId || receiveInfo.WorkspaceId,
workspaceName: receiveInfo.workspaceName || receiveInfo.WorkspaceName,
workspaceLogo: receiveInfo.workspaceLogo || receiveInfo.WorkspaceLogo,
version: receiveInfo.version || receiveInfo.Version,
canHideBranding: receiveInfo.canHideBranding ?? receiveInfo.CanHideBranding,
token: receiveInfo.weakToken || receiveInfo.WeakToken,
projectId: receiveInfo.projectId || receiveInfo.ProjectId
userEmail: receiveInfo.email,
serverUrl: receiveInfo.server,
sourceApplication: getSlugFromHostAppNameAndVersion(receiveInfo.sourceApplication),
workspaceId: receiveInfo.workspaceId,
workspaceName: receiveInfo.workspaceName,
workspaceLogo: receiveInfo.workspaceLogo,
version: receiveInfo.version,
canHideBranding: receiveInfo.canHideBranding
})
console.log(`Receive info retrieved from desktop service - credentials loaded`)
}
// Now get the data from visual store for Speckle API download
const token = visualStore.receiveInfo?.token
const serverUrl = visualStore.receiveInfo?.serverUrl
const projectId = visualStore.receiveInfo?.projectId
if (!token || !serverUrl || !projectId) {
visualStore.setCommonError(
'Missing Speckle credentials. Please refresh the data from the data connector.'
)
visualStore.setViewerReadyToLoad(false)
return {
modelObjects: [],
objectIds: [],
selectedIds: [],
colorByIds: null,
objectTooltipData: new Map(),
isFromStore: false
}
}
visualStore.setViewerReadyToLoad(true)
console.log('Downloading objects directly from Speckle API...')
console.log(`Server: ${serverUrl}, Project: ${projectId}, Object: ${id}`)
try {
modelObjects = await fetchFromSpeckleApi(id, serverUrl, projectId, token)
console.log('Successfully downloaded from Speckle API')
// Debug: Check what we're passing to the viewer
if (modelObjects && modelObjects.length > 0 && modelObjects[0].length > 0) {
console.log('ModelObjects structure:', {
totalModels: modelObjects.length,
firstModelObjectCount: modelObjects[0].length,
firstObject: modelObjects[0][0]
})
}
} catch (error) {
console.error('Failed to download from Speckle API:', error)
visualStore.setCommonError(`Failed to download objects from Speckle: ${error.message}`)
visualStore.setViewerReadyToLoad(false)
return {
modelObjects: [],
objectIds: [],
selectedIds: [],
colorByIds: null,
objectTooltipData: new Map(),
isFromStore: false
}
}
visualStore.setViewerReloadNeeded() // they should be marked as deferred action bc of update function complexity.
visualStore.setLoadingProgress('🌍 Loading objects into viewer', null)
console.log(`🚀 Upload is completed in ${(performance.now() - start) / 1000} s!`)
}
if (visualStore.receiveInfo && visualStore.receiveInfo.version) {
const { checkUpdate } = useUpdateConnector()
await checkUpdate()
console.log(`🚀 Upload is completed in ${(performance.now() - start) / 1000} s!`)
}
// If colors assigned, data arrives nested
@@ -535,11 +464,11 @@ export async function processMatrixView(
previousPalette = host.colorPalette['colorPalette']
return {
modelObjects: internalizedModelObjects || modelObjects, // Use internalized data if available
modelObjects,
objectIds,
selectedIds,
colorByIds: colorByIds.length > 0 ? colorByIds : null,
objectTooltipData,
isFromStore: !!internalizedModelObjects // true if loaded from internalized data
isFromStore: false
}
}
+1 -2
View File
@@ -31,8 +31,7 @@ export class Tracker {
// eslint-disable-next-line camelcase
server_id: hashedServer,
email: receiveInfo.userEmail,
isAnonymous: receiveInfo.userEmail === '',
core_version: receiveInfo.version
isAnonymous: receiveInfo.userEmail === ''
}
}
+12 -1
View File
@@ -1,3 +1,4 @@
import { FilteringState } from '@speckle/viewer'
import { OrthographicCamera, PerspectiveCamera } from 'three'
export function projectToScreen(cam: OrthographicCamera | PerspectiveCamera, loc) {
@@ -15,7 +16,17 @@ export interface Hit {
object?: Record<string, unknown>
point: { x: number; y: number; z: number }
}
export function pickViewableHit(hits: Hit[], state: FilteringState): Hit | null {
let hit = null
if (state.isolatedObjects) {
// Find the first hit contained in the isolated objects
hit = hits.find((hit) => {
const hitId = hit.object.id as string
return state.isolatedObjects.includes(hitId)
})
}
return hit
}
export const createViewerContainerDiv = (parent: HTMLElement) => {
const container = parent.appendChild(document.createElement('div'))
@@ -1,7 +1,19 @@
<template>
<div
v-if="visualStore.loadingProgress"
class="absolute top-1/2 left-1/2 w-1/2 -translate-x-1/2 z-20 text-center text-sm"
>
<!-- Progress Bar -->
<LoadingBar :loading="!!visualStore.loadingProgress"></LoadingBar>
</div>
<viewer-wrapper id="speckle-3d-view" class="h-full w-full cursor-default"></viewer-wrapper>
</template>
<script setup lang="ts">
import ViewerWrapper from 'src/components/ViewerWrapper.vue'
import { useVisualStore } from '../store/visualStore'
import LoadingBar from '@src/components/loading/LoadingBar.vue'
const visualStore = useVisualStore()
</script>
+26 -279
View File
@@ -4,13 +4,11 @@ import '../style/visual.css'
import { FormattingSettingsService } from 'powerbi-visuals-utils-formattingmodel'
import { createApp } from 'vue'
import App from './App.vue'
import VueTippy from 'vue-tippy'
import { selectionHandlerKey, tooltipHandlerKey } from 'src/injectionKeys'
import { SpeckleDataInput } from './types'
import { processMatrixView, ReceiveInfo, validateMatrixView } from './utils/matrixViewUtils'
import { SpeckleVisualSettingsModel } from './settings/visualSettingsModel'
import { unzipModelObjects } from './utils/compression'
import TooltipHandler from './handlers/tooltipHandler'
import SelectionHandler from './handlers/selectionHandler'
@@ -22,6 +20,7 @@ import ITooltipService = powerbi.extensibility.ITooltipService
import { pinia } from './plugins/pinia'
import { useVisualStore } from './store/visualStore'
import { unzipModelObjects } from './utils/compression'
// noinspection JSUnusedGlobalSymbols
export class Visual implements IVisual {
@@ -47,11 +46,6 @@ export class Visual implements IVisual {
console.log('🚀 Init Vue App')
createApp(App)
.use(pinia)
.use(VueTippy, {
defaultProps: {
theme: 'custom'
}
})
// .use(store, storeKey)
.provide(selectionHandlerKey, this.selectionHandler)
.provide(tooltipHandlerKey, this.tooltipHandler)
@@ -69,10 +63,6 @@ export class Visual implements IVisual {
public async update(options: VisualUpdateOptions) {
const visualStore = useVisualStore()
if (visualStore.commonError) {
visualStore.setCommonError(undefined)
visualStore.setViewerReadyToLoad(false)
}
if (visualStore.postFileSaveSkipNeeded) {
visualStore.setPostFileSaveSkipNeeded(false)
@@ -88,43 +78,13 @@ export class Visual implements IVisual {
// @ts-ignore
console.log('⤴️ Update type 👉', powerbi.VisualUpdateType[options.type])
this.formattingSettings = this.formattingSettingsService.populateFormattingSettingsModel(
SpeckleVisualSettingsModel,
options.dataViews[0]
)
visualStore.setFormattingSettings(this.formattingSettings)
console.log(
'Data Loading - Internalize Data:',
this.formattingSettings.dataLoading.internalizeData.value
)
// Handle toggle state changes
const currentToggleState = this.formattingSettings.dataLoading.internalizeData.value
const previousToggleState = visualStore.previousToggleState
// Detect user toggle changes
if (previousToggleState !== undefined && currentToggleState !== previousToggleState) {
console.log('🔄 User changed toggle from', previousToggleState, 'to', currentToggleState)
if (currentToggleState) {
// Toggle switched ON - internalize via streaming
if (visualStore.isViewerObjectsLoaded && visualStore.lastLoadedRootObjectId) {
console.log('📁 Toggle ON - starting internalization')
await this.internalizeCurrentViewerData()
} else {
console.log('📁 Toggle ON - no active session to internalize')
}
} else {
// Toggle switched OFF - remove internalized data
console.log('🗑️ Toggle OFF - removing internalized data')
this.removeInternalizedData()
}
}
// CRITICAL: Always update the previous state for next comparison
visualStore.setPreviousToggleState(currentToggleState)
console.log('Selector colors', this.formattingSettings.colorSelector)
try {
const matrixView = options.dataViews[0].matrix
@@ -144,10 +104,16 @@ export class Visual implements IVisual {
return
case powerbi.VisualUpdateType.Data:
try {
// read saved settings from file if any
console.log('🔍 Checking for other saved settings:')
// read saved data from file if any
if (
!visualStore.isViewerObjectsLoaded &&
this.isFirstViewerLoad &&
options.dataViews[0].metadata.objects
) {
const chunks = options.dataViews[0].metadata.objects.storedData
?.speckleObjects as string
const objectsFromFile = unzipModelObjects(chunks)
if (!visualStore.isViewerObjectsLoaded && options.dataViews[0].metadata.objects) {
if (options.dataViews[0].metadata.objects.viewMode?.defaultViewMode as string) {
console.log(
`Default View Mode: ${
@@ -160,32 +126,7 @@ export class Visual implements IVisual {
)
}
if (options.dataViews[0].metadata.objects.workspace?.brandingHidden as boolean) {
console.log(
`Branding Hidden: ${
options.dataViews[0].metadata.objects.workspace?.brandingHidden as boolean
}`
)
visualStore.setBrandingHidden(
options.dataViews[0].metadata.objects.workspace?.brandingHidden as boolean
)
}
if (options.dataViews[0].metadata.objects.viewMode?.navbarHidden as boolean) {
console.log(
`Navbar Hidden: ${
options.dataViews[0].metadata.objects.viewMode?.navbarHidden as boolean
}`
)
visualStore.setNavbarHidden(
options.dataViews[0].metadata.objects.viewMode?.navbarHidden as boolean
)
}
if (options.dataViews[0].metadata.objects.cameraPosition?.positionX as string) {
console.log(`Stored camera position is found`)
visualStore.setCameraPositionInFile([
Number(options.dataViews[0].metadata.objects.cameraPosition?.positionX),
Number(options.dataViews[0].metadata.objects.cameraPosition?.positionY),
@@ -195,96 +136,31 @@ export class Visual implements IVisual {
Number(options.dataViews[0].metadata.objects.cameraPosition?.targetZ)
])
}
const camera = options.dataViews[0].metadata.objects.camera
if (camera && 'isOrtho' in camera) {
console.log(
`Projection is ortho?: ${
options.dataViews[0].metadata.objects.camera?.isOrtho as boolean
}`
)
visualStore.setIsOrthoProjection(
options.dataViews[0].metadata.objects.camera?.isOrtho as boolean
)
}
if (camera && 'isGhost' in camera) {
console.log(
`Is ghost?: ${options.dataViews[0].metadata.objects.camera?.isGhost as boolean}`
)
visualStore.setIsGhost(
options.dataViews[0].metadata.objects.camera?.isGhost as boolean
)
}
if (camera && 'zoomOnFilter' in camera) {
console.log(
`Zoom on filter?: ${
options.dataViews[0].metadata.objects.camera?.zoomOnFilter as boolean
}`
)
visualStore.setIsZoomOnFilterActive(
options.dataViews[0].metadata.objects.camera?.zoomOnFilter as boolean
)
}
// Log persisted data loading setting but don't force sync
if (
options.dataViews[0].metadata.objects.dataLoading?.internalizeData !== undefined
) {
console.log(
`Stored Data Loading - Internalize Data: ${
options.dataViews[0].metadata.objects.dataLoading?.internalizeData as boolean
}`
)
}
// get receive info from file for persistence
// get receive info from file for mixpanel
try {
const receiveInfoFromFile = JSON.parse(
options.dataViews[0].metadata.objects.storedData?.receiveInfo as string
) as ReceiveInfo
// Don't call setReceiveInfo here as it would trigger another save
visualStore.receiveInfo = receiveInfoFromFile
visualStore.setReceiveInfo(receiveInfoFromFile)
} catch (error) {
console.warn(error)
console.log('missing stored receive info')
console.log('missing mixpanel info')
}
const savedVersionObjectId = objectsFromFile.map((o) => o[0].id).join(',')
if (visualStore.lastLoadedRootObjectId !== savedVersionObjectId) {
this.tryReadFromFile(objectsFromFile, visualStore)
}
}
// Check for internalized data
const internalizedData = options.dataViews[0].metadata.objects?.storedData
?.speckleObjects as string
const input = await processMatrixView(
matrixView,
this.host,
validationResult.colorBy,
this.formattingSettings,
(obj, id) => this.selectionHandler.set(obj, id),
internalizedData
(obj, id) => this.selectionHandler.set(obj, id)
)
this.updateViewer(input)
// Auto-internalize new API data if toggle is ON and this is fresh data (not from store)
// Imagine that user has a visual and select internalizing data and changes the data source
// This will automatically internalize the new data
if (
this.formattingSettings.dataLoading.internalizeData.value &&
input.modelObjects &&
input.modelObjects.length > 0 &&
!input.isFromStore
) {
console.log('📦 Auto-internalizing new API data since toggle is ON')
// Trigger internalization after objects are loaded
setTimeout(() => {
this.internalizeCurrentViewerData()
}, 2000) // avoid a race condition (i know)
}
} catch (error) {
console.error('Data update error', error ?? 'Unknown')
}
@@ -312,8 +188,9 @@ export class Visual implements IVisual {
}
public getFormattingModel(): powerbi.visuals.FormattingModel {
console.log('🎨 getFormattingModel called')
console.log('Showing Formatting settings', this.formattingSettings)
const model = this.formattingSettingsService.buildFormattingModel(this.formattingSettings)
console.log('Formatting model was created', model)
return model
}
@@ -321,7 +198,7 @@ export class Visual implements IVisual {
const visualStore = useVisualStore()
this.tooltipHandler.setup(input.objectTooltipData)
visualStore.setViewerReadyToLoad(true)
visualStore.setViewerReadyToLoad()
if (visualStore.isViewerInitialized && !visualStore.viewerReloadNeeded) {
visualStore.setDataInput(input)
@@ -329,13 +206,14 @@ export class Visual implements IVisual {
// we should give some time to Vue to render ViewerWrapper component to be able to have proper emitter setup. Happiness level 6/10
setTimeout(() => {
visualStore.setDataInput(input)
// visualStore.writeObjectsToFile(input.objects)
}, 250)
}
}
private tryReadFromFile(objectsFromFile: object[][], visualStore) {
visualStore.setViewerReadyToLoad(true)
visualStore.setIsLoadingFromFile(true)
visualStore.setViewerReadyToLoad()
visualStore.setIsLoadingFromFile(true) // to block unnecessary streaming data if bg service is running
setTimeout(() => {
visualStore.loadObjectsFromFile(objectsFromFile)
this.isFirstViewerLoad = false
@@ -343,137 +221,6 @@ export class Visual implements IVisual {
console.log(`${objectsFromFile.length} objects retrieved from persistent properties!`)
}
private async internalizeCurrentViewerData() {
const visualStore = useVisualStore()
// Get the current root object ID from the last loaded data
if (!visualStore.lastLoadedRootObjectId) {
console.log('📁 No root object ID to internalize')
return
}
try {
console.log('📁 Starting internalization via desktop service streaming...')
visualStore.setLoadingProgress('📦 Internalizing data...', null)
// Use desktop service for internalization
// TBD: getting objects from viewer caused two issue:
// - Data format -> we need to make an extra operation to match with the offline loader
// - Memory -> need to save data two times so sometimes causes memory issues
const rootObjectIds = visualStore.lastLoadedRootObjectId
const projectId = visualStore.receiveInfo?.projectId
// Handle federated models by processing each object ID separately
const objectIds = rootObjectIds.split(',')
let allStreamedObjects = []
for (const objectId of objectIds) {
console.log(`📁 Fetching objects for ID: ${objectId}`)
// For federated models, pass project ID explicitly to avoid "project id is not set" error
const url = projectId
? `http://localhost:29364/get-objects/${objectId}?projectId=${projectId}`
: `http://localhost:29364/get-objects/${objectId}`
const response = await fetch(url)
if (!response.body) {
console.error(`📁 No response body from desktop service for ${objectId}`)
continue
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let allObjectsData = ''
console.log(`📁 Streaming objects from desktop service for ${objectId}...`)
while (true) {
const { done, value } = await reader.read()
if (done) break
allObjectsData += decoder.decode(value, { stream: true })
}
// Parse NDJSON (newline-delimited JSON) format
const lines = allObjectsData.trim().split('\n')
const objectsForThisId = lines.map((line) => JSON.parse(line))
console.log(`📁 Streamed ${objectsForThisId.length} objects for ID ${objectId}`)
allStreamedObjects.push(...objectsForThisId)
}
const streamedObjects = allStreamedObjects
if (streamedObjects.length === 0) {
console.error('📁 No objects retrieved from desktop service')
visualStore.clearLoadingProgress()
return
}
console.log(`📁 Retrieved ${streamedObjects.length} total objects from desktop service`)
// Clean up objects to reduce file size (same as desktop service does)
const cleanedObjects = streamedObjects.map((obj: any, index: number) => {
// Skip first object (root), clean others
if (index === 0) return obj
const cleanedObj = { ...obj }
// Remove unnecessary properties
if (cleanedObj.speckle_type?.includes('Objects.Data.DataObject')) {
delete cleanedObj.properties
}
delete cleanedObj.__closure
return cleanedObj
})
console.log(`📁 Cleaned objects: ${cleanedObjects.length} total`)
// Wrap in array format expected by viewer (object[][])
const modelObjectsArray = [cleanedObjects]
// Use existing writeObjectsToFile method from visualStore
visualStore.writeObjectsToFile(modelObjectsArray)
// Clear loading message immediately when done
visualStore.clearLoadingProgress()
console.log('📁 Successfully internalized data via desktop service!')
} catch (error) {
console.error('📁 Failed to internalize via desktop service:', error)
// Clear loading message immediately on error
visualStore.clearLoadingProgress()
}
}
private removeInternalizedData() {
const visualStore = useVisualStore()
try {
// Clear stored data from PowerBI file
this.host.persistProperties({
merge: [
{
objectName: 'storedData',
properties: {
speckleObjects: null,
receiveInfo: null
},
selector: null
}
]
})
console.log('🗑️ Successfully removed internalized data from file!')
} catch (error) {
console.error('🗑️ Failed to remove internalized data:', error)
}
}
public async destroy() {
await this.clear()
}