Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9ff8ee5f7 | |||
| 30c2a2002c | |||
| 4502a48098 | |||
| f2ab186bd8 | |||
| fbe9672f81 | |||
| 2691902533 | |||
| 95294c6e6f | |||
| e70aa8ae4b | |||
| e65bf83995 | |||
| 74ade84015 | |||
| abc4bf11fe | |||
| 0bd1218d49 | |||
| e277ce686e | |||
| f3f5eddb7b | |||
| 28ce6b6a76 | |||
| fe483b7b2b | |||
| 72b4b9b589 | |||
| 57c0f198bd | |||
| 439bf56f47 | |||
| 3ecae7f493 | |||
| 843174f5b6 | |||
| 83cfa39be0 | |||
| e4401da357 | |||
| 222a6f8987 | |||
| c44b54616e | |||
| 7b22e929e0 | |||
| e1e6d4e640 | |||
| 7bdb80f801 | |||
| da774b631a | |||
| ca0765c862 | |||
| 87b64b7a11 | |||
| 41c4e642fe | |||
| 76febcfce6 | |||
| 8fbb5a3c9d | |||
| eecde37f8b | |||
| a21a960516 | |||
| 9e76f62ad0 | |||
| ab266fa410 |
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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&style=flat-square&logo=discourse&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&logo=read-the-docs&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&style=flat-square&logo=discourse&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&logo=read-the-docs&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>
|
||||
|
||||
Speckle’s connection to Power BI consists of two parts:
|
||||
|
||||
- **Data Connector** fetches the data you uploaded from AEC apps to Speckle.
|
||||
@@ -30,19 +22,19 @@ Speckle’s connection to Power BI consists of two parts:
|
||||
|
||||

|
||||
|
||||
## 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 
|
||||
|
||||
@@ -127,10 +91,12 @@ What is Speckle? Check our ](https://app.speckle.systems) ⇒ creating an account at our public server
|
||||
- [](https://app.speckle.systems) ⇒ creating an account at our public server
|
||||
|
||||
### Resources
|
||||
|
||||
- [](https://speckle.community) for help, feature requests or just to hang with other speckle enthusiasts, check out our community forum!
|
||||
- [](https://speckle.systems) our tutorials portal is full of resources to get you started using Speckle
|
||||
- [](https://docs.speckle.systems) reference on almost any end-user and developer functionality
|
||||
- [](https://speckle.guide/dev/) reference on almost any end-user and developer functionality
|
||||
|
||||

|
||||
|
||||
@@ -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,42 +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 record, optional filterKeys 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
|
||||
);
|
||||
|
||||
[DataSource.Kind = "Speckle", Publish="GetByUrl.Publish"]
|
||||
shared Speckle.GetByUrl = Value.ReplaceType(
|
||||
Speckle.LoadFunction("GetByUrl.pqm"),
|
||||
@@ -250,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
|
||||
@@ -272,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)
|
||||
@@ -303,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 = [
|
||||
@@ -316,42 +197,17 @@ 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",
|
||||
|
||||
@@ -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()[Key] otherwise 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]
|
||||
@@ -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
|
||||
@@ -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,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,196 +0,0 @@
|
||||
// function for extracting and flattening properties from Speckle objects
|
||||
(inputRecord as record, optional filterKeys as list) as record =>
|
||||
let
|
||||
// auto-extract properties if the input has a "properties" field
|
||||
ActualInput = if Record.HasFields(inputRecord, {"properties"}) then
|
||||
inputRecord[properties]
|
||||
else
|
||||
inputRecord,
|
||||
|
||||
// helper function to check if a key should be included
|
||||
ShouldIncludeKey = (keyName as text) as logical =>
|
||||
if filterKeys = null then
|
||||
true
|
||||
else
|
||||
List.Contains(filterKeys, keyName),
|
||||
|
||||
// define excluded paths
|
||||
ExcludedPaths = {
|
||||
"Composite Structure",
|
||||
"Material Quantities",
|
||||
"Parameters.Type Parameters.Structure"
|
||||
},
|
||||
|
||||
IsExcludedPath = (path as text) as logical =>
|
||||
List.AnyTrue(
|
||||
List.Transform(
|
||||
ExcludedPaths,
|
||||
(excludedPath) => Text.StartsWith(path, excludedPath)
|
||||
)
|
||||
),
|
||||
|
||||
// helper function to handle duplicate keys by adding suffixes
|
||||
AddUniqueKey = (existingRecord as record, newKey as text, newValue as any) as record =>
|
||||
let
|
||||
originalKey = newKey,
|
||||
counter = 1,
|
||||
|
||||
// find a unique key by adding suffix if needed
|
||||
FindUniqueKey = (testKey as text, testCounter as number) as text =>
|
||||
if Record.HasFields(existingRecord, {testKey}) then
|
||||
@FindUniqueKey(originalKey & "_" & Text.From(testCounter), testCounter + 1)
|
||||
else
|
||||
testKey,
|
||||
|
||||
uniqueKey = FindUniqueKey(newKey, counter),
|
||||
result = Record.AddField(existingRecord, uniqueKey, newValue)
|
||||
in
|
||||
result,
|
||||
|
||||
// enhanced combine function that handles duplicates and filtering
|
||||
SafeRecordCombine = (records as list) as record =>
|
||||
List.Accumulate(
|
||||
records,
|
||||
[],
|
||||
(state, current) =>
|
||||
List.Accumulate(
|
||||
Record.FieldNames(current),
|
||||
state,
|
||||
(innerState, fieldName) =>
|
||||
if ShouldIncludeKey(fieldName) then
|
||||
AddUniqueKey(innerState, fieldName, Record.Field(current, fieldName))
|
||||
else
|
||||
innerState
|
||||
)
|
||||
),
|
||||
|
||||
// helper function to process name-value objects
|
||||
ProcessNameValueObject = (obj as record) as record =>
|
||||
let
|
||||
// Assert that the object has required fields
|
||||
_ = if not (Record.HasFields(obj, {"name"}) and Record.HasFields(obj, {"value"})) then
|
||||
error [
|
||||
Reason = "Invalid Name-Value Object",
|
||||
Message = "Object must have both 'name' and 'value' fields"
|
||||
]
|
||||
else
|
||||
null,
|
||||
|
||||
BaseName = obj[name],
|
||||
BaseValue = obj[value],
|
||||
|
||||
// only extract name and value if it should be included
|
||||
Result = if ShouldIncludeKey(BaseName) then
|
||||
Record.FromList({BaseValue}, {BaseName})
|
||||
else
|
||||
[]
|
||||
in
|
||||
Result,
|
||||
|
||||
// helper function to process direct key-value objects
|
||||
ProcessDirectKeyValueObject = (obj as record) as record =>
|
||||
let
|
||||
// assert that input is a record
|
||||
_ = if not Value.Is(obj, type record) then
|
||||
error [
|
||||
Reason = "Invalid Input Type",
|
||||
Message = "Expected record for direct key-value processing"
|
||||
]
|
||||
else
|
||||
null,
|
||||
|
||||
// extract all primitive key-value pairs
|
||||
PrimitiveFields = List.Transform(
|
||||
Record.FieldNames(obj),
|
||||
(fieldName) =>
|
||||
let
|
||||
fieldValue = Record.Field(obj, fieldName)
|
||||
in
|
||||
if not Type.Is(Value.Type(fieldValue), type record) and ShouldIncludeKey(fieldName) then
|
||||
Record.FromList({fieldValue}, {fieldName})
|
||||
else
|
||||
[]
|
||||
),
|
||||
|
||||
// filter out empty records and combine using safe combine
|
||||
FilteredFields = List.Select(PrimitiveFields, (rec) => Record.FieldCount(rec) > 0),
|
||||
Result = SafeRecordCombine(FilteredFields)
|
||||
in
|
||||
Result,
|
||||
|
||||
// helper functions for type checking
|
||||
HasDirectKeyValuePattern = (obj as record) as logical =>
|
||||
List.AllTrue(
|
||||
List.Transform(
|
||||
Record.FieldValues(obj),
|
||||
(value) => not Type.Is(Value.Type(value), type record)
|
||||
)
|
||||
),
|
||||
|
||||
IsNestedContainer = (obj as record) as logical =>
|
||||
List.AnyTrue(
|
||||
List.Transform(
|
||||
Record.FieldValues(obj),
|
||||
(value) => Type.Is(Value.Type(value), type record)
|
||||
)
|
||||
),
|
||||
|
||||
// main processing function with path tracking
|
||||
ProcessField = (fieldName as text, fieldValue as any, currentPath as text) as record =>
|
||||
let
|
||||
fieldPath = if currentPath = "" then fieldName else currentPath & "." & fieldName
|
||||
in
|
||||
if IsExcludedPath(fieldPath) then
|
||||
[]
|
||||
else if Type.Is(Value.Type(fieldValue), type record) then
|
||||
if Record.HasFields(fieldValue, {"name", "value"}) then
|
||||
ProcessNameValueObject(fieldValue)
|
||||
else if HasDirectKeyValuePattern(fieldValue) then
|
||||
ProcessDirectKeyValueObject(fieldValue)
|
||||
else if IsNestedContainer(fieldValue) then
|
||||
// recursive call for nested containers
|
||||
@ProcessRecord(fieldValue, fieldPath)
|
||||
else
|
||||
// unknown record type, skip
|
||||
[]
|
||||
else
|
||||
// primitive value - check if should be included
|
||||
if ShouldIncludeKey(fieldName) then
|
||||
Record.FromList({fieldValue}, {fieldName})
|
||||
else
|
||||
[],
|
||||
|
||||
// process entire record recursively
|
||||
ProcessRecord = (record as record, currentPath as text) as record =>
|
||||
let
|
||||
// assert that input is a record
|
||||
_ = if not Value.Is(record, type record) then
|
||||
error [
|
||||
Reason = "Invalid Record Type",
|
||||
Message = "Expected record for processing, but received: " & Text.From(Value.Type(record))
|
||||
]
|
||||
else
|
||||
null,
|
||||
|
||||
ProcessedFields = List.Transform(
|
||||
Record.FieldNames(record),
|
||||
(fieldName) => ProcessField(fieldName, Record.Field(record, fieldName), currentPath)
|
||||
),
|
||||
FilteredFields = List.Select(ProcessedFields, (rec) => Record.FieldCount(rec) > 0),
|
||||
CombinedRecord = SafeRecordCombine(FilteredFields)
|
||||
in
|
||||
CombinedRecord,
|
||||
|
||||
// assert that input is a record before processing
|
||||
_ = if not Value.Is(ActualInput, type record) then
|
||||
error [
|
||||
Reason = "Invalid Input Type",
|
||||
Message = "Input must be a record, but received: " & Text.From(Value.Type(ActualInput))
|
||||
]
|
||||
else
|
||||
null,
|
||||
|
||||
// start processing from root
|
||||
Result = ProcessRecord(ActualInput, "")
|
||||
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"),
|
||||
@@ -47,7 +47,6 @@
|
||||
SourceApplication = modelInfo[sourceApplication],
|
||||
Token = apiKey,
|
||||
Version = connectorVersion,
|
||||
VersionId = parsedUrl[versionId],
|
||||
WorkspaceId = workspaceInfo[workspaceId],
|
||||
WorkspaceName = workspaceInfo[workspaceName],
|
||||
WorkspaceLogo = workspaceInfo[workspaceLogo],
|
||||
@@ -71,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
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+8
-11
@@ -31,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",
|
||||
@@ -5367,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",
|
||||
@@ -5383,8 +5382,7 @@
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
]
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "5.1.2",
|
||||
@@ -14716,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"
|
||||
},
|
||||
|
||||
@@ -35,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",
|
||||
|
||||
@@ -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,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,5 +1,4 @@
|
||||
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'
|
||||
@@ -7,7 +6,7 @@ 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,27 +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 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.
|
||||
@@ -85,17 +73,6 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
|
||||
const setReceiveInfo = (newReceiveInfo: ReceiveInfo) => (receiveInfo.value = newReceiveInfo)
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
/**
|
||||
* Ideally one time set when onMounted of `ViewerWrapper.vue` component
|
||||
* @param emit picky emit function to trigger events under `IViewerEvents` interface
|
||||
@@ -124,9 +101,7 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const clearLoadingProgress = () => {
|
||||
loadingProgress.value = undefined
|
||||
}
|
||||
const clearLoadingProgress = () => (loadingProgress.value = undefined)
|
||||
|
||||
// MAKE TS HAPPY
|
||||
type SpeckleObject = {
|
||||
@@ -139,6 +114,7 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
viewerReloadNeeded.value = false
|
||||
console.log(`📦 Loading viewer from cached data with ${lastLoadedRootObjectId.value} id.`)
|
||||
await viewerEmit.value('loadObjects', objects)
|
||||
clearLoadingProgress()
|
||||
objectsFromStore.value = objects
|
||||
isViewerObjectsLoaded.value = true
|
||||
viewerReloadNeeded.value = false
|
||||
@@ -157,26 +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
|
||||
setLoadingProgress('Storing objects into file', null)
|
||||
writeObjectsToFile(dataInput.value.modelObjects)
|
||||
loadingProgress.value = undefined
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -216,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
|
||||
@@ -280,57 +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 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) =>
|
||||
@@ -340,40 +229,10 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
|
||||
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)
|
||||
|
||||
@@ -385,58 +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()
|
||||
}
|
||||
|
||||
return {
|
||||
host,
|
||||
receiveInfo,
|
||||
@@ -460,22 +274,7 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
isFilterActive,
|
||||
latestColorBy,
|
||||
formattingSettings,
|
||||
isBrandingHidden,
|
||||
isOrthoProjection,
|
||||
isGhostActive,
|
||||
isNavbarHidden,
|
||||
isZoomOnFilterActive,
|
||||
latestAvailableVersion,
|
||||
isConnectorUpToDate,
|
||||
commonError,
|
||||
setCommonError,
|
||||
setLatestAvailableVersion,
|
||||
setIsOrthoProjection,
|
||||
setIsGhost,
|
||||
setIsZoomOnFilterActive,
|
||||
setFormattingSettings,
|
||||
setBrandingHidden,
|
||||
setNavbarHidden,
|
||||
setPostClickSkipNeeded,
|
||||
setPostFileSaveSkipNeeded,
|
||||
setCameraPositionInFile,
|
||||
@@ -488,15 +287,8 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
setObjectsFromStore,
|
||||
writeObjectsToFile,
|
||||
writeCameraViewToFile,
|
||||
writeIsGhostToFile,
|
||||
writeZoomOnFilterToFile,
|
||||
writeIsOrthoToFile,
|
||||
writeViewModeToFile,
|
||||
writeCameraPositionToFile,
|
||||
writeHideBrandingToFile,
|
||||
writeNavbarVisibilityToFile,
|
||||
toggleBranding,
|
||||
toggleNavbar,
|
||||
setViewerEmitter,
|
||||
setDataInput,
|
||||
setFieldInputState,
|
||||
@@ -505,8 +297,6 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
setLoadingProgress,
|
||||
clearLoadingProgress,
|
||||
setIsLoadingFromFile,
|
||||
resetFilters,
|
||||
downloadLatestVersion,
|
||||
handleObjectsLoadedComplete
|
||||
resetFilters
|
||||
}
|
||||
})
|
||||
|
||||
@@ -10,7 +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'
|
||||
|
||||
export class AsyncPause {
|
||||
private lastPauseTime = 0
|
||||
@@ -160,40 +159,6 @@ export type ReceiveInfo = {
|
||||
version?: string
|
||||
}
|
||||
|
||||
export type PreGetObjects = {
|
||||
modelExists: boolean
|
||||
objectCount?: number
|
||||
}
|
||||
|
||||
async function getPreGetObjects(commaSeparatedModelIds: string): Promise<PreGetObjects[]> {
|
||||
const modelIds = (commaSeparatedModelIds as string).split(',')
|
||||
const preGetObjects = []
|
||||
|
||||
for await (const id of modelIds) {
|
||||
const res = await getPreGetObjectsForModel(id)
|
||||
preGetObjects.push(res)
|
||||
}
|
||||
return preGetObjects
|
||||
}
|
||||
|
||||
async function getPreGetObjectsForModel(id: string): Promise<PreGetObjects> {
|
||||
try {
|
||||
const preGetObjectsRes = await fetch(`http://localhost:29364/pre-get-objects/${id}`)
|
||||
|
||||
if (!preGetObjectsRes.body) {
|
||||
console.log('No response body for pre get objects')
|
||||
return {
|
||||
modelExists: false,
|
||||
objectCount: null
|
||||
} as PreGetObjects
|
||||
}
|
||||
|
||||
return (await preGetObjectsRes.json()) as PreGetObjects
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function getReceiveInfo(id) {
|
||||
try {
|
||||
const ids = (id as string).split(',')
|
||||
@@ -210,29 +175,19 @@ async function getReceiveInfo(id) {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStreamedData(commaSeparatedModelIds: string, totalObjectCount: number) {
|
||||
async function fetchStreamedData(commaSeparatedModelIds: string) {
|
||||
const modelIds = (commaSeparatedModelIds as string).split(',')
|
||||
const modelObjects = []
|
||||
|
||||
let loadedObjectCount = 0
|
||||
|
||||
for await (const id of modelIds) {
|
||||
const objects = await fetchStreamedDataForModel(id, totalObjectCount, loadedObjectCount)
|
||||
const objects = await fetchStreamedDataForModel(id)
|
||||
modelObjects.push(objects)
|
||||
loadedObjectCount += objects.length
|
||||
}
|
||||
return modelObjects
|
||||
}
|
||||
|
||||
async function fetchStreamedDataForModel(
|
||||
id: string,
|
||||
totalObjectCount: number,
|
||||
loadedObjectCount: number
|
||||
) {
|
||||
console.log(loadedObjectCount, totalObjectCount)
|
||||
|
||||
async function fetchStreamedDataForModel(id) {
|
||||
try {
|
||||
const visualStore = useVisualStore()
|
||||
const response = await fetch(`http://localhost:29364/get-objects/${id}`)
|
||||
|
||||
if (!response.body) {
|
||||
@@ -259,11 +214,6 @@ async function fetchStreamedDataForModel(
|
||||
try {
|
||||
const obj = JSON.parse(jsonString)
|
||||
objects.push(obj)
|
||||
visualStore.setLoadingProgress(
|
||||
'Loading objects from storage',
|
||||
(objects.length + loadedObjectCount) / totalObjectCount
|
||||
)
|
||||
// console.log('Loading', (objects.length + loadedObjectCount) / totalObjectCount)
|
||||
|
||||
// console.log('Received object:', jsonObject)
|
||||
} catch (e) {
|
||||
@@ -296,14 +246,9 @@ async function fetchStreamedDataForModel(
|
||||
const endObjectCleanup = performance.now()
|
||||
console.log(`Objects cleaned up in: ${(endObjectCleanup - startObjectCleanup) / 1000} s`)
|
||||
|
||||
try {
|
||||
const sizeInBytes = new TextEncoder().encode(JSON.stringify(objects)).length
|
||||
const sizeInMB = sizeInBytes / (1024 * 1024)
|
||||
console.log(`Size of objects: ${sizeInMB} MB`)
|
||||
} catch (error) {
|
||||
console.log("Can't calculate the size of the model")
|
||||
console.log(error)
|
||||
}
|
||||
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) {
|
||||
@@ -358,16 +303,11 @@ export async function processMatrixView(
|
||||
|
||||
if (visualStore.lastLoadedRootObjectId !== id && !visualStore.isLoadingFromFile) {
|
||||
const start = performance.now()
|
||||
visualStore.setViewerReadyToLoad()
|
||||
visualStore.setLoadingProgress('Loading', null)
|
||||
|
||||
const getPreGetObjectsRes: PreGetObjects[] = await getPreGetObjects(id)
|
||||
|
||||
if (getPreGetObjectsRes.some((preGetObjects) => preGetObjects.modelExists === false)) {
|
||||
visualStore.setCommonError(
|
||||
'Version Object ID is not found in storage. Please make sure you placed correct field or consider refreshing your data via data connector.'
|
||||
)
|
||||
visualStore.setViewerReadyToLoad(false)
|
||||
return
|
||||
}
|
||||
// stream data
|
||||
modelObjects = await fetchStreamedData(id)
|
||||
|
||||
const receiveInfo = await getReceiveInfo(id)
|
||||
if (receiveInfo) {
|
||||
@@ -381,25 +321,11 @@ export async function processMatrixView(
|
||||
version: receiveInfo.version,
|
||||
canHideBranding: receiveInfo.canHideBranding
|
||||
})
|
||||
console.log(`Receive info retrieved from desktop service`, receiveInfo)
|
||||
}
|
||||
|
||||
const totalObjectCount = getPreGetObjectsRes.reduce((sum, obj) => {
|
||||
return sum + (obj.objectCount ?? 0)
|
||||
}, 0)
|
||||
|
||||
visualStore.setViewerReadyToLoad(true)
|
||||
// stream data
|
||||
modelObjects = await fetchStreamedData(id, totalObjectCount)
|
||||
|
||||
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
|
||||
|
||||
@@ -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 === ''
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
@@ -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)
|
||||
@@ -136,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),
|
||||
@@ -171,41 +136,6 @@ 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
|
||||
)
|
||||
}
|
||||
|
||||
// get receive info from file for mixpanel
|
||||
try {
|
||||
const receiveInfoFromFile = JSON.parse(
|
||||
@@ -216,8 +146,8 @@ export class Visual implements IVisual {
|
||||
console.warn(error)
|
||||
console.log('missing mixpanel info')
|
||||
}
|
||||
|
||||
const savedVersionObjectId = objectsFromFile.map((o) => o[0].id).join(',')
|
||||
|
||||
if (visualStore.lastLoadedRootObjectId !== savedVersionObjectId) {
|
||||
this.tryReadFromFile(objectsFromFile, visualStore)
|
||||
}
|
||||
@@ -268,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)
|
||||
@@ -282,7 +212,7 @@ export class Visual implements IVisual {
|
||||
}
|
||||
|
||||
private tryReadFromFile(objectsFromFile: object[][], visualStore) {
|
||||
visualStore.setViewerReadyToLoad(true)
|
||||
visualStore.setViewerReadyToLoad()
|
||||
visualStore.setIsLoadingFromFile(true) // to block unnecessary streaming data if bg service is running
|
||||
setTimeout(() => {
|
||||
visualStore.loadObjectsFromFile(objectsFromFile)
|
||||
|
||||
Reference in New Issue
Block a user