Compare commits
125 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9fd34831c | |||
| dbac5c013b | |||
| a73e832816 | |||
| 0b55013a84 | |||
| baa723287b | |||
| 0976597db3 | |||
| 40536a565f | |||
| 34115d9a5d | |||
| 74ac3e3990 | |||
| f9b5e250d8 | |||
| 0befca0200 | |||
| 1a74336e27 | |||
| 9f9b31d9ba | |||
| df3ad118e1 | |||
| ec634be352 | |||
| 0a4ae9340a | |||
| 92bcf4b5c0 | |||
| 2a22bbf0af | |||
| 7b5e5397b6 | |||
| 24eeb44ff7 | |||
| b1f16c4005 | |||
| 2307d87735 | |||
| b80624396d | |||
| 098ef3d112 | |||
| 94fdc7a2c3 | |||
| 525857bd26 | |||
| 959bcaa671 | |||
| 04b3aef829 | |||
| 318dc6dbbe | |||
| 20577a1fdb | |||
| e74bad829e | |||
| dda04e49c2 | |||
| 97983fb8aa | |||
| 1cac02ae61 | |||
| 0a5001987e | |||
| 5ffb3ea1dd | |||
| 3461c48b11 | |||
| 220946a611 | |||
| 53e4cda456 | |||
| 4ca0ae0978 | |||
| 685a137531 | |||
| 78af91f38a | |||
| 108a406bd5 | |||
| d7ede2edcf | |||
| a25d635ca1 | |||
| 5a9add6d76 | |||
| 89c8005dee | |||
| a384370652 | |||
| 5ec90095f0 | |||
| 20fad26fef | |||
| 03215f79c4 | |||
| 6d17377ca2 | |||
| 256abaed0c | |||
| 26409b4ea6 | |||
| 865c4c1608 | |||
| 67836c2a7f | |||
| 95d819f7f3 | |||
| dee3ee6c4d | |||
| 7ed612ec14 | |||
| 4bd7af4c31 | |||
| 3ed2e977df | |||
| 788fa1c532 | |||
| bafb7df6ed | |||
| be4e4df983 | |||
| b4830c80ab | |||
| a2d97facc5 | |||
| aea344a46a | |||
| 13aa65bc2e | |||
| 0a307c28e0 | |||
| e0f4a4c02c | |||
| 29773f9492 | |||
| 8f67ef4c84 | |||
| 2c5f192403 | |||
| 0c58789dd6 | |||
| 82acce2abb | |||
| d83472c30b | |||
| 634df47a25 | |||
| 9ad59bf1d3 | |||
| ffc0d8ef5e | |||
| 94c80857a0 | |||
| c8d858d575 | |||
| 36b9787b66 | |||
| bde7a42c44 | |||
| 1040f4622d | |||
| 91e799d006 | |||
| 8694666874 | |||
| fa6ad8ec40 | |||
| 8e60249291 | |||
| 68d6bf3d55 | |||
| 2a8925c8ef | |||
| f9b3d3db52 | |||
| bfd0c33373 | |||
| d155a4b165 | |||
| 2e9ece856f | |||
| 808e288848 | |||
| 701116c66c | |||
| e73d392013 | |||
| dd7f3fe95d | |||
| fdcc1f2cef | |||
| 5e2f108e49 | |||
| df334e95a2 | |||
| ce733d1ced | |||
| 8345258990 | |||
| dbd0f2f9ce | |||
| c9b4155660 | |||
| c4d094d722 | |||
| 5b003b182b | |||
| 7eabd47f6d | |||
| 822b999be9 | |||
| 2407b8758a | |||
| d6f5e65bd7 | |||
| 183cc36654 | |||
| a740272585 | |||
| 72128a9f4e | |||
| 677c663ef3 | |||
| a077857c66 | |||
| 5897a286bc | |||
| 5b49fb2a9a | |||
| 424404dd11 | |||
| 31312522a7 | |||
| 932198dccf | |||
| 3770502ca4 | |||
| 93e8fcdd9d | |||
| 370052b2be | |||
| 85f8f72335 |
@@ -26,7 +26,7 @@ jobs:
|
||||
run: |
|
||||
TAG=${{ github.ref_name }}
|
||||
if [[ "${{ github.ref }}" != refs/tags/* ]]; then
|
||||
TAG="v3.0.99.${{ github.run_number }}"
|
||||
TAG="v3.0.99"
|
||||
fi
|
||||
SEMVER="${TAG#v}"
|
||||
FILE_VERSION=$(echo "$TAG" | sed -E 's/^v([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
run: |
|
||||
TAG=${{ github.ref_name }}
|
||||
if [[ "${{ github.ref }}" != refs/tags/* ]]; then
|
||||
TAG="v3.0.99.${{ github.run_number }}"
|
||||
TAG="v3.0.99"
|
||||
fi
|
||||
SEMVER="${TAG#v}"
|
||||
FILE_VERSION=$(echo "$TAG" | sed -E 's/^v([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
|
||||
|
||||
@@ -3,18 +3,26 @@
|
||||
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://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>
|
||||
<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>
|
||||
|
||||
> 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 Viewer Visual for Power BI
|
||||
Speckle Connector and 3D 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.
|
||||
@@ -22,19 +30,19 @@ Speckle’s connection to Power BI consists of two parts:
|
||||
|
||||

|
||||
|
||||
# Repo Structure
|
||||
## Repository Structure
|
||||
|
||||
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).
|
||||
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).
|
||||
|
||||
`src/powerbi-data-connector` contains all the code for the Data connector.
|
||||
|
||||
`src/powerbi-visual` contains all the code for 3D Visual.
|
||||
|
||||
# Installation
|
||||
## Installation
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
# Using 3D Visual
|
||||
### 3D Visual
|
||||
|
||||
3D Visual can be imported as any other Power BI custom visual.
|
||||
|
||||
@@ -43,34 +51,62 @@ Speckle connector can be installed directly from the [connectors portal](https:/
|
||||
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://speckle.guide/user/powerbi-visual/introduction.html).
|
||||
For more on how to use the visual, [check our docs](https://docs.speckle.systems/connectors/power-bi).
|
||||
|
||||
# Usage
|
||||
## Quick Start
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
# **Developing & Debugging**
|
||||
## Development Setup
|
||||
|
||||
We encourage everyone interested to debug/hack/contribute/give feedback to this project.
|
||||
### For local development of the 3D Visual
|
||||
|
||||
## **Setup**
|
||||
1. **Clone the repository**:
|
||||
```bash
|
||||
git clone https://github.com/specklesystems/speckle-powerbi.git
|
||||
cd speckle-powerbi
|
||||
```
|
||||
|
||||
### **Install PowerQuery SDK**
|
||||
2. **Navigate to the visual directory**:
|
||||
```bash
|
||||
cd src/powerbi-visual
|
||||
```
|
||||
|
||||
Follow the instructions from the [official docs](https://docs.microsoft.com/en-us/power-query/installingsdk)
|
||||
3. **Install dependencies**:
|
||||
```bash
|
||||
npm install
|
||||
# or
|
||||
yarn install
|
||||
```
|
||||
|
||||
### **Build with Visual Studio**
|
||||
4. **Start development server**:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
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.
|
||||
5. **Build the visual**:
|
||||
```bash
|
||||
# Development build
|
||||
npm run build:dev
|
||||
|
||||
# Production build
|
||||
npm run build
|
||||
```
|
||||
|
||||
### **Debug**
|
||||
### For local development of the Data Connector
|
||||
|
||||
You can start the PowerQuery connector in VisualStudio, this will open a standalone connector you can use for testing purposes.
|
||||
1. **Install PowerQuery SDK**:
|
||||
Follow the instructions from the [official docs](https://docs.microsoft.com/en-us/power-query/installingsdk)
|
||||
|
||||
We don't know of a way to debug the connector live in PowerBI, but we'd be happy to hear about it.
|
||||
2. **Open the project in Visual Studio Code**:
|
||||
- Open `src/powerbi-data-connector/Speckle.proj`
|
||||
- Build the project to generate the `.mez` file
|
||||
|
||||
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 
|
||||
|
||||
@@ -91,12 +127,10 @@ 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://speckle.guide/dev/) reference on almost any end-user and developer functionality
|
||||
|
||||

|
||||
- [](https://docs.speckle.systems) reference on almost any end-user and developer functionality
|
||||
|
||||
@@ -4,6 +4,79 @@ 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.Utf8)),
|
||||
// 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
|
||||
@@ -75,6 +148,53 @@ shared Speckle.GetWorkspace = Value.ReplaceType(
|
||||
type function (url as Uri.Type) as record
|
||||
);
|
||||
|
||||
shared Speckle.Objects.Properties = Value.ReplaceType(
|
||||
Speckle.LoadFunction("Objects.Properties.pqm"),
|
||||
type function (inputRecord as any, optional filterKeys as list, optional parentPath as text, optional existingFields as list) as record
|
||||
);
|
||||
|
||||
|
||||
shared Speckle.Utils.ExpandRecord = Value.ReplaceType(
|
||||
Speckle.LoadFunction("Utils.ExpandRecord.pqm"),
|
||||
type function (
|
||||
table as table,
|
||||
columnName as text,
|
||||
optional FieldNames as list,
|
||||
optional UseCombinedNames as logical
|
||||
) as table
|
||||
);
|
||||
|
||||
shared Speckle.Objects.Collections = Value.ReplaceType(
|
||||
Speckle.LoadFunction("Objects.Collections.pqm"),
|
||||
type function (inputData as table) as table
|
||||
);
|
||||
|
||||
shared Speckle.Objects.CompositeStructure = Value.ReplaceType(
|
||||
Speckle.LoadFunction("Objects.CompositeStructure.pqm"),
|
||||
type function (objectRecord as record, optional outputAsList as nullable logical) as any
|
||||
);
|
||||
|
||||
shared Speckle.Objects.MaterialQuantities = Value.ReplaceType(
|
||||
Speckle.LoadFunction("Objects.MaterialQuantities.pqm"),
|
||||
type function (objectRecord as record, optional outputAsList as logical) as any
|
||||
);
|
||||
|
||||
shared Speckle.Models.Federate = Value.ReplaceType(
|
||||
Speckle.LoadFunction("Models.Federate.pqm"),
|
||||
type function (tables as list, optional excludeData as logical) as table
|
||||
);
|
||||
|
||||
shared Speckle.Models.MaterialQuantities = Value.ReplaceType(
|
||||
Speckle.LoadFunction("Models.MaterialQuantities.pqm"),
|
||||
type function (inputTable as table, optional addPrefix as logical) as table
|
||||
);
|
||||
|
||||
[DataSource.Kind = "Speckle"]
|
||||
shared Speckle.Project.Issues = Value.ReplaceType(
|
||||
Speckle.LoadFunction("Project.Issues.pqm"),
|
||||
type function (url as Uri.Type, optional getReplies as logical) as table
|
||||
);
|
||||
|
||||
[DataSource.Kind = "Speckle", Publish="GetByUrl.Publish"]
|
||||
shared Speckle.GetByUrl = Value.ReplaceType(
|
||||
Speckle.LoadFunction("GetByUrl.pqm"),
|
||||
@@ -85,6 +205,13 @@ shared Speckle.GetByUrl = Value.ReplaceType(
|
||||
Documentation.FieldDescription = "The URL of a model in a Speckle server project. You can copy it directly from your browser.",
|
||||
Documentation.SampleValues = {"https://app.speckle.systems/projects/7902de1f57/models/7f890a65df"}
|
||||
]
|
||||
),
|
||||
optional ExpandProperties as (
|
||||
type logical meta [
|
||||
Documentation.FieldCaption = "Expand Properties (may slow query)",
|
||||
Documentation.FieldDescription = "Expand the properties column into individual columns for easier analysis. When checked, each property from the 'properties' record column will have its own column. This can slow down the query if you have a lot of properties.",
|
||||
Documentation.AllowedValues = {true, false}
|
||||
]
|
||||
)
|
||||
) as table meta [
|
||||
Documentation.Name = "Speckle - Get Data by URL",
|
||||
@@ -133,7 +260,7 @@ GetByUrl.Icons = [
|
||||
Speckle = [
|
||||
// This is used when running the connector on an on-premises data gateway
|
||||
TestConnection = (path) => {"Speckle.GetUser", path},
|
||||
// Authentication strategy
|
||||
// Authentication strategy - OAuth only
|
||||
Authentication = [
|
||||
OAuth = [
|
||||
Label = "Speckle Account",
|
||||
@@ -141,14 +268,31 @@ Speckle = [
|
||||
let
|
||||
server = Text.Combine(
|
||||
{Uri.Parts(dataSourcePath)[Scheme], "://", Uri.Parts(dataSourcePath)[Host]}
|
||||
)
|
||||
),
|
||||
// Generate PKCE parameters for enhanced security
|
||||
codeVerifier = GeneratePKCEVerifier(),
|
||||
codeChallenge = GeneratePKCEChallenge(codeVerifier),
|
||||
// Detect if server supports /oauth/token
|
||||
oauthCheck = try Web.Contents(
|
||||
Text.Combine({server, "oauth", "token"}, "/"),
|
||||
[ManualStatusHandling = {400, 401, 403, 404, 405, 500}]
|
||||
) otherwise null,
|
||||
useNewOAuth = oauthCheck <> null and Value.Metadata(oauthCheck)[Response.Status] = 200,
|
||||
// Build auth URL based on server capabilities
|
||||
authUrl = if useNewOAuth then
|
||||
Text.Combine({server, "authn", "verify", AuthAppId, codeChallenge}, "/") &
|
||||
"?code_challenge_method=S256" &
|
||||
"&pbiNew=true"
|
||||
else
|
||||
// Legacy
|
||||
Text.Combine({server, "authn", "verify", AuthAppId, codeVerifier}, "/")
|
||||
in
|
||||
[
|
||||
LoginUri = Text.Combine({server, "authn", "verify", AuthAppId, state}, "/"),
|
||||
LoginUri = authUrl,
|
||||
CallbackUri = "https://oauth.powerbi.com/views/oauthredirect.html",
|
||||
WindowHeight = 800,
|
||||
WindowWidth = 600,
|
||||
Context = null
|
||||
Context = [code_verifier = codeVerifier, use_new_oauth = useNewOAuth]
|
||||
],
|
||||
FinishLogin = (clientApplication, dataSourcePath, context, callbackUri, state) =>
|
||||
let
|
||||
@@ -156,22 +300,35 @@ Speckle = [
|
||||
{Uri.Parts(dataSourcePath)[Scheme], "://", Uri.Parts(dataSourcePath)[Host]}
|
||||
),
|
||||
Parts = Uri.Parts(callbackUri)[Query],
|
||||
Source = Web.Contents(
|
||||
Text.Combine({server, "auth", "token"}, "/"),
|
||||
[
|
||||
Headers = [
|
||||
#"Content-Type" = "application/json"
|
||||
],
|
||||
Content = Json.FromValue(
|
||||
[
|
||||
codeVerifier = if context <> null then context[code_verifier] else null,
|
||||
useNewOAuth = if context <> null and Record.HasFields(context, "use_new_oauth") then context[use_new_oauth] else false,
|
||||
// Single token exchange call based on server capability
|
||||
Source = if useNewOAuth then
|
||||
Web.Contents(
|
||||
Text.Combine({server, "oauth", "token"}, "/"),
|
||||
[
|
||||
Headers = [#"Content-Type" = "application/json"],
|
||||
Content = Json.FromValue([
|
||||
appId = AuthAppId,
|
||||
accessCode = Parts[access_code],
|
||||
codeVerifier = codeVerifier
|
||||
])
|
||||
]
|
||||
)
|
||||
else
|
||||
// Legacy
|
||||
Web.Contents(
|
||||
Text.Combine({server, "auth", "token"}, "/"),
|
||||
[
|
||||
Headers = [#"Content-Type" = "application/json"],
|
||||
Content = Json.FromValue([
|
||||
appId = AuthAppId,
|
||||
appSecret = AuthAppSecret,
|
||||
challenge = state
|
||||
]
|
||||
)
|
||||
]
|
||||
),
|
||||
accessCode = Parts[access_code],
|
||||
challenge = codeVerifier
|
||||
])
|
||||
]
|
||||
),
|
||||
json = Json.Document(Source)
|
||||
in
|
||||
[
|
||||
@@ -185,7 +342,8 @@ Speckle = [
|
||||
server = Text.Combine(
|
||||
{Uri.Parts(dataSourcePath)[Scheme], "://", Uri.Parts(dataSourcePath)[Host]}
|
||||
),
|
||||
Source = Web.Contents(
|
||||
// Enhanced refresh with error handling for gateway compatibility
|
||||
Source = try Web.Contents(
|
||||
Text.Combine({server, "auth", "token"}, "/"),
|
||||
[
|
||||
Headers = [
|
||||
@@ -197,24 +355,42 @@ 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
|
||||
[
|
||||
access_token = json[token],
|
||||
scope = null,
|
||||
token_type = "bearer",
|
||||
refresh_token = json[refreshToken]
|
||||
]
|
||||
],
|
||||
Key = [
|
||||
KeyLabel = "Personal Access Token",
|
||||
Label = "Private Project"
|
||||
],
|
||||
Implicit = [
|
||||
Label = "Public Project"
|
||||
result
|
||||
]
|
||||
],
|
||||
Label = "Speckle"
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
(url as text) as table =>
|
||||
(url as text, optional ExpandProperties as logical) as table =>
|
||||
let
|
||||
// set default value for ExpandProperties
|
||||
shouldExpandProperties = if ExpandProperties = null then false else ExpandProperties,
|
||||
|
||||
// import required functions
|
||||
GetStructuredData = Extension.LoadFunction("GetStructuredData.pqm"),
|
||||
SendToServer = Extension.LoadFunction("SendToServer.pqm"),
|
||||
GetModel = Extension.LoadFunction("GetModel.pqm"),
|
||||
Parser = Extension.LoadFunction("Parser.pqm"),
|
||||
CheckPermissions = Extension.LoadFunction("CheckPermissions.pqm"),
|
||||
ExchangeToken = Extension.LoadFunction("ExchangeToken.pqm"),
|
||||
EncodeUserInfo = Extension.LoadFunction("EncodeUserInfo.pqm"),
|
||||
GetUser = Extension.LoadFunction("GetUser.pqm"),
|
||||
GetVersion = Extension.LoadFunction("GetVersion.pqm"),
|
||||
GetWorkspace = Extension.LoadFunction("GetWorkspace.pqm"),
|
||||
MarkReceived = Extension.LoadFunction("MarkReceived.pqm"),
|
||||
|
||||
// the logic for importing functions from other files
|
||||
Extension.LoadFunction = (fileName as text) =>
|
||||
@@ -26,24 +35,55 @@
|
||||
|
||||
// parse the URL to determine if it's a federated model
|
||||
parsedUrl = Parser(url),
|
||||
|
||||
|
||||
// check if user has permission to load the model
|
||||
permissionCheck = CheckPermissions(url),
|
||||
|
||||
|
||||
// assert that permission check returned a valid result
|
||||
permissionAssert = if not Record.HasFields(permissionCheck, {"authorized", "code", "message"}) then
|
||||
error "Invalid permission check result"
|
||||
else
|
||||
null,
|
||||
|
||||
|
||||
// if not authorized, throw an error with the message from the server
|
||||
authCheck = if not permissionCheck[authorized] then
|
||||
error Text.Format(
|
||||
"Permission denied: #{0} (Error code: #{1})",
|
||||
"Permission denied: #{0} (Error code: #{1})",
|
||||
{permissionCheck[message], permissionCheck[code]}
|
||||
)
|
||||
else
|
||||
null,
|
||||
|
||||
// get user info, connector version, and workspace info for encoding
|
||||
userInfo = GetUser(url),
|
||||
powerfulToken = userInfo[Token],
|
||||
userEmail = userInfo[UserEmail],
|
||||
connectorVersion = GetVersion(),
|
||||
workspaceInfo = GetWorkspace(url),
|
||||
|
||||
// exchange powerful token for weak token with limited scopes
|
||||
tokenExchangeResult = ExchangeToken(
|
||||
powerfulToken,
|
||||
{"profile:read", "streams:read", "users:read"},
|
||||
parsedUrl[projectId],
|
||||
parsedUrl[baseUrl],
|
||||
parsedUrl[resourceIdString]
|
||||
),
|
||||
|
||||
// throw error if token exchange failed - do NOT use powerful token as fallback
|
||||
tokenToUse = if tokenExchangeResult[Success] then
|
||||
tokenExchangeResult[Token]
|
||||
else
|
||||
error [
|
||||
Reason = "TokenExchangeFailed",
|
||||
Message.Format = "Failed to exchange token for limited scope token: #{0}",
|
||||
Message.Parameters = {tokenExchangeResult[ErrorMessage]},
|
||||
Detail = [
|
||||
ErrorMessage = tokenExchangeResult[ErrorMessage],
|
||||
ProjectId = parsedUrl[projectId],
|
||||
ServerUrl = parsedUrl[baseUrl]
|
||||
]
|
||||
],
|
||||
|
||||
// only proceed if user has permisson to load
|
||||
results = if permissionCheck[authorized] then
|
||||
@@ -53,45 +93,108 @@
|
||||
modelsData = List.Transform(
|
||||
parsedUrl[federatedModels],
|
||||
each ProcessSingleModel(
|
||||
parsedUrl[baseUrl],
|
||||
parsedUrl[projectId],
|
||||
[modelId],
|
||||
parsedUrl[baseUrl],
|
||||
parsedUrl[projectId],
|
||||
[modelId],
|
||||
[versionId]
|
||||
)
|
||||
),
|
||||
|
||||
|
||||
// extract all data tables
|
||||
allTables = List.Transform(modelsData, each [Data]),
|
||||
|
||||
|
||||
// extract all root object IDs
|
||||
allRootIds = List.Transform(modelsData, each [RootObjectId]),
|
||||
|
||||
|
||||
// extract all encoded userInfo strings
|
||||
allEncodedUserInfos = List.Transform(modelsData, each [EncodedUserInfo]),
|
||||
|
||||
// combine all root object IDs into a comma-separated string
|
||||
combinedRootIds = Text.Combine(allRootIds, ","),
|
||||
|
||||
|
||||
// combine all encoded userInfo strings with delimiter |||
|
||||
// (delimiter chosen to avoid conflicts with base64 characters)
|
||||
combinedEncodedUserInfos = Text.Combine(allEncodedUserInfos, "|||"),
|
||||
|
||||
// combine all data tables
|
||||
combinedData = Table.Combine(allTables),
|
||||
|
||||
// replace the "Version Object ID" column with the combined root IDs
|
||||
finalData = Table.TransformColumns(
|
||||
combinedData,
|
||||
{"Version Object ID", each combinedRootIds}
|
||||
)
|
||||
|
||||
// replace both columns with combined values
|
||||
transformedData = Table.TransformColumns(
|
||||
combinedData,
|
||||
{
|
||||
{"Version Object ID", each combinedRootIds},
|
||||
{"Model Info", each combinedEncodedUserInfos}
|
||||
}
|
||||
),
|
||||
|
||||
// expand properties column if requested and if it exists
|
||||
finalData = if shouldExpandProperties and Table.HasColumns(transformedData, {"properties"}) then
|
||||
try
|
||||
Speckle.Utils.ExpandRecord(transformedData, "properties")
|
||||
otherwise
|
||||
transformedData // fallback to original data if expansion fails
|
||||
else
|
||||
transformedData
|
||||
in
|
||||
finalData
|
||||
else
|
||||
// use existing functionality for single models
|
||||
let
|
||||
// get model name
|
||||
// get model info
|
||||
modelInfo = GetModel(url),
|
||||
modelName = modelInfo[modelName],
|
||||
|
||||
rootObjectId = modelInfo[rootObjectId],
|
||||
sourceApplication = modelInfo[sourceApplication],
|
||||
versionId = modelInfo[versionId],
|
||||
|
||||
// mark version as received
|
||||
markReceivedResult = MarkReceived(powerfulToken, versionId, parsedUrl[projectId], parsedUrl[baseUrl]),
|
||||
|
||||
// get structured data
|
||||
structuredData = GetStructuredData(url),
|
||||
|
||||
// rename column based on send status
|
||||
newColumnName = "Version Object ID",
|
||||
result = Table.RenameColumns(structuredData, {{"Version Object ID", newColumnName}})
|
||||
|
||||
// build userInfoData record for this model
|
||||
userInfoData = [
|
||||
rootObjectId = rootObjectId,
|
||||
server = parsedUrl[baseUrl],
|
||||
email = userEmail,
|
||||
projectId = parsedUrl[projectId],
|
||||
token = tokenToUse,
|
||||
workspaceId = workspaceInfo[workspaceId],
|
||||
workspaceName = workspaceInfo[workspaceName],
|
||||
workspaceLogo = workspaceInfo[workspaceLogo],
|
||||
version = connectorVersion,
|
||||
sourceApplication = sourceApplication,
|
||||
canHideBranding = workspaceInfo[canHideBranding],
|
||||
versionId = versionId,
|
||||
url = url
|
||||
],
|
||||
|
||||
// try to send to desktop service for backward compatibility (non-blocking)
|
||||
// must be called BEFORE encoding to ensure it executes
|
||||
desktopServiceSent = TrySendToDesktopService(userInfoData),
|
||||
|
||||
// encode userInfoData as base64 JSON string
|
||||
encodedUserInfo = EncodeUserInfo(userInfoData),
|
||||
|
||||
// replace both columns with appropriate values
|
||||
transformedData = Table.TransformColumns(
|
||||
structuredData,
|
||||
{
|
||||
{"Version Object ID", each rootObjectId},
|
||||
{"Model Info", each if (desktopServiceSent or not desktopServiceSent) and (markReceivedResult or not markReceivedResult) then encodedUserInfo else encodedUserInfo}
|
||||
}
|
||||
),
|
||||
|
||||
// expand properties column if requested and if it exists
|
||||
result = if shouldExpandProperties and Table.HasColumns(transformedData, {"properties"}) then
|
||||
try
|
||||
Speckle.Utils.ExpandRecord(transformedData, "properties")
|
||||
otherwise
|
||||
transformedData // fallback to original data if expansion fails
|
||||
else
|
||||
transformedData
|
||||
in
|
||||
result
|
||||
else
|
||||
@@ -100,38 +203,98 @@
|
||||
{permissionCheck[message], permissionCheck[code]}
|
||||
),
|
||||
|
||||
// helper function to try sending user info to desktop service for backward compatibility
|
||||
// returns true if successful, false otherwise (non-blocking)
|
||||
TrySendToDesktopService = (userInfoData as record) =>
|
||||
try
|
||||
let
|
||||
userInfoJson = Json.FromValue(userInfoData),
|
||||
response = Web.Contents(
|
||||
"http://127.0.0.1:29364/store-user-info",
|
||||
[
|
||||
Headers = [
|
||||
#"Content-Type" = "application/json",
|
||||
#"Method" = "POST"
|
||||
],
|
||||
Content = userInfoJson,
|
||||
ManualStatusHandling = {400, 401, 403, 404, 500, 502, 503, 504},
|
||||
Timeout = #duration(0, 0, 0, 2)
|
||||
]
|
||||
),
|
||||
statusCode = Value.Metadata(response)[Response.Status]
|
||||
in
|
||||
statusCode >= 200 and statusCode < 300
|
||||
otherwise
|
||||
false,
|
||||
|
||||
// function to process a single model and get its data
|
||||
ProcessSingleModel = (baseUrl, projectId, modelId, versionId) =>
|
||||
ProcessSingleModel = (baseUrl, projectId, modelId, versionId) =>
|
||||
let
|
||||
// construct a standard URL for the model
|
||||
singleModelUrl = Text.Combine({
|
||||
baseUrl,
|
||||
"/projects/",
|
||||
projectId,
|
||||
"/models/",
|
||||
baseUrl,
|
||||
"/projects/",
|
||||
projectId,
|
||||
"/models/",
|
||||
modelId,
|
||||
if versionId <> null then Text.Combine({"@", versionId}) else ""
|
||||
}),
|
||||
|
||||
|
||||
// get model info
|
||||
modelInfo = GetModel(singleModelUrl),
|
||||
rootObjectId = modelInfo[rootObjectId],
|
||||
modelName = modelInfo[modelName],
|
||||
|
||||
sourceApplication = modelInfo[sourceApplication],
|
||||
federatedVersionId = if versionId <> null then versionId else modelInfo[versionId],
|
||||
|
||||
// mark version as received (non-blocking, best-effort)
|
||||
markReceivedResult = MarkReceived(powerfulToken, federatedVersionId, projectId, baseUrl),
|
||||
|
||||
// get structured data
|
||||
structuredData = GetStructuredData(singleModelUrl),
|
||||
|
||||
// add the model name as context
|
||||
|
||||
// build userInfoData record for this model
|
||||
userInfoData = [
|
||||
rootObjectId = rootObjectId,
|
||||
server = baseUrl,
|
||||
email = userEmail,
|
||||
projectId = projectId,
|
||||
token = tokenToUse,
|
||||
workspaceId = workspaceInfo[workspaceId],
|
||||
workspaceName = workspaceInfo[workspaceName],
|
||||
workspaceLogo = workspaceInfo[workspaceLogo],
|
||||
version = connectorVersion,
|
||||
sourceApplication = sourceApplication,
|
||||
canHideBranding = workspaceInfo[canHideBranding],
|
||||
versionId = if versionId <> null then versionId else modelInfo[versionId],
|
||||
url = singleModelUrl
|
||||
],
|
||||
|
||||
// try to send to desktop service for backward compatibility (non-blocking)
|
||||
// must be called BEFORE encoding to ensure it executes
|
||||
desktopServiceSent = TrySendToDesktopService(userInfoData),
|
||||
|
||||
// encode userInfoData as base64 JSON string
|
||||
encodedUserInfo = EncodeUserInfo(userInfoData),
|
||||
|
||||
// add the model name as context - with version id if exists
|
||||
// reference desktopServiceSent and markReceivedResult to force evaluation
|
||||
result = Table.AddColumn(
|
||||
structuredData,
|
||||
"Source Model",
|
||||
each modelName,
|
||||
structuredData,
|
||||
"Source Model",
|
||||
each if versionId <> null then
|
||||
if (markReceivedResult or not markReceivedResult) then Text.Combine({modelName, "-", versionId}) else Text.Combine({modelName, "-", versionId})
|
||||
else if (desktopServiceSent or not desktopServiceSent) and (markReceivedResult or not markReceivedResult) then
|
||||
modelName
|
||||
else
|
||||
modelName,
|
||||
type text
|
||||
)
|
||||
in
|
||||
[
|
||||
Data = result,
|
||||
RootObjectId = rootObjectId
|
||||
RootObjectId = rootObjectId,
|
||||
EncodedUserInfo = encodedUserInfo
|
||||
]
|
||||
in
|
||||
results
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
(server as text, optional query as text, optional variables as record) as record =>
|
||||
let
|
||||
apiKey = try Extension.CurrentCredential()[Key] otherwise null,
|
||||
// Enhanced credential retrieval with OAuth2 support
|
||||
apiKey = try Extension.CurrentCredential()[access_token] otherwise null,
|
||||
|
||||
defaultQuery = "query {
|
||||
activeUser {
|
||||
email
|
||||
@@ -12,7 +14,9 @@
|
||||
version
|
||||
}
|
||||
}",
|
||||
Source = Web.Contents(
|
||||
|
||||
// Enhanced API call with comprehensive error handling
|
||||
Source = try Web.Contents(
|
||||
Text.Combine({server, "graphql"}, "/"),
|
||||
[
|
||||
Headers = [
|
||||
@@ -20,14 +24,56 @@
|
||||
#"Content-Type" = "application/json",
|
||||
#"Authorization" = if apiKey = null then "" else Text.Format("Bearer #{0}", {apiKey})
|
||||
],
|
||||
ManualStatusHandling = {400},
|
||||
ManualStatusHandling = {400, 401, 403, 404, 500, 502, 503, 504},
|
||||
Content = Json.FromValue([query = Text.From(query ?? defaultQuery), variables = variables])
|
||||
]
|
||||
),
|
||||
#"JSON" = Json.Document(Source)
|
||||
in
|
||||
// Check if response contains errors, if so, return first error.
|
||||
if Record.HasFields(#"JSON", {"errors"}) then
|
||||
error #"JSON"[errors]{0}[message]
|
||||
) 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
|
||||
#"JSON"[data]
|
||||
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]
|
||||
in
|
||||
result
|
||||
@@ -0,0 +1,18 @@
|
||||
// Function to encode userInfoData as base64-encoded JSON string
|
||||
(userInfoData as record) as text =>
|
||||
let
|
||||
JsonText = Text.FromBinary(
|
||||
Json.FromValue(userInfoData),
|
||||
TextEncoding.Utf8
|
||||
),
|
||||
|
||||
// Convert JSON text to binary
|
||||
JsonBinary = Text.ToBinary(JsonText, TextEncoding.Utf8),
|
||||
|
||||
// Encode binary as base64
|
||||
Base64Encoded = Binary.ToText(JsonBinary, BinaryEncoding.Base64),
|
||||
|
||||
// Return base64-encoded string
|
||||
Result = Base64Encoded
|
||||
in
|
||||
Result
|
||||
@@ -0,0 +1,135 @@
|
||||
// Function to exchange powerful token for weak limited token
|
||||
(powerfulToken as text, scopes as list, projectId as text, serverUrl as text, optional resourceIdString as text) as record =>
|
||||
let
|
||||
// Helper function to load .pqm modules dynamically
|
||||
Extension.LoadFunction = (fileName as text) =>
|
||||
let
|
||||
binary = Extension.Contents(fileName),
|
||||
asText = Text.FromBinary(binary)
|
||||
in
|
||||
try
|
||||
Expression.Evaluate(asText, #shared)
|
||||
catch (e) =>
|
||||
error
|
||||
[
|
||||
Reason = "Extension.LoadFunction Failure",
|
||||
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
|
||||
Message.Parameters = {fileName, e[Reason], e[Message]},
|
||||
Detail = [File = fileName, Error = e]
|
||||
],
|
||||
|
||||
// Validate inputs
|
||||
ValidationError = if Text.Length(powerfulToken) = 0 then
|
||||
"PowerfulToken is required"
|
||||
else if List.Count(scopes) = 0 then
|
||||
"Scopes are required"
|
||||
else if Text.Length(projectId) = 0 then
|
||||
"ProjectId is required"
|
||||
else if Text.Length(serverUrl) = 0 then
|
||||
"ServerUrl is required"
|
||||
else
|
||||
null,
|
||||
|
||||
// Ensure serverUrl ends with /
|
||||
NormalizedServerUrl = if Text.End(serverUrl, 1) = "/" then
|
||||
serverUrl
|
||||
else
|
||||
serverUrl & "/",
|
||||
|
||||
// New Share Token API mutation with variables
|
||||
NewGraphQLQuery = "mutation CreateEmbedShareToken($input: CreateEmbedShareTokenInput!) {
|
||||
sharingMutations {
|
||||
createEmbedShareToken(input: $input) {
|
||||
token
|
||||
}
|
||||
}
|
||||
}",
|
||||
NewGraphQLVariables = [
|
||||
input = [
|
||||
projectId = projectId,
|
||||
resourceIdString = resourceIdString
|
||||
]
|
||||
],
|
||||
|
||||
// Legacy apiTokenCreate mutation with variables
|
||||
TokenLifespanMs = 10 * 365 * 24 * 3600 * 1000,
|
||||
TokenName = "Limited Power BI Visual Token - " & DateTime.ToText(DateTime.LocalNow(), "yyyy-MM-dd HH:mm"),
|
||||
LegacyGraphQLQuery = "mutation CreateApiToken($token: ApiTokenCreateInput!) {
|
||||
apiTokenCreate(token: $token)
|
||||
}",
|
||||
LegacyGraphQLVariables = [
|
||||
token = [
|
||||
name = TokenName,
|
||||
scopes = scopes,
|
||||
lifespan = TokenLifespanMs,
|
||||
limitResources = {[
|
||||
type = "project",
|
||||
id = projectId
|
||||
]}
|
||||
]
|
||||
],
|
||||
|
||||
// Helper: execute a GraphQL query with variables and extract token
|
||||
ExecuteGraphQL = (query as text, variables as record, extractToken as function) =>
|
||||
let
|
||||
Response = Web.Contents(
|
||||
NormalizedServerUrl & "graphql",
|
||||
[
|
||||
Headers = [
|
||||
#"Method" = "POST",
|
||||
#"Content-Type" = "application/json",
|
||||
#"Authorization" = "Bearer " & powerfulToken
|
||||
],
|
||||
Content = Json.FromValue([
|
||||
query = query,
|
||||
variables = variables
|
||||
]),
|
||||
ManualStatusHandling = {400, 401, 403, 404, 500, 502, 503, 504},
|
||||
Timeout = #duration(0, 0, 0, 10)
|
||||
]
|
||||
),
|
||||
StatusCode = Value.Metadata(Response)[Response.Status],
|
||||
JsonResponse = if StatusCode >= 200 and StatusCode < 300 then
|
||||
Json.Document(Response)
|
||||
else
|
||||
null,
|
||||
HasErrors = JsonResponse <> null and Record.HasFields(JsonResponse, {"errors"}),
|
||||
Token = if JsonResponse <> null and not HasErrors then
|
||||
try extractToken(JsonResponse) otherwise null
|
||||
else
|
||||
null,
|
||||
ErrorMsg = if HasErrors then
|
||||
try JsonResponse[errors]{0}[message] otherwise "GraphQL mutation failed"
|
||||
else if JsonResponse = null then
|
||||
"Request failed with status " & Number.ToText(StatusCode)
|
||||
else
|
||||
null
|
||||
in
|
||||
[Success = Token <> null, Token = Token, ErrorMessage = ErrorMsg],
|
||||
|
||||
// Try new API first, fall back to legacy
|
||||
Result = if ValidationError <> null then
|
||||
[Success = false, Token = null, ErrorMessage = ValidationError]
|
||||
else
|
||||
let
|
||||
newResult = if resourceIdString <> null then
|
||||
try ExecuteGraphQL(
|
||||
NewGraphQLQuery,
|
||||
NewGraphQLVariables,
|
||||
each [data][sharingMutations][createEmbedShareToken][token]
|
||||
) otherwise [Success = false, Token = null, ErrorMessage = "New API request failed"]
|
||||
else
|
||||
[Success = false, Token = null, ErrorMessage = null],
|
||||
|
||||
finalResult = if newResult[Success] then
|
||||
newResult
|
||||
else
|
||||
try ExecuteGraphQL(
|
||||
LegacyGraphQLQuery,
|
||||
LegacyGraphQLVariables,
|
||||
each [data][apiTokenCreate]
|
||||
) otherwise [Success = false, Token = null, ErrorMessage = "Token exchange request failed"]
|
||||
in
|
||||
finalResult
|
||||
in
|
||||
Result
|
||||
@@ -29,7 +29,7 @@
|
||||
versionId = parsedUrl[versionId],
|
||||
|
||||
// get API key if available
|
||||
apiKey = try Extension.CurrentCredential()[Key] otherwise null,
|
||||
apiKey = try Extension.CurrentCredential()[access_token] otherwise null,
|
||||
|
||||
// graphql query to get model info including root object id
|
||||
// includes specific version if provided
|
||||
|
||||
@@ -39,8 +39,9 @@
|
||||
),
|
||||
// fields to remove from data record
|
||||
FieldsToRemove = {"__closure", "totalChildrenCount", "renderMaterialProxies"},
|
||||
// create the final table with cleaned data records
|
||||
FinalTable = Table.FromRecords(
|
||||
|
||||
// create basic table with cleaned data records (no properties column yet)
|
||||
BasicTable = Table.FromRecords(
|
||||
List.Transform(
|
||||
TableFromList[Column1],
|
||||
each let
|
||||
@@ -48,18 +49,21 @@
|
||||
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 = Record.RemoveFields(record, fieldsToRemoveForThisRecord) // Data
|
||||
#"Model Info" = rootId,
|
||||
#"Application ID" = Record.FieldOrDefault(record, "applicationId", null), // Application ID
|
||||
data = cleanedRecord // Data
|
||||
]
|
||||
)
|
||||
),
|
||||
|
||||
// Function to check if a row should be excluded based on speckle type
|
||||
// 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", "")
|
||||
@@ -70,14 +74,38 @@
|
||||
// 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
|
||||
HasDataObjects = Table.RowCount(
|
||||
Table.SelectRows(
|
||||
FinalTable,
|
||||
BasicTable,
|
||||
each Text.Contains(Record.FieldOrDefault([data], "speckle_type", ""), "DataObject")
|
||||
and not ShouldExcludeRow(_)
|
||||
)
|
||||
) > 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,
|
||||
|
||||
@@ -26,7 +26,7 @@ in
|
||||
parsedUrl = Parser(url),
|
||||
server = parsedUrl[baseUrl],
|
||||
|
||||
apiKey = try Extension.CurrentCredential()[Key] otherwise try Extension.CurrentCredential()[access_token] otherwise "",
|
||||
apiKey = try Extension.CurrentCredential()[access_token] otherwise "",
|
||||
|
||||
query = "query {
|
||||
activeUser {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Function to mark a version as received via GraphQL mutation
|
||||
// Uses the powerful token
|
||||
(powerfulToken as text, versionId as text, projectId as text, serverUrl as text) as logical =>
|
||||
try
|
||||
let
|
||||
NormalizedServerUrl = if Text.End(serverUrl, 1) = "/" then
|
||||
serverUrl
|
||||
else
|
||||
serverUrl & "/",
|
||||
|
||||
// Build GraphQL
|
||||
GraphQLMutation = "mutation MarkVersionReceived($input: MarkReceivedVersionInput!) { versionMutations { markReceived(input: $input) } }",
|
||||
|
||||
Variables = [
|
||||
input = [
|
||||
versionId = versionId,
|
||||
projectId = projectId,
|
||||
sourceApplication = "powerbi-data"
|
||||
]
|
||||
],
|
||||
|
||||
// Make GraphQL request
|
||||
Response = Web.Contents(
|
||||
NormalizedServerUrl & "graphql",
|
||||
[
|
||||
Headers = [
|
||||
#"Method" = "POST",
|
||||
#"Content-Type" = "application/json",
|
||||
#"Authorization" = "Bearer " & powerfulToken
|
||||
],
|
||||
Content = Json.FromValue([
|
||||
query = GraphQLMutation,
|
||||
variables = Variables
|
||||
]),
|
||||
ManualStatusHandling = {400, 401, 403, 404, 500, 502, 503, 504},
|
||||
Timeout = #duration(0, 0, 0, 5)
|
||||
]
|
||||
),
|
||||
|
||||
StatusCode = Value.Metadata(Response)[Response.Status]
|
||||
in
|
||||
StatusCode >= 200 and StatusCode < 300
|
||||
otherwise
|
||||
false
|
||||
@@ -0,0 +1,42 @@
|
||||
// function for federating multiple tables by combining them and creating concatenated Version Object ID and Model Info fields
|
||||
(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", "Model Info", "Object IDs"}, MissingField.Ignore)
|
||||
else
|
||||
_
|
||||
),
|
||||
|
||||
CombinedTable = Table.Combine(ProcessedTables),
|
||||
|
||||
DistinctVersionObjectIDs = List.Distinct(CombinedTable[Version Object ID]),
|
||||
ConcatenatedVersionObjectIDs = Text.Combine(DistinctVersionObjectIDs, ","),
|
||||
|
||||
DistinctModelInfo = List.Distinct(CombinedTable[Model Info]),
|
||||
ConcatenatedModelInfo = Text.Combine(DistinctModelInfo, "|||"),
|
||||
|
||||
// Replace all Version Object ID values with the concatenated string
|
||||
TableWithVersionObjectID = Table.ReplaceValue(
|
||||
CombinedTable,
|
||||
each [Version Object ID],
|
||||
ConcatenatedVersionObjectIDs,
|
||||
Replacer.ReplaceText,
|
||||
{"Version Object ID"}
|
||||
),
|
||||
|
||||
// Replace all Model Info values with the concatenated string
|
||||
FederatedTable = Table.ReplaceValue(
|
||||
TableWithVersionObjectID,
|
||||
each [Model Info],
|
||||
ConcatenatedModelInfo,
|
||||
Replacer.ReplaceText,
|
||||
{"Model Info"}
|
||||
)
|
||||
in
|
||||
FederatedTable
|
||||
@@ -0,0 +1,22 @@
|
||||
// function for transforming a table to extract and expand Material Quantities data
|
||||
(inputTable as table, optional addPrefix as logical) as table =>
|
||||
let
|
||||
// Default addPrefix to false if not provided
|
||||
UsePrefix = if addPrefix = null then false else addPrefix,
|
||||
|
||||
// Add mq column using existing MaterialQuantities function with list output
|
||||
AddedMQ = Table.AddColumn(inputTable, "mq", each Speckle.Objects.MaterialQuantities([data], true)),
|
||||
|
||||
// Expand the mq list column
|
||||
ExpandMQ = Table.ExpandListColumn(AddedMQ, "mq"),
|
||||
|
||||
// Add MQProperties column using Properties function with error handling
|
||||
AddedMQProperties = Table.AddColumn(ExpandMQ, "MQ", each try Speckle.Objects.Properties([mq]) otherwise null),
|
||||
|
||||
// Expand the MQProperties record using Utils.ExpandRecord
|
||||
ExpandMQProperties = Speckle.Utils.ExpandRecord(AddedMQProperties, "MQ", null, UsePrefix),
|
||||
|
||||
// Remove the temporary mq and MQProperties columns
|
||||
FinalTable = Table.RemoveColumns(ExpandMQProperties, {"mq", "MQ"}, MissingField.Ignore)
|
||||
in
|
||||
FinalTable
|
||||
@@ -0,0 +1,163 @@
|
||||
// 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
|
||||
@@ -0,0 +1,18 @@
|
||||
(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
|
||||
@@ -0,0 +1,15 @@
|
||||
// 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
|
||||
@@ -0,0 +1,257 @@
|
||||
// function for extracting and flattening properties from Speckle objects
|
||||
(inputRecord as any, optional filterKeys as list, optional parentPath as text, optional existingFields as list) as record =>
|
||||
let
|
||||
// Define excluded paths
|
||||
ExcludedPaths = {
|
||||
"Composite Structure",
|
||||
"Material Quantities",
|
||||
"Parameters.Type Parameters.Structure"
|
||||
},
|
||||
|
||||
// Helper function to check if a path should be excluded
|
||||
IsPathExcluded = (currentPath as text) as logical =>
|
||||
List.AnyTrue(List.Transform(ExcludedPaths, each Text.Contains(currentPath, _))),
|
||||
|
||||
// Helper function to resolve naming conflicts
|
||||
ResolveFieldName = (fieldName as text, parentPathParam as nullable text, existingFieldsParam as nullable list) as text =>
|
||||
let
|
||||
// Ensure we have valid inputs
|
||||
parentPath = if parentPathParam = null then "" else parentPathParam,
|
||||
existingFields = if existingFieldsParam = null then {} else existingFieldsParam,
|
||||
|
||||
// Try original field name first
|
||||
candidateName = fieldName,
|
||||
|
||||
// If no conflict, return original name
|
||||
finalName = if not List.Contains(existingFields, candidateName) then
|
||||
candidateName
|
||||
else if parentPath = "" then
|
||||
fieldName // No parent path available, keep original
|
||||
else
|
||||
let
|
||||
// Split parent path and try adding parents one by one
|
||||
pathParts = Text.Split(parentPath, "."),
|
||||
reversedParts = List.Reverse(pathParts), // Start with immediate parent
|
||||
|
||||
// Use iteration instead of recursion
|
||||
ResolveWithIteration = () =>
|
||||
let
|
||||
// Generate all possible candidates
|
||||
candidates = List.Generate(
|
||||
() => [depth = 1, candidate = fieldName & "." & List.First(reversedParts)],
|
||||
each [depth] <= List.Count(reversedParts),
|
||||
each [
|
||||
depth = [depth] + 1,
|
||||
candidate = fieldName & "." & Text.Combine(List.FirstN(reversedParts, [depth]), ".")
|
||||
],
|
||||
each [candidate]
|
||||
),
|
||||
|
||||
// Find first non-conflicting candidate
|
||||
firstNonConflicting = List.First(
|
||||
List.Select(candidates, each not List.Contains(existingFields, _)),
|
||||
// If all conflict, use full path
|
||||
fieldName & "." & Text.Combine(reversedParts, ".")
|
||||
)
|
||||
in
|
||||
firstNonConflicting,
|
||||
|
||||
resolvedName = ResolveWithIteration()
|
||||
in
|
||||
resolvedName
|
||||
in
|
||||
finalName,
|
||||
|
||||
// Create the main flattening function with self-reference capability
|
||||
FlattenRecordImpl = (
|
||||
flattenFn as function,
|
||||
inputRecord as any,
|
||||
filterKeys as nullable list,
|
||||
parentPathParam as nullable text,
|
||||
existingFieldsParam as nullable list
|
||||
) as record =>
|
||||
let
|
||||
// Ensure non-null values for internal use
|
||||
currentParentPath = if parentPathParam = null then "" else parentPathParam,
|
||||
currentExistingFields = if existingFieldsParam = null then {} else existingFieldsParam,
|
||||
currentfilterKeys = filterKeys,
|
||||
|
||||
// Check if record has "properties" field and use it instead of the root record
|
||||
recordToProcess = if inputRecord = null then
|
||||
null
|
||||
else if Value.Is(inputRecord, type record) and Record.HasFields(inputRecord, {"properties"}) then
|
||||
Record.Field(inputRecord, "properties")
|
||||
else
|
||||
inputRecord,
|
||||
|
||||
// Helper function to check if a field should be included
|
||||
ShouldIncludeField = (fieldName as text) as logical =>
|
||||
if currentfilterKeys = null then true
|
||||
else List.Contains(currentfilterKeys, fieldName),
|
||||
|
||||
// Handle different input types
|
||||
result = if recordToProcess = null then
|
||||
[]
|
||||
else if Value.Is(recordToProcess, type record) then
|
||||
let
|
||||
fieldNames = Record.FieldNames(recordToProcess),
|
||||
|
||||
// Process each field
|
||||
processedFields = List.Accumulate(
|
||||
fieldNames,
|
||||
[FlattenedRecord = [], ExistingFieldsList = currentExistingFields],
|
||||
(state, fieldName) =>
|
||||
let
|
||||
fieldValue = Record.Field(recordToProcess, fieldName),
|
||||
newPath = if currentParentPath = "" then fieldName else currentParentPath & "." & fieldName,
|
||||
|
||||
// Skip if path is excluded
|
||||
shouldProcess = not IsPathExcluded(newPath),
|
||||
|
||||
processResult = if not shouldProcess then
|
||||
state
|
||||
else
|
||||
let
|
||||
// Check if this is a name/value record
|
||||
hasNameValue = Value.Is(fieldValue, type record) and
|
||||
Record.HasFields(fieldValue, {"name", "value"}),
|
||||
|
||||
finalResult = if hasNameValue then
|
||||
let
|
||||
nameField = Record.Field(fieldValue, "name"),
|
||||
valueField = Record.Field(fieldValue, "value"),
|
||||
// Check if this name field should be included
|
||||
shouldInclude = if nameField = null then false else ShouldIncludeField(nameField),
|
||||
result = if shouldInclude and nameField <> null then
|
||||
let
|
||||
resolvedName = ResolveFieldName(nameField, currentParentPath, state[ExistingFieldsList]),
|
||||
newRecord = Record.AddField(state[FlattenedRecord], resolvedName, valueField),
|
||||
newFieldsList = state[ExistingFieldsList] & {resolvedName}
|
||||
in
|
||||
[FlattenedRecord = newRecord, ExistingFieldsList = newFieldsList]
|
||||
else
|
||||
state
|
||||
in
|
||||
result
|
||||
else if fieldValue = null then
|
||||
let
|
||||
shouldInclude = ShouldIncludeField(fieldName),
|
||||
result = if shouldInclude then
|
||||
let
|
||||
resolvedName = ResolveFieldName(fieldName, currentParentPath, state[ExistingFieldsList]),
|
||||
newRecord = Record.AddField(state[FlattenedRecord], resolvedName, null),
|
||||
newFieldsList = state[ExistingFieldsList] & {resolvedName}
|
||||
in
|
||||
[FlattenedRecord = newRecord, ExistingFieldsList = newFieldsList]
|
||||
else
|
||||
state
|
||||
in
|
||||
result
|
||||
else if Value.Is(fieldValue, type record) then
|
||||
let
|
||||
// Skip empty records
|
||||
fieldCount = Record.FieldCount(fieldValue),
|
||||
recursiveResult = if fieldCount = 0 then
|
||||
state
|
||||
else
|
||||
let
|
||||
// Call the function through the passed reference
|
||||
// IMPORTANT: Pass the current state's existing fields list
|
||||
flattened = flattenFn(flattenFn, fieldValue, currentfilterKeys, newPath, state[ExistingFieldsList]),
|
||||
// Get all field names from the flattened result
|
||||
flattenedFieldNames = Record.FieldNames(flattened),
|
||||
// Merge the flattened record with the current state
|
||||
combinedRecord = flattened & state[FlattenedRecord],
|
||||
// Update the existing fields list with ALL fields from both records
|
||||
allFieldNames = List.Distinct(state[ExistingFieldsList] & flattenedFieldNames)
|
||||
in
|
||||
[FlattenedRecord = combinedRecord, ExistingFieldsList = allFieldNames]
|
||||
in
|
||||
recursiveResult
|
||||
else if Value.Is(fieldValue, type list) then
|
||||
let
|
||||
listLength = List.Count(fieldValue),
|
||||
// Skip empty lists
|
||||
listResult = if listLength = 0 then
|
||||
state
|
||||
else
|
||||
List.Accumulate(
|
||||
List.Positions(fieldValue),
|
||||
state,
|
||||
(listState, index) =>
|
||||
let
|
||||
listItem = fieldValue{index},
|
||||
indexSuffix = Text.From(index + 1), // 1-based indexing
|
||||
listFieldName = fieldName & "." & indexSuffix,
|
||||
listPath = if currentParentPath = "" then listFieldName else currentParentPath & "." & listFieldName,
|
||||
|
||||
itemResult = if Value.Is(listItem, type record) then
|
||||
let
|
||||
itemFieldCount = Record.FieldCount(listItem),
|
||||
itemFlattened = if itemFieldCount = 0 then
|
||||
listState
|
||||
else
|
||||
let
|
||||
// Call the function through the passed reference
|
||||
flattened = flattenFn(flattenFn, listItem, currentfilterKeys, listPath, listState[ExistingFieldsList]),
|
||||
// Get all field names from the flattened result
|
||||
flattenedFieldNames = Record.FieldNames(flattened),
|
||||
// Merge the flattened record with the current state
|
||||
combinedRecord = flattened & listState[FlattenedRecord],
|
||||
// Update the existing fields list with ALL fields
|
||||
allFieldNames = List.Distinct(listState[ExistingFieldsList] & flattenedFieldNames)
|
||||
in
|
||||
[FlattenedRecord = combinedRecord, ExistingFieldsList = allFieldNames]
|
||||
in
|
||||
itemFlattened
|
||||
else
|
||||
let
|
||||
shouldInclude = ShouldIncludeField(listFieldName),
|
||||
result = if shouldInclude then
|
||||
let
|
||||
resolvedName = ResolveFieldName(listFieldName, currentParentPath, listState[ExistingFieldsList]),
|
||||
newRecord = Record.AddField(listState[FlattenedRecord], resolvedName, listItem),
|
||||
newFieldsList = listState[ExistingFieldsList] & {resolvedName}
|
||||
in
|
||||
[FlattenedRecord = newRecord, ExistingFieldsList = newFieldsList]
|
||||
else
|
||||
listState
|
||||
in
|
||||
result
|
||||
in
|
||||
itemResult
|
||||
)
|
||||
in
|
||||
listResult
|
||||
else
|
||||
// Handle primitive values
|
||||
let
|
||||
shouldInclude = ShouldIncludeField(fieldName),
|
||||
result = if shouldInclude then
|
||||
let
|
||||
resolvedName = ResolveFieldName(fieldName, currentParentPath, state[ExistingFieldsList]),
|
||||
newRecord = Record.AddField(state[FlattenedRecord], resolvedName, fieldValue),
|
||||
newFieldsList = state[ExistingFieldsList] & {resolvedName}
|
||||
in
|
||||
[FlattenedRecord = newRecord, ExistingFieldsList = newFieldsList]
|
||||
else
|
||||
state
|
||||
in
|
||||
result
|
||||
in
|
||||
finalResult
|
||||
in
|
||||
processResult
|
||||
)
|
||||
in
|
||||
processedFields[FlattenedRecord]
|
||||
else
|
||||
// If input is not a record, return it as is in a record wrapper
|
||||
[Value = recordToProcess]
|
||||
in
|
||||
result,
|
||||
|
||||
// Call the implementation with self-reference
|
||||
result = FlattenRecordImpl(FlattenRecordImpl, inputRecord, filterKeys, parentPath, existingFields)
|
||||
in
|
||||
result
|
||||
@@ -54,5 +54,6 @@
|
||||
modelId = if isFederated then null else processedModels{0}[modelId],
|
||||
versionId = if isFederated then null else processedModels{0}[versionId],
|
||||
isFederated = isFederated,
|
||||
federatedModels = if isFederated then processedModels else null
|
||||
federatedModels = if isFederated then processedModels else null,
|
||||
resourceIdString = rawModelSegment
|
||||
]
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// Function for getting issues from Speckle projects, models, or versions
|
||||
(url as text, optional getReplies as logical) as table =>
|
||||
let
|
||||
// Import required functions
|
||||
Parser = Extension.LoadFunction("Parser.pqm"),
|
||||
ApiFetch = Extension.LoadFunction("Api.Fetch.pqm"),
|
||||
|
||||
// Set default value for getReplies parameter
|
||||
getRepliesValue = if getReplies = null then false else getReplies,
|
||||
|
||||
// Extension.LoadFunction logic for importing functions from other files
|
||||
Extension.LoadFunction = (fileName as text) =>
|
||||
let
|
||||
binary = Extension.Contents(fileName),
|
||||
asText = Text.FromBinary(binary)
|
||||
in
|
||||
try
|
||||
Expression.Evaluate(asText, #shared)
|
||||
catch (e) =>
|
||||
error
|
||||
[
|
||||
Reason = "Extension.LoadFunction Failure",
|
||||
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
|
||||
Message.Parameters = {fileName, e[Reason], e[Message]},
|
||||
Detail = [File = fileName, Error = e]
|
||||
],
|
||||
|
||||
// Parse the URL to get necessary components with fallback for project-only URLs
|
||||
parsedUrl = try Parser(url) otherwise
|
||||
// Custom parsing for project-only URLs
|
||||
let
|
||||
urlParts = Uri.Parts(url),
|
||||
baseUrl = Text.Combine({urlParts[Scheme], "://", urlParts[Host]}),
|
||||
pathSegments = List.Select(Text.Split(urlParts[Path], "/"), each _ <> ""),
|
||||
projectId = if List.Count(pathSegments) >= 2 and pathSegments{0} = "projects"
|
||||
then pathSegments{1} else null
|
||||
in
|
||||
if projectId = null then
|
||||
error [
|
||||
Reason = "Invalid URL",
|
||||
Message = "The URL must be a valid Speckle project URL in the format 'https://server/projects/PROJECT_ID' or include models/versions"
|
||||
]
|
||||
else
|
||||
[
|
||||
baseUrl = baseUrl,
|
||||
projectId = projectId,
|
||||
modelId = null,
|
||||
versionId = null
|
||||
],
|
||||
|
||||
server = parsedUrl[baseUrl],
|
||||
projectId = parsedUrl[projectId],
|
||||
modelId = parsedUrl[modelId],
|
||||
versionId = parsedUrl[versionId],
|
||||
|
||||
// Define the GraphQL query (single query for all scopes)
|
||||
issuesQuery = "query Project($projectId: String!, $input: ProjectIssuesInput" &
|
||||
(if getRepliesValue then ", $repliesInput2: IssueRepliesInput" else "") & ") {
|
||||
project(id: $projectId) {
|
||||
issues(input: $input) {
|
||||
items {
|
||||
identifier
|
||||
title
|
||||
rawDescription
|
||||
status
|
||||
priority
|
||||
assignee {
|
||||
user {
|
||||
name
|
||||
}
|
||||
}
|
||||
dueDate
|
||||
labels {
|
||||
name
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
resourceIdString
|
||||
viewerState
|
||||
id" &
|
||||
(if getRepliesValue then "
|
||||
replies(input: $repliesInput2) {
|
||||
items {
|
||||
issueId
|
||||
id
|
||||
rawDescription
|
||||
createdAt
|
||||
author {
|
||||
user {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}" else "") & "
|
||||
}
|
||||
}
|
||||
}
|
||||
}",
|
||||
|
||||
// Build input variable dynamically based on URL scope
|
||||
inputVariable =
|
||||
if versionId <> null then
|
||||
// Version URL: resourceIdString = "MODEL_ID@VERSION_ID"
|
||||
[
|
||||
limit = 10000,
|
||||
resourceIdString = modelId & "@" & versionId
|
||||
]
|
||||
else if modelId <> null then
|
||||
// Model URL: resourceIdString = MODEL_ID
|
||||
[
|
||||
limit = 10000,
|
||||
resourceIdString = modelId
|
||||
]
|
||||
else
|
||||
// Project URL: no resourceIdString
|
||||
[
|
||||
limit = 10000
|
||||
],
|
||||
|
||||
// Build query variables
|
||||
queryVariables = if getRepliesValue then
|
||||
[
|
||||
projectId = projectId,
|
||||
input = inputVariable,
|
||||
repliesInput2 = [limit = 10000]
|
||||
]
|
||||
else
|
||||
[
|
||||
projectId = projectId,
|
||||
input = inputVariable
|
||||
],
|
||||
|
||||
// Make the API request using ApiFetch
|
||||
result = ApiFetch(server, issuesQuery, queryVariables),
|
||||
|
||||
// Extract issues from the response
|
||||
issues = result[project][issues][items],
|
||||
|
||||
// Transform to table structure with specified columns
|
||||
issuesTable = Table.FromRecords(
|
||||
List.Transform(issues, (issue) =>
|
||||
let
|
||||
// Extract selectedObjectApplicationIds from viewerState (already a record object)
|
||||
viewerState = try issue[viewerState] otherwise null,
|
||||
selectedObjectIds = try viewerState[ui][filters][selectedObjectApplicationIds] otherwise null,
|
||||
objectIds = try Record.FieldNames(selectedObjectIds) otherwise null,
|
||||
applicationIds = try Record.FieldValues(selectedObjectIds) otherwise null,
|
||||
|
||||
baseRecord = [
|
||||
ID = issue[identifier],
|
||||
Title = issue[title],
|
||||
Description = try issue[rawDescription] otherwise null,
|
||||
Status = try issue[status] otherwise null,
|
||||
Priority = try issue[priority] otherwise null,
|
||||
Assignee = try issue[assignee][user][name] otherwise null,
|
||||
#"Due Date" = try DateTime.From(issue[dueDate]) otherwise null,
|
||||
Labels = try List.Transform(issue[labels], each _[name]) otherwise {},
|
||||
#"Created at" = try DateTime.From(issue[createdAt]) otherwise null,
|
||||
#"Updated at" = try DateTime.From(issue[updatedAt]) otherwise null,
|
||||
URL = server & "/projects/" & projectId & "/models/" & issue[resourceIdString] & "#threadId=" & issue[id],
|
||||
#"Object IDs" = objectIds,
|
||||
#"Application IDs" = applicationIds
|
||||
],
|
||||
recordWithReplies = if getRepliesValue then
|
||||
baseRecord & [Replies = try issue[replies][items] otherwise null]
|
||||
else
|
||||
baseRecord
|
||||
in
|
||||
recordWithReplies
|
||||
)
|
||||
)
|
||||
in
|
||||
issuesTable
|
||||
@@ -1,13 +1,11 @@
|
||||
(url as text) as list =>
|
||||
try let
|
||||
// Import required functions
|
||||
let
|
||||
GetModel = Extension.LoadFunction("GetModel.pqm"),
|
||||
Parser = Extension.LoadFunction("Parser.pqm"),
|
||||
GetUser = Extension.LoadFunction("GetUser.pqm"),
|
||||
GetVersion = Extension.LoadFunction("GetVersion.pqm"),
|
||||
GetWorkspace = Extension.LoadFunction("GetWorkspace.pqm"),
|
||||
ExchangeToken = Extension.LoadFunction("ExchangeToken.pqm"),
|
||||
|
||||
// the logic for importing functions from other files
|
||||
// helper function to load .pqm modules dynamically
|
||||
Extension.LoadFunction = (fileName as text) =>
|
||||
let
|
||||
binary = Extension.Contents(fileName),
|
||||
@@ -27,53 +25,76 @@
|
||||
modelInfo = GetModel(url),
|
||||
parsedUrl = Parser(url),
|
||||
userInfo = GetUser(url),
|
||||
powerfulToken = userInfo[Token],
|
||||
|
||||
apiKey = userInfo[Token],
|
||||
|
||||
userEmail = userInfo[UserEmail],
|
||||
|
||||
// get version from Speckle.pq - look GetVersion.pqm
|
||||
connectorVersion = GetVersion(),
|
||||
|
||||
workspaceInfo = GetWorkspace(url),
|
||||
|
||||
// Prepare request data
|
||||
requestData = Json.FromValue([
|
||||
Url = url,
|
||||
Server = parsedUrl[baseUrl],
|
||||
Email = userEmail,
|
||||
ProjectId = parsedUrl[projectId],
|
||||
ObjectId = modelInfo[rootObjectId],
|
||||
SourceApplication = modelInfo[sourceApplication],
|
||||
Token = apiKey,
|
||||
Version = connectorVersion,
|
||||
WorkspaceId = workspaceInfo[workspaceId],
|
||||
WorkspaceName = workspaceInfo[workspaceName],
|
||||
WorkspaceLogo = workspaceInfo[workspaceLogo],
|
||||
CanHideBranding = workspaceInfo[canHideBranding]
|
||||
]),
|
||||
|
||||
// Send request to local server
|
||||
Response = Web.Contents(
|
||||
"http://127.0.0.1:29364/download",
|
||||
[
|
||||
Headers = [
|
||||
#"Content-Type" = "application/json",
|
||||
#"Method" = "POST"
|
||||
],
|
||||
Content = requestData,
|
||||
ManualStatusHandling = {400, 401, 403, 404, 500}
|
||||
]
|
||||
// exchange powerful token for weak token using GraphQL
|
||||
// this replaces the desktop service token exchange
|
||||
tokenExchangeResult = ExchangeToken(
|
||||
powerfulToken,
|
||||
{"profile:read", "streams:read", "users:read"},
|
||||
parsedUrl[projectId],
|
||||
parsedUrl[baseUrl],
|
||||
parsedUrl[resourceIdString]
|
||||
),
|
||||
|
||||
// Parse response
|
||||
JsonResponse = Json.Document(Response)
|
||||
|
||||
// throw error if token exchange failed - do NOT use powerful token as fallback
|
||||
tokenToUse = if tokenExchangeResult[Success] then
|
||||
tokenExchangeResult[Token]
|
||||
else
|
||||
error [
|
||||
Reason = "TokenExchangeFailed",
|
||||
Message.Format = "Failed to exchange token for limited scope token: #{0}",
|
||||
Message.Parameters = {tokenExchangeResult[ErrorMessage]},
|
||||
Detail = [
|
||||
ErrorMessage = tokenExchangeResult[ErrorMessage],
|
||||
ProjectId = parsedUrl[projectId],
|
||||
ServerUrl = parsedUrl[baseUrl]
|
||||
]
|
||||
],
|
||||
|
||||
// downloads data directly from server
|
||||
DirectDownload = (token as text) =>
|
||||
let
|
||||
objectUrl = Text.Combine({
|
||||
parsedUrl[baseUrl],
|
||||
"/objects/",
|
||||
parsedUrl[projectId],
|
||||
"/",
|
||||
modelInfo[rootObjectId]
|
||||
}),
|
||||
|
||||
Response = Web.Contents(
|
||||
objectUrl,
|
||||
[
|
||||
Headers = [
|
||||
#"Authorization" = "Bearer " & token,
|
||||
#"Accept" = "application/json"
|
||||
],
|
||||
ManualStatusHandling = {400, 401, 403, 404, 500, 502, 503, 504}
|
||||
]
|
||||
),
|
||||
|
||||
StatusCode = Value.Metadata(Response)[Response.Status],
|
||||
|
||||
JsonResponse = if StatusCode >= 200 and StatusCode < 300 then
|
||||
Json.Document(Response)
|
||||
else
|
||||
error [
|
||||
Reason = "DirectDownloadFailed",
|
||||
Message.Format = "Failed to download model data from Speckle server (Status: #{0})",
|
||||
Message.Parameters = {Text.From(StatusCode)},
|
||||
Detail = [
|
||||
StatusCode = StatusCode,
|
||||
ObjectUrl = objectUrl,
|
||||
ProjectId = parsedUrl[projectId],
|
||||
RootObjectId = modelInfo[rootObjectId]
|
||||
]
|
||||
]
|
||||
in
|
||||
JsonResponse,
|
||||
|
||||
// download data using the token (weak if exchange succeeded, powerful otherwise)
|
||||
FinalResult = DirectDownload(tokenToUse)
|
||||
|
||||
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."
|
||||
]
|
||||
FinalResult
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// 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
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"dataRoles": [
|
||||
{
|
||||
"displayName": "Version Object ID",
|
||||
"displayName": "Model Info",
|
||||
"kind": "Measure",
|
||||
"name": "rootObjectId"
|
||||
},
|
||||
@@ -89,6 +89,18 @@
|
||||
"properties": {
|
||||
"defaultViewMode": {
|
||||
"type": { "text": true }
|
||||
},
|
||||
"navbarHidden": {
|
||||
"type": { "bool": true }
|
||||
},
|
||||
"edgesEnabled": {
|
||||
"type": { "bool": true }
|
||||
},
|
||||
"edgesWeight": {
|
||||
"type": { "numeric": true }
|
||||
},
|
||||
"edgesColor": {
|
||||
"type": { "numeric": true }
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -102,6 +114,16 @@
|
||||
},
|
||||
"isGhost": {
|
||||
"type": { "bool": true }
|
||||
},
|
||||
"zoomOnFilter": {
|
||||
"type": { "bool": true }
|
||||
}
|
||||
}
|
||||
},
|
||||
"sectionBox": {
|
||||
"properties": {
|
||||
"boxData": {
|
||||
"type": { "text": true }
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -127,6 +149,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"dataLoading": {
|
||||
"properties": {
|
||||
"internalizeData": {
|
||||
"type": {
|
||||
"bool": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"color": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
@@ -203,7 +234,7 @@
|
||||
{
|
||||
"essential": true,
|
||||
"name": "WebAccess",
|
||||
"parameters": ["https://analytics.speckle.systems", "http://localhost:29364", "*"]
|
||||
"parameters": ["https://analytics.speckle.systems", "*"]
|
||||
},
|
||||
{
|
||||
"essential": false,
|
||||
|
||||
Generated
+3868
-1994
File diff suppressed because it is too large
Load Diff
@@ -17,10 +17,10 @@
|
||||
"@babel/runtime-corejs3": "^7.21.5",
|
||||
"@headlessui/vue": "^1.7.13",
|
||||
"@heroicons/vue": "^2.0.12",
|
||||
"@speckle/objectloader": "^2.23.8",
|
||||
"@speckle/objectloader2": "2.26.7",
|
||||
"@speckle/tailwind-theme": "2.23.2",
|
||||
"@speckle/ui-components": "2.23.2",
|
||||
"@speckle/viewer": "2.23.23",
|
||||
"@speckle/viewer": "2.26.5",
|
||||
"color-interpolate": "^1.0.5",
|
||||
"core-js": "^3.30.2",
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
@@ -5,76 +5,71 @@
|
||||
<ViewerControlsButtonToggle flat tooltip="Zoom extends" @click="onZoomExtentsClicked">
|
||||
<ArrowsPointingOutIcon class="h-4 w-4 md:h-5 md:w-5" />
|
||||
</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" />
|
||||
<Ghost v-else class="h-5 w-5 opacity-30" />
|
||||
</ViewerControlsButtonToggle>
|
||||
</ViewerControlsButtonGroup>
|
||||
<ViewerControlsButtonGroup>
|
||||
<!-- View Modes -->
|
||||
<ViewerViewModesMenu
|
||||
:open="viewModesOpen"
|
||||
@force-close-others="activeControl = 'none'"
|
||||
@update:open="(value) => toggleActiveControl(value ? 'viewModes' : 'none')"
|
||||
@view-mode-clicked="(value) => $emit('view-mode-clicked', value)"
|
||||
/>
|
||||
<!-- Views -->
|
||||
<ViewerViewsMenu
|
||||
:open="viewsOpen"
|
||||
<!-- View Modes Toggle -->
|
||||
<div class="relative">
|
||||
<ViewerControlsButtonToggle
|
||||
flat
|
||||
tooltip="View modes"
|
||||
:active="viewModesOpen"
|
||||
@click="toggleActiveControl('viewModes')"
|
||||
>
|
||||
<ViewModesIcon class="h-5 w-5" />
|
||||
</ViewerControlsButtonToggle>
|
||||
<!-- View Modes Panel (shown when glasses icon is clicked) -->
|
||||
<ViewerViewModesMenu
|
||||
v-if="viewModesOpen"
|
||||
@view-mode-clicked="(viewMode, options) => $emit('view-mode-clicked', viewMode, options)"
|
||||
/>
|
||||
</div>
|
||||
<!-- Camera -->
|
||||
<ViewerCameraMenu
|
||||
:open="cameraOpen"
|
||||
:views="views"
|
||||
@force-close-others="activeControl = 'none'"
|
||||
@update:open="(value) => toggleActiveControl(value ? 'views' : 'none')"
|
||||
@update:open="(value) => toggleActiveControl(value ? 'camera' : 'none')"
|
||||
@view-clicked="(view) => $emit('view-clicked', view)"
|
||||
/>
|
||||
<!-- Perspective/Ortho -->
|
||||
<ViewerControlsButtonToggle
|
||||
flat
|
||||
secondary
|
||||
tooltip="Projection"
|
||||
:active="visualStore.isOrthoProjection"
|
||||
@click="toggleProjection"
|
||||
>
|
||||
<Perspective v-if="visualStore.isOrthoProjection" class="h-3.5 md:h-4 w-4" />
|
||||
<PerspectiveMore v-else class="h-3.5 md:h-4 w-4" />
|
||||
</ViewerControlsButtonToggle>
|
||||
<!-- Section box -->
|
||||
<div class="relative">
|
||||
<ViewerControlsButtonToggle
|
||||
flat
|
||||
tooltip="Section box"
|
||||
@click="$emit('update:sectionBox')"
|
||||
>
|
||||
<ScissorsIcon class="h-4 w-4 md:h-5 md:w-5" />
|
||||
</ViewerControlsButtonToggle>
|
||||
<span
|
||||
v-if="sectionBox"
|
||||
class="absolute top-1 right-1 h-2 w-2 rounded-full bg-primary pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
</ViewerControlsButtonGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowsPointingOutIcon } from '@heroicons/vue/24/solid'
|
||||
import { SpeckleView } from '@speckle/viewer'
|
||||
import { ArrowsPointingOutIcon, ScissorsIcon } from '@heroicons/vue/24/solid'
|
||||
import { CanonicalView, SpeckleView, ViewMode } from '@speckle/viewer'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useVisualStore } from '@src/store/visualStore'
|
||||
import ViewerControlsButtonGroup from './viewer/controls/ViewerControlsButtonGroup.vue'
|
||||
import ViewerControlsButtonToggle from './viewer/controls/ViewerControlsButtonToggle.vue'
|
||||
|
||||
import ViewerCameraMenu from './viewer/camera/ViewerCameraMenu.vue'
|
||||
import ViewerViewModesMenu from './viewer/view-modes/ViewerViewModesMenu.vue'
|
||||
import ViewerViewsMenu from './viewer/views/ViewerViewsMenu.vue'
|
||||
|
||||
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 ViewModesIcon from '../components/global/icon/ViewModes.vue'
|
||||
import type { ViewModeOptions } from '@src/plugins/viewer'
|
||||
|
||||
const visualStore = useVisualStore()
|
||||
|
||||
const emits = defineEmits([
|
||||
'update:sectionBox',
|
||||
'view-clicked',
|
||||
'toggle-projection',
|
||||
'clear-palette',
|
||||
'view-mode-clicked'
|
||||
])
|
||||
const emits = defineEmits<{
|
||||
(e: 'update:sectionBox', value: boolean): void
|
||||
(e: 'view-clicked', view: CanonicalView | SpeckleView): void
|
||||
(e: 'clear-palette'): void
|
||||
(e: 'view-mode-clicked', viewMode: ViewMode, options: ViewModeOptions): void
|
||||
}>()
|
||||
withDefaults(defineProps<{ sectionBox: boolean; views: SpeckleView[] }>(), {
|
||||
sectionBox: false
|
||||
})
|
||||
@@ -82,7 +77,7 @@ withDefaults(defineProps<{ sectionBox: boolean; views: SpeckleView[] }>(), {
|
||||
type ActiveControl =
|
||||
| 'none'
|
||||
| 'viewModes'
|
||||
| 'views'
|
||||
| 'camera'
|
||||
| 'sun'
|
||||
| 'projection'
|
||||
| 'sectionBox'
|
||||
@@ -99,29 +94,6 @@ const toggleActiveControl = (control: ActiveControl) => {
|
||||
activeControl.value = activeControl.value === control ? 'none' : control
|
||||
}
|
||||
|
||||
const toggleProjection = () => {
|
||||
visualStore.viewerEmit('toggleProjection')
|
||||
visualStore.setIsOrthoProjection(!visualStore.isOrthoProjection)
|
||||
visualStore.writeIsOrthoToFile()
|
||||
}
|
||||
|
||||
const toggleGhostHidden = () => {
|
||||
visualStore.setIsGhost(!visualStore.isGhostActive)
|
||||
visualStore.viewerEmit('toggleGhostHidden', visualStore.isGhostActive)
|
||||
visualStore.writeIsGhostToFile()
|
||||
}
|
||||
|
||||
const viewModesOpen = computed({
|
||||
get: () => activeControl.value === 'viewModes',
|
||||
set: (value) => {
|
||||
activeControl.value = value ? 'viewModes' : 'none'
|
||||
}
|
||||
})
|
||||
|
||||
const viewsOpen = computed({
|
||||
get: () => activeControl.value === 'views',
|
||||
set: (value) => {
|
||||
activeControl.value = value ? 'views' : 'none'
|
||||
}
|
||||
})
|
||||
const viewModesOpen = computed(() => activeControl.value === 'viewModes')
|
||||
const cameraOpen = computed(() => activeControl.value === 'camera')
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="border">
|
||||
<div>
|
||||
<transition name="slide-fade">
|
||||
<nav
|
||||
v-show="!isNavbarCollapsed"
|
||||
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">
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<FormButton
|
||||
v-if="visualStore.latestAvailableVersion && !visualStore.isConnectorUpToDate"
|
||||
v-if="visualStore.latestAvailableVersion && !visualStore.isConnectorUpToDate && visualStore.isRunningInDesktop"
|
||||
v-tippy="{
|
||||
content: 'New connector version is available.<br>Click to download.',
|
||||
allowHTML: true
|
||||
@@ -46,7 +46,7 @@
|
||||
<button
|
||||
class="text-gray-400 hover:text-gray-700 transition"
|
||||
title="Hide navbar"
|
||||
@click="isNavbarCollapsed = true"
|
||||
@click="visualStore.toggleNavbar()"
|
||||
>
|
||||
<ChevronUpIcon class="w-4 h-4" />
|
||||
</button>
|
||||
@@ -58,17 +58,17 @@
|
||||
<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="isNavbarCollapsed ? 'top-1' : 'top-11'"
|
||||
:class="visualStore.isNavbarHidden ? 'top-1' : 'top-11'"
|
||||
>
|
||||
<strong>Object IDs</strong>
|
||||
field is needed for interactivity with other visuals.
|
||||
</div>
|
||||
|
||||
<div v-if="isNavbarCollapsed" class="fixed top-0 right-0 z-20">
|
||||
<div v-if="visualStore.isNavbarHidden" class="fixed top-4 right-2 z-20">
|
||||
<button
|
||||
class="transition opacity-50 hover:opacity-100"
|
||||
title="Show navbar"
|
||||
@click="isNavbarCollapsed = false"
|
||||
@click="visualStore.toggleNavbar()"
|
||||
>
|
||||
<ChevronDownIcon class="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
@@ -76,12 +76,13 @@
|
||||
|
||||
<transition name="slide-left">
|
||||
<ViewerControls
|
||||
v-show="!isNavbarCollapsed"
|
||||
v-model:section-box="bboxActive"
|
||||
v-show="!visualStore.isNavbarHidden"
|
||||
:section-box="sectionBoxEnabled"
|
||||
:views="views"
|
||||
class="fixed top-11 left-2 z-30"
|
||||
@update:section-box="onSectionBoxToggle"
|
||||
@view-clicked="(view) => viewerHandler.setView(view)"
|
||||
@view-mode-clicked="(viewMode) => viewerHandler.setViewMode(viewMode)"
|
||||
@view-mode-clicked="(viewMode, options) => viewerHandler.setViewMode(viewMode, options)"
|
||||
/>
|
||||
</transition>
|
||||
|
||||
@@ -91,6 +92,11 @@
|
||||
</FormButton>
|
||||
</div>
|
||||
|
||||
<div v-if="sectionBoxVisible" class="absolute bottom-5 left-1/2 -translate-x-1/2 z-50 flex gap-2">
|
||||
<FormButton size="sm" color="outline" @click="onSectionBoxReset">Reset</FormButton>
|
||||
<FormButton size="sm" @click="onSectionBoxDone">Done</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'"
|
||||
@@ -149,10 +155,11 @@ const tooltipHandler = inject(tooltipHandlerKey)
|
||||
let viewerHandler: ViewerHandler = null
|
||||
|
||||
const container = ref<HTMLElement>()
|
||||
let bboxActive = ref(false)
|
||||
let views: Ref<SpeckleView[]> = ref([])
|
||||
|
||||
const isNavbarCollapsed = ref(false)
|
||||
type SectionBoxState = 'inactive' | 'editing' | 'applied'
|
||||
const sectionBoxState = ref<SectionBoxState>('inactive')
|
||||
const sectionBoxEnabled = computed(() => sectionBoxState.value !== 'inactive')
|
||||
const sectionBoxVisible = computed(() => sectionBoxState.value === 'editing')
|
||||
const views: Ref<SpeckleView[]> = ref([])
|
||||
|
||||
const isInteractive = computed(
|
||||
() => visualStore.fieldInputState.rootObjectId && visualStore.fieldInputState.objectIds
|
||||
@@ -160,10 +167,56 @@ const isInteractive = computed(
|
||||
|
||||
const goToSpeckleWebsite = () => visualStore.host.launchUrl('https://speckle.systems')
|
||||
|
||||
function disableSectionBox() {
|
||||
sectionBoxState.value = 'inactive'
|
||||
viewerHandler.toggleSectionBox(false)
|
||||
visualStore.writeSectionBoxToFile(null)
|
||||
visualStore.setSectionBoxData(null)
|
||||
}
|
||||
|
||||
function onSectionBoxToggle() {
|
||||
switch (sectionBoxState.value) {
|
||||
case 'inactive':
|
||||
sectionBoxState.value = 'editing'
|
||||
viewerHandler.toggleSectionBox(true)
|
||||
break
|
||||
case 'editing':
|
||||
onSectionBoxDone()
|
||||
break
|
||||
case 'applied':
|
||||
sectionBoxState.value = 'editing'
|
||||
viewerHandler.setSectionBoxVisible(true)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function onSectionBoxReset() {
|
||||
disableSectionBox()
|
||||
}
|
||||
|
||||
function onSectionBoxDone() {
|
||||
sectionBoxState.value = 'applied'
|
||||
viewerHandler.setSectionBoxVisible(false)
|
||||
const boxData = viewerHandler.getSectionBoxData()
|
||||
visualStore.setSectionBoxData(boxData)
|
||||
visualStore.writeSectionBoxToFile(boxData)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Sync section box UI state when restored from file
|
||||
viewerHandler.emitter.on('objectsLoaded', () => {
|
||||
if (visualStore.sectionBoxData) {
|
||||
sectionBoxState.value = 'applied'
|
||||
}
|
||||
})
|
||||
|
||||
visualStore.setViewerEmitter(viewerHandler.emit)
|
||||
})
|
||||
|
||||
@@ -171,43 +224,59 @@ onBeforeUnmount(async () => {
|
||||
await viewerHandler.dispose()
|
||||
})
|
||||
|
||||
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) {
|
||||
async function handleObjectClicked(hit: any, isMultiSelect: boolean, mouseEvent?: PointerEvent) {
|
||||
// Skip if dragging occurred
|
||||
if (dragged.value) return
|
||||
|
||||
const intersectResult = await viewerHandler.intersect({ x: ev.clientX, y: ev.clientY })
|
||||
|
||||
const multi = isMultiSelect(ev)
|
||||
const hit = intersectResult?.hit
|
||||
|
||||
console.log('🎯 Object clicked in ViewerWrapper:', hit, isMultiSelect)
|
||||
|
||||
if (hit) {
|
||||
visualStore.setPostClickSkipNeeded(true)
|
||||
const id = hit.object.id as string
|
||||
if (multi || !selectionHandler.isSelected(id)) {
|
||||
await selectionHandler.select(id, multi)
|
||||
if (isMultiSelect || !selectionHandler.isSelected(id)) {
|
||||
await selectionHandler.select(id, isMultiSelect)
|
||||
}
|
||||
tooltipHandler.show(hit, { x: ev.clientX, y: ev.clientY })
|
||||
|
||||
// Show tooltip if we have mouse coordinates
|
||||
if (mouseEvent) {
|
||||
tooltipHandler.show(hit, { x: mouseEvent.clientX, y: mouseEvent.clientY })
|
||||
}
|
||||
|
||||
const selection = selectionHandler.getCurrentSelection()
|
||||
const ids = selection.map((s) => s.id)
|
||||
await viewerHandler.selectObjects(ids)
|
||||
} else {
|
||||
visualStore.setPostClickSkipNeeded(false)
|
||||
tooltipHandler.hide()
|
||||
if (!multi) {
|
||||
if (!isMultiSelect) {
|
||||
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
|
||||
const intersectResult = await viewerHandler.intersect({ x: ev.clientX, y: ev.clientY })
|
||||
await selectionHandler.showContextMenu(ev, intersectResult?.hit)
|
||||
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)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="w-full flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label
|
||||
:for="name"
|
||||
class="block text-body-2xs text-foreground-2"
|
||||
>
|
||||
{{ label || name }}
|
||||
</label>
|
||||
<span class="text-body-2xs text-foreground-2">{{ displayValue }}</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
:id="name"
|
||||
:name="name"
|
||||
type="range"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:value="currentValue"
|
||||
:disabled="disabled"
|
||||
class="w-full h-1.5 outline-none slider"
|
||||
:class="{
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed': disabled
|
||||
}"
|
||||
:aria-label="label"
|
||||
:aria-valuemin="min"
|
||||
:aria-valuemax="max"
|
||||
:aria-valuenow="currentValue"
|
||||
@input="handleInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
name: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
modelValue?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const currentValue = ref(props.modelValue ?? props.min)
|
||||
|
||||
// Watch for external changes to modelValue
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
if (newVal !== undefined && newVal !== currentValue.value) {
|
||||
currentValue.value = newVal
|
||||
}
|
||||
})
|
||||
|
||||
const displayValue = computed(() => {
|
||||
// Round to avoid floating point issues
|
||||
return Math.round(currentValue.value * 10) / 10
|
||||
})
|
||||
|
||||
const clampValue = (value: number): number => {
|
||||
return Math.max(props.min, Math.min(props.max, value))
|
||||
}
|
||||
|
||||
const handleInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const value = Number(target.value)
|
||||
const clampedValue = clampValue(value)
|
||||
currentValue.value = clampedValue
|
||||
emit('update:modelValue', clampedValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-runnable-track {
|
||||
@apply h-1.5 rounded-full bg-outline-3;
|
||||
}
|
||||
|
||||
.slider::-moz-range-track {
|
||||
@apply h-1.5 rounded-full bg-outline-3;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
@apply h-2.5 w-2.5 rounded-full cursor-pointer bg-foreground-2;
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
@apply h-2.5 w-2.5 rounded-full cursor-pointer border-0 bg-foreground-2;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
:id="name"
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
:disabled="disabled"
|
||||
class="relative inline-flex flex-shrink-0 h-[18px] w-[30px] rounded-full transition-colors ease-in-out duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-primary cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
|
||||
:class="modelValue ? 'bg-primary' : 'bg-foreground-3'"
|
||||
@click="toggle"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block h-3 w-3 rounded-full mt-[3px] ml-[3px] ring-0 transition ease-in-out duration-200 bg-foreground-on-primary"
|
||||
:class="modelValue ? 'translate-x-[12px]' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
<label v-if="showLabel" :for="name" class="block label-light">
|
||||
<span>{{ label || name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: boolean
|
||||
showLabel?: boolean
|
||||
name: string
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
showLabel: true,
|
||||
modelValue: false
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const toggle = () => {
|
||||
if (!props.disabled) {
|
||||
emit('update:modelValue', !props.modelValue)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,38 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M18.5 8.79167L12 12.5833M18.5 8.79167V15.2917L12 19.0833M18.5 8.79167L12 5L5.5 8.79167M12 12.5833L5.5 8.79167M12 12.5833V19.0833M12 19.0833L5.5 15.2917V8.79167"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M5.5 15.2917L1.5 17.6251"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M18.5 15.2957L22.5 17.629"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 5V1"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<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>
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<ViewerMenu v-model:open="open">
|
||||
<template #trigger-icon>
|
||||
<VideoCameraIcon class="w-5 h-5" />
|
||||
</template>
|
||||
<template #title>Camera</template>
|
||||
<div class="flex flex-col p-1.5 min-w-[180px] space-y-0.5">
|
||||
<ViewerMenuItem
|
||||
label="Orthographic projection"
|
||||
:active="visualStore.isOrthoProjection"
|
||||
@click="toggleProjection"
|
||||
/>
|
||||
<ViewerMenuItem
|
||||
label="Move camera on filter"
|
||||
:active="visualStore.isZoomOnFilterActive"
|
||||
@click="toggleZoomOnFilter"
|
||||
/>
|
||||
<ViewerMenuItem
|
||||
label="Ghost filtered objects"
|
||||
:active="visualStore.isGhostActive"
|
||||
@click="toggleGhostHidden"
|
||||
/>
|
||||
|
||||
<div class="w-full border-b border-outline-2 my-1"></div>
|
||||
|
||||
<div class="text-body-2xs font-semibold text-foreground-2 px-2 py-1">Views</div>
|
||||
|
||||
<ViewerMenuItem
|
||||
v-for="shortcut in viewShortcuts"
|
||||
:key="shortcut.name"
|
||||
:label="shortcut.name"
|
||||
hide-active-tick
|
||||
:active="false"
|
||||
@click="handleViewChange(shortcut.name.toLowerCase() as CanonicalView)"
|
||||
/>
|
||||
|
||||
<div v-if="views.length !== 0" class="w-full border-b border-outline-2 my-1"></div>
|
||||
|
||||
<ViewerMenuItem
|
||||
v-for="view in views"
|
||||
:key="view.id"
|
||||
hide-active-tick
|
||||
:active="false"
|
||||
:label="view.name ? view.name : view.id"
|
||||
@click="handleViewChange(view)"
|
||||
/>
|
||||
</div>
|
||||
</ViewerMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { VideoCameraIcon } from '@heroicons/vue/24/outline'
|
||||
import type { CanonicalView, SpeckleView } from '@speckle/viewer'
|
||||
import { useVisualStore } from '@src/store/visualStore'
|
||||
import ViewerMenu from '../menu/ViewerMenu.vue'
|
||||
import ViewerMenuItem from '../menu/ViewerMenuItem.vue'
|
||||
import { ViewShortcuts } from '@src/helpers/viewer/shortcuts/shortcuts'
|
||||
|
||||
const visualStore = useVisualStore()
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
views: SpeckleView[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void
|
||||
(e: 'view-clicked', value: CanonicalView | SpeckleView): void
|
||||
}>()
|
||||
|
||||
const open = computed({
|
||||
get: () => props.open,
|
||||
set: (val) => emit('update:open', val)
|
||||
})
|
||||
|
||||
const viewShortcuts = Object.values(ViewShortcuts)
|
||||
|
||||
const handleViewChange = (v: CanonicalView | SpeckleView) => {
|
||||
emit('view-clicked', v)
|
||||
}
|
||||
|
||||
const toggleProjection = () => {
|
||||
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()
|
||||
}
|
||||
</script>
|
||||
@@ -1,85 +1,204 @@
|
||||
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
|
||||
<template>
|
||||
<ViewerMenu v-model:open="open" title="View modes">
|
||||
<template #trigger-icon>
|
||||
<ViewModes class="h-5 w-5" />
|
||||
</template>
|
||||
<template #title>View modes</template>
|
||||
<div
|
||||
class="p-1.5"
|
||||
@mouseenter="cancelCloseTimer"
|
||||
@mouseleave="isManuallyOpened ? undefined : startCloseTimer"
|
||||
@focusin="cancelCloseTimer"
|
||||
@focusout="isManuallyOpened ? undefined : startCloseTimer"
|
||||
>
|
||||
<div v-for="(label, mode) in viewModes" :key="mode">
|
||||
<ViewerMenuItem
|
||||
:label="label"
|
||||
:active="mode.toString() === visualStore.defaultViewModeInFile"
|
||||
@click="handleViewModeChange(Number(mode))"
|
||||
<div class="absolute left-10 sm:left-[46px] -top-0 bg-foundation rounded-md border border-outline-2 shadow min-w-[180px] z-30">
|
||||
<!-- Header -->
|
||||
<div class="px-2 py-1.5 border-b border-outline-2">
|
||||
<span class="text-body-2xs font-medium text-foreground">View modes</span>
|
||||
</div>
|
||||
|
||||
<!-- View Mode List -->
|
||||
<div class="py-0.5">
|
||||
<button
|
||||
v-for="item in viewModes"
|
||||
:key="item.mode"
|
||||
class="w-full px-2 py-1 flex items-center hover:bg-highlight-1 text-left"
|
||||
@click="handleViewModeChange(item.mode)"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<CheckIcon
|
||||
v-if="isActiveMode(item.mode)"
|
||||
class="w-3.5 h-3.5 text-foreground"
|
||||
/>
|
||||
<span v-else class="w-3.5 h-3.5" />
|
||||
<span class="text-body-2xs" :class="isActiveMode(item.mode) ? 'text-foreground font-medium' : 'text-foreground-2'">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Edges Section -->
|
||||
<div class="border-t border-outline-2 px-2 py-1.5 space-y-2">
|
||||
<!-- Edges Toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-body-2xs text-foreground">Edges</span>
|
||||
<FormSwitch
|
||||
v-model="edgesEnabledLocal"
|
||||
:show-label="false"
|
||||
name="toggle-edges"
|
||||
:disabled="currentViewMode === ViewMode.PEN"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Weight Slider (only show when edges enabled) -->
|
||||
<div v-if="edgesEnabledLocal" class="py-1">
|
||||
<FormRange
|
||||
v-model="edgesWeightLocal"
|
||||
name="edge-weight"
|
||||
label="Weight"
|
||||
:min="0.5"
|
||||
:max="3"
|
||||
:step="0.1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Color Selector (only show when edges enabled) -->
|
||||
<div v-if="edgesEnabledLocal" class="flex items-center justify-between">
|
||||
<span class="text-body-2xs text-foreground-2">Color</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-for="(color, index) in edgesColorOptions"
|
||||
:key="color === 'auto' ? 'auto' : color"
|
||||
class="flex items-center justify-center size-4 rounded-full"
|
||||
:class="edgesColorLocal === color && 'ring-2 ring-primary ring-offset-1'"
|
||||
@click="handleEdgesColorChange(color)"
|
||||
>
|
||||
<span
|
||||
class="size-3 rounded-full cursor-pointer"
|
||||
:style="{
|
||||
background:
|
||||
index === 0
|
||||
? 'linear-gradient(135deg, #1a1a1a 50%, #ffffff 50%)'
|
||||
: `#${(color as number).toString(16).padStart(6, '0')}`
|
||||
}"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ViewerMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { ViewMode } from '@speckle/viewer'
|
||||
import ViewerMenu from '../menu/ViewerMenu.vue'
|
||||
import ViewerMenuItem from '../menu/ViewerMenuItem.vue'
|
||||
import { onUnmounted, ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||
import { useVisualStore } from '@src/store/visualStore'
|
||||
import ViewModes from '../../global/icon/ViewModes.vue'
|
||||
import FormSwitch from '../../form/FormSwitch.vue'
|
||||
import FormRange from '../../form/FormRange.vue'
|
||||
import { CheckIcon } from '@heroicons/vue/24/solid'
|
||||
import type { ViewModeOptions } from '@src/plugins/viewer'
|
||||
|
||||
const viewModes = {
|
||||
[ViewMode.DEFAULT]: 'Default',
|
||||
[ViewMode.DEFAULT_EDGES]: 'Edges',
|
||||
[ViewMode.SHADED]: 'Shaded',
|
||||
[ViewMode.PEN]: 'Pen',
|
||||
[ViewMode.ARCTIC]: 'Arctic',
|
||||
[ViewMode.COLORS]: 'Colors'
|
||||
}
|
||||
// Array to maintain proper display order (matching Speckle frontend)
|
||||
const viewModes = [
|
||||
{ mode: ViewMode.DEFAULT, label: 'Rendered' },
|
||||
{ mode: ViewMode.SHADED, label: 'Shaded' },
|
||||
{ mode: ViewMode.ARCTIC, label: 'Arctic' },
|
||||
{ mode: ViewMode.SOLID, label: 'Solid' },
|
||||
{ mode: ViewMode.PEN, label: 'Pen' }
|
||||
]
|
||||
|
||||
const edgesColorOptions = [
|
||||
'auto' as const,
|
||||
0x3b82f6, // blue-500
|
||||
0x8b5cf6, // violet-500
|
||||
0x65a30d, // lime-600
|
||||
0xf97316, // orange-500
|
||||
0xf43f5e // rose-500
|
||||
]
|
||||
|
||||
const visualStore = useVisualStore()
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
}>()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void
|
||||
(e: 'force-close-others'): void
|
||||
(e: 'view-mode-clicked', value: ViewMode): void
|
||||
(e: 'view-mode-clicked', value: ViewMode, options: ViewModeOptions): void
|
||||
}>()
|
||||
|
||||
// Computed v-model
|
||||
const open = computed({
|
||||
get: () => props.open,
|
||||
set: (val) => emit('update:open', val)
|
||||
// Initialization flag
|
||||
const isInitialized = ref(false)
|
||||
|
||||
// Local state synced with store (with safe defaults)
|
||||
const edgesEnabledLocal = ref(visualStore.edgesEnabled ?? true)
|
||||
const edgesWeightLocal = ref(visualStore.edgesWeight ?? 1)
|
||||
const edgesColorLocal = ref<number | 'auto'>(visualStore.edgesColor ?? 'auto')
|
||||
|
||||
// Mark as initialized after next tick to prevent watchers firing on mount
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
isInitialized.value = true
|
||||
})
|
||||
})
|
||||
|
||||
// State
|
||||
const isManuallyOpened = ref(false)
|
||||
// Current view mode from store
|
||||
const currentViewMode = computed(() => {
|
||||
return visualStore.defaultViewModeInFile
|
||||
? Number(visualStore.defaultViewModeInFile) as ViewMode
|
||||
: ViewMode.DEFAULT
|
||||
})
|
||||
|
||||
const { start: startCloseTimer, stop: cancelCloseTimer } = useTimeoutFn(
|
||||
() => {
|
||||
open.value = false
|
||||
},
|
||||
3000,
|
||||
{ immediate: false }
|
||||
)
|
||||
const isActiveMode = (mode: ViewMode) => mode === currentViewMode.value
|
||||
|
||||
const handleViewModeChange = (mode: ViewMode) => {
|
||||
open.value = false
|
||||
visualStore.setDefaultViewModeInFile(mode.toString())
|
||||
visualStore.writeViewModeToFile(mode)
|
||||
emit('view-mode-clicked', mode)
|
||||
// Compute the actual edge color to use (auto resolves to dark)
|
||||
const finalEdgesColor = computed(() => {
|
||||
if (edgesColorLocal.value === 'auto') {
|
||||
return 0x1a1a1a // dark edges by default
|
||||
}
|
||||
return edgesColorLocal.value
|
||||
})
|
||||
|
||||
// Build view mode options
|
||||
const buildViewModeOptions = (mode: ViewMode): ViewModeOptions => {
|
||||
// PEN mode always has edges enabled and opacity 1
|
||||
const isPenMode = mode === ViewMode.PEN
|
||||
return {
|
||||
edges: isPenMode ? true : edgesEnabledLocal.value,
|
||||
outlineThickness: edgesWeightLocal.value,
|
||||
outlineOpacity: isPenMode ? 1 : 0.75,
|
||||
outlineColor: finalEdgesColor.value
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelCloseTimer()
|
||||
const handleViewModeChange = (mode: ViewMode) => {
|
||||
const options = buildViewModeOptions(mode)
|
||||
visualStore.setDefaultViewModeInFile(mode.toString())
|
||||
visualStore.writeViewModeToFile(mode)
|
||||
emit('view-mode-clicked', mode, options)
|
||||
}
|
||||
|
||||
const handleEdgesColorChange = (color: number | 'auto') => {
|
||||
edgesColorLocal.value = color
|
||||
}
|
||||
|
||||
// Apply edges changes to viewer when settings change
|
||||
const applyEdgesSettings = () => {
|
||||
// Don't apply during initialization
|
||||
if (!isInitialized.value) return
|
||||
|
||||
// Update store
|
||||
visualStore.setEdgesEnabled(edgesEnabledLocal.value)
|
||||
visualStore.setEdgesWeight(edgesWeightLocal.value)
|
||||
visualStore.setEdgesColor(edgesColorLocal.value)
|
||||
visualStore.writeEdgesSettingsToFile()
|
||||
|
||||
// Re-apply current view mode with new options
|
||||
const options = buildViewModeOptions(currentViewMode.value)
|
||||
emit('view-mode-clicked', currentViewMode.value, options)
|
||||
}
|
||||
|
||||
// Watch for edges settings changes and apply them
|
||||
watch([edgesEnabledLocal, edgesWeightLocal, edgesColorLocal], () => {
|
||||
applyEdgesSettings()
|
||||
})
|
||||
|
||||
// Sync local state with store when store changes (e.g., from file load)
|
||||
watch(() => visualStore.edgesEnabled, (val) => {
|
||||
edgesEnabledLocal.value = val
|
||||
})
|
||||
|
||||
watch(() => visualStore.edgesWeight, (val) => {
|
||||
edgesWeightLocal.value = val
|
||||
})
|
||||
|
||||
watch(() => visualStore.edgesColor, (val) => {
|
||||
edgesColorLocal.value = val
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
|
||||
<template>
|
||||
<ViewerMenu v-model:open="open" title="Views">
|
||||
<template #trigger-icon>
|
||||
<Views class="w-5 h-5" />
|
||||
</template>
|
||||
<template #title>Views</template>
|
||||
<div
|
||||
class="max-h-64 simple-scrollbar overflow-y-auto flex flex-col p-1.5"
|
||||
@mouseenter="cancelCloseTimer"
|
||||
@mouseleave="isManuallyOpened ? undefined : startCloseTimer"
|
||||
@focusin="cancelCloseTimer"
|
||||
@focusout="isManuallyOpened ? undefined : startCloseTimer"
|
||||
>
|
||||
<div v-for="shortcut in viewShortcuts" :key="shortcut.name">
|
||||
<ViewerMenuItem
|
||||
:label="shortcut.name"
|
||||
hide-active-tick
|
||||
:active="activeView === shortcut.name.toLowerCase()"
|
||||
@click="handleViewChange(shortcut.name.toLowerCase() as CanonicalView)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="views.length !== 0" class="w-full border-b my-1"></div>
|
||||
|
||||
<ViewerMenuItem
|
||||
v-for="view in views"
|
||||
:key="view.id"
|
||||
hide-active-tick
|
||||
:active="activeView === view.id"
|
||||
:label="view.name ? view.name : view.id"
|
||||
@click="handleViewChange(view)"
|
||||
/>
|
||||
</div>
|
||||
</ViewerMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import type { CanonicalView, SpeckleView } from '@speckle/viewer'
|
||||
import { onUnmounted, ref, computed } from 'vue'
|
||||
import ViewerMenu from '../menu/ViewerMenu.vue'
|
||||
import ViewerMenuItem from '../menu/ViewerMenuItem.vue'
|
||||
import Views from '../../global/icon/Views.vue'
|
||||
import { ViewShortcuts } from '../../../helpers/viewer/shortcuts/shortcuts'
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
views: SpeckleView[]
|
||||
open: boolean
|
||||
}>()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void
|
||||
(e: 'force-close-others'): void
|
||||
(e: 'view-clicked', value: CanonicalView | SpeckleView)
|
||||
}>()
|
||||
|
||||
// Computed open for v-model
|
||||
const open = computed({
|
||||
get: () => props.open,
|
||||
set: (val) => emit('update:open', val)
|
||||
})
|
||||
|
||||
// State
|
||||
const isManuallyOpened = ref(false)
|
||||
const activeView = ref<string | null>(null)
|
||||
|
||||
const { start: startCloseTimer, stop: cancelCloseTimer } = useTimeoutFn(
|
||||
() => {
|
||||
open.value = false
|
||||
},
|
||||
3000,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
const handleViewChange = (v: CanonicalView | SpeckleView) => {
|
||||
open.value = false
|
||||
emit('view-clicked', v)
|
||||
}
|
||||
|
||||
const viewShortcuts = Object.values(ViewShortcuts)
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelCloseTimer()
|
||||
})
|
||||
</script>
|
||||
@@ -41,8 +41,12 @@ export function useUpdateConnector() {
|
||||
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 }
|
||||
|
||||
// Filter out prerelease versions
|
||||
const stableVersions = sortedVersions.filter((v) => !v.Prerelease)
|
||||
const latestVersion = stableVersions[0]
|
||||
const sanitizedVersion = sanitizeVersion(latestVersion.Number)
|
||||
latestAvailableVersion.value = { ...latestVersion, Number: sanitizedVersion }
|
||||
visualStore.setLatestAvailableVersion(latestAvailableVersion.value)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
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,31 +1,116 @@
|
||||
import ObjectLoader from '@speckle/objectloader'
|
||||
import { ObjectLoader2Factory } from '@speckle/objectloader2'
|
||||
import { SpeckleLoader, WorldTree } from '@speckle/viewer'
|
||||
|
||||
// Base type from objectloader2 (has id, speckle_type properties)
|
||||
interface Base {
|
||||
id: string
|
||||
speckle_type: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export class SpeckleObjectsOfflineLoader extends SpeckleLoader {
|
||||
constructor(targetTree: WorldTree, resourceData: string, resourceId?: string) {
|
||||
super(targetTree, resourceId || '', undefined, undefined, resourceData)
|
||||
constructor(targetTree: WorldTree, resourceData: unknown, resourceId?: string) {
|
||||
// Resource ID is not used for offline loading since we have objects in memory
|
||||
// Pass empty string to avoid URL parsing issues
|
||||
super(targetTree, '', undefined, undefined, resourceData)
|
||||
}
|
||||
|
||||
protected initObjectLoader(
|
||||
_resource: string,
|
||||
_authToken?: string,
|
||||
_enableCaching?: boolean,
|
||||
resourceData?: string | ArrayBuffer
|
||||
): ObjectLoader {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return ObjectLoader.createFromObjects(resourceData as unknown as [])
|
||||
resource: string,
|
||||
authToken?: string,
|
||||
enableCaching?: boolean,
|
||||
resourceData?: unknown
|
||||
): ReturnType<SpeckleLoader['initObjectLoader']> {
|
||||
// Use ObjectLoader2Factory.createFromObjects for offline/memory-based loading
|
||||
// The objects array must contain ALL objects (root + all children)
|
||||
// The first object in the array must be the root object
|
||||
const objects = (resourceData ?? this._resourceData) as Base[]
|
||||
|
||||
if (!objects || objects.length === 0) {
|
||||
throw new Error('SpeckleObjectsOfflineLoader: No objects provided')
|
||||
}
|
||||
|
||||
// Ensure all objects have an 'id' property
|
||||
const missingIds = objects.filter((obj) => !obj.id)
|
||||
if (missingIds.length > 0) {
|
||||
console.error('Objects missing id property:', missingIds.slice(0, 5))
|
||||
throw new Error(
|
||||
`SpeckleObjectsOfflineLoader: ${missingIds.length} objects are missing 'id' property`
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`Creating offline loader with ${objects.length} objects, root: ${objects[0].id}`)
|
||||
|
||||
// Create a Set of all object IDs for quick lookup
|
||||
const objectIds = new Set(objects.map((obj) => obj.id))
|
||||
|
||||
// Check for references to objects that aren't in the array
|
||||
const missingReferences = new Set<string>()
|
||||
objects.forEach((obj) => {
|
||||
// Check all properties for references (objects that look like { referencedId: "xxx" })
|
||||
Object.values(obj).forEach((value) => {
|
||||
if (value && typeof value === 'object') {
|
||||
if ('referencedId' in value && typeof value.referencedId === 'string') {
|
||||
if (!objectIds.has(value.referencedId)) {
|
||||
missingReferences.add(value.referencedId)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check arrays for references
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => {
|
||||
if (item && typeof item === 'object' && 'referencedId' in item) {
|
||||
if (!objectIds.has(item.referencedId)) {
|
||||
missingReferences.add(item.referencedId)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (missingReferences.size > 0) {
|
||||
console.warn(
|
||||
`⚠️ Found ${missingReferences.size} missing object references:`,
|
||||
Array.from(missingReferences).slice(0, 10)
|
||||
)
|
||||
} else {
|
||||
console.log('✅ All object references are present')
|
||||
}
|
||||
|
||||
// @ts-ignore - Type compatibility issue between local objectloader2 and viewer's objectloader2
|
||||
return ObjectLoader2Factory.createFromObjects(objects)
|
||||
}
|
||||
|
||||
public async load(): Promise<boolean> {
|
||||
const rootObject = await this.loader.getRootObject()
|
||||
if (!rootObject && this._resource) {
|
||||
console.error('No root id set!')
|
||||
if (!rootObject) {
|
||||
console.error('No root object found!')
|
||||
return false
|
||||
}
|
||||
/** If not id is provided, we make one up based on the root object id */
|
||||
this._resource = this._resource || `/json/${rootObject.id as string}`
|
||||
|
||||
/** Set resource to a fake URL for logging purposes only */
|
||||
this._resource = this._resource || `/json/${rootObject.baseId as string}`
|
||||
|
||||
console.log('Loading objects from memory (offline mode)')
|
||||
|
||||
// Call parent load() which will use our ObjectLoader2 to iterate through objects
|
||||
// Since we're using MemoryDownloader, it won't actually download anything
|
||||
return super.load()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up the ObjectLoader2 resources
|
||||
*/
|
||||
public async dispose(): Promise<void> {
|
||||
try {
|
||||
if (this.loader && 'disposeAsync' in this.loader) {
|
||||
// @ts-ignore - disposeAsync exists on ObjectLoader2
|
||||
await this.loader.disposeAsync()
|
||||
console.log('SpeckleObjectsOfflineLoader: ObjectLoader2 disposed')
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error disposing ObjectLoader2 in offline loader:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useVisualStore } from '@src/store/visualStore'
|
||||
import { ObjectLoader2Factory } from '@speckle/objectloader2'
|
||||
|
||||
interface SpeckleObject {
|
||||
id: string
|
||||
speckle_type?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export class SpeckleApiLoader {
|
||||
private serverUrl: string
|
||||
private token: string
|
||||
private projectId: string
|
||||
private headers: Record<string, string>
|
||||
|
||||
constructor(serverUrl: string, projectId: string, token: string) {
|
||||
this.serverUrl = serverUrl.replace(/\/$/, '')
|
||||
this.projectId = projectId
|
||||
this.token = token
|
||||
this.headers = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
async downloadObjectsWithChildren(
|
||||
objectId: string,
|
||||
onProgress?: (loaded: number, total: number) => void
|
||||
): Promise<SpeckleObject[]> {
|
||||
const visualStore = useVisualStore()
|
||||
|
||||
visualStore.setLoadingProgress('Initializing object loader', 0)
|
||||
console.log('Creating ObjectLoader v2 for Power BI environment')
|
||||
|
||||
const loader = ObjectLoader2Factory.createFromUrl({
|
||||
serverUrl: this.serverUrl,
|
||||
streamId: this.projectId,
|
||||
objectId,
|
||||
token: this.token,
|
||||
attributeMask: { exclude: ['properties', 'encodedValue'] },
|
||||
options: { useCache: false }
|
||||
})
|
||||
|
||||
try {
|
||||
// Get total count for progress tracking
|
||||
const totalCount = await loader.getTotalObjectCount()
|
||||
console.log(`Loading ${totalCount} objects using ObjectLoader v2`)
|
||||
|
||||
const objects: SpeckleObject[] = []
|
||||
let loadedCount = 0
|
||||
|
||||
// Stream all objects using the async iterator
|
||||
for await (const obj of loader.getObjectIterator()) {
|
||||
objects.push(obj as SpeckleObject) // Type assertion for SpeckleObject interface
|
||||
loadedCount++
|
||||
|
||||
// Update progress
|
||||
if (onProgress) {
|
||||
onProgress(loadedCount, totalCount)
|
||||
}
|
||||
|
||||
const progress = totalCount > 0 ? loadedCount / totalCount : 0
|
||||
visualStore.setLoadingProgress('🌍 Loading from Speckle', progress)
|
||||
|
||||
// Log progress every 100 objects
|
||||
if (loadedCount % 100 === 0) {
|
||||
console.log(`Loaded ${loadedCount}/${totalCount} objects`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Downloaded ${objects.length} objects using ObjectLoader v2`)
|
||||
|
||||
visualStore.setLoadingProgress('🔄 Finalizing object download...', 0.9)
|
||||
|
||||
// Recursively fetch all missing references until none remain
|
||||
let iterationCount = 0
|
||||
let totalFetched = 0
|
||||
|
||||
while (iterationCount < 10) {
|
||||
// Safety limit: loop exits early when missingIds.size === 0 (line 108)
|
||||
// This limit only prevents infinite loops if something goes wrong
|
||||
iterationCount++
|
||||
|
||||
const objectIds = new Set(objects.map((obj) => obj.id))
|
||||
const missingIds = new Set<string>()
|
||||
|
||||
// Check all objects for missing references
|
||||
objects.forEach((obj) => {
|
||||
Object.values(obj).forEach((value) => {
|
||||
if (value && typeof value === 'object') {
|
||||
if ('referencedId' in value && typeof value.referencedId === 'string') {
|
||||
if (!objectIds.has(value.referencedId)) {
|
||||
missingIds.add(value.referencedId)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => {
|
||||
if (item && typeof item === 'object' && 'referencedId' in item) {
|
||||
if (!objectIds.has(item.referencedId)) {
|
||||
missingIds.add(item.referencedId)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (missingIds.size === 0) {
|
||||
console.log(
|
||||
`✅ No more missing references. Complete after ${iterationCount} iteration(s)`
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Iteration ${iterationCount}: Fetching ${missingIds.size} missing referenced objects...`
|
||||
)
|
||||
|
||||
visualStore.setLoadingProgress(`🔄 Loading additional objects)`, 0.9)
|
||||
|
||||
// Fetch missing objects with progress tracking
|
||||
const missingIdsArray = Array.from(missingIds)
|
||||
let fetchedInIteration = 0
|
||||
|
||||
for (const missingId of missingIdsArray) {
|
||||
try {
|
||||
const missingObj = await loader.getObject({ id: missingId })
|
||||
objects.push(missingObj as SpeckleObject)
|
||||
totalFetched++
|
||||
fetchedInIteration++
|
||||
|
||||
// Update progress within this iteration
|
||||
const iterationProgress = fetchedInIteration / missingIdsArray.length
|
||||
visualStore.setLoadingProgress(
|
||||
`🔄 Loading objects (${objects.length} loaded)`,
|
||||
0.9 + iterationProgress * 0.05 // Progress from 0.9 to 0.95
|
||||
)
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ Could not fetch missing object ${missingId}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ Iteration ${iterationCount} complete. Fetched ${missingIdsArray.length} objects. Total: ${objects.length}`
|
||||
)
|
||||
}
|
||||
|
||||
if (iterationCount >= 10) {
|
||||
console.warn(
|
||||
'⚠️ Reached maximum iterations for fetching references. Some objects may still be missing.'
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ Downloaded total of ${objects.length} objects (${totalFetched} additional references fetched)`
|
||||
)
|
||||
|
||||
visualStore.setLoadingProgress('Download complete', 1)
|
||||
|
||||
return objects
|
||||
} catch (error) {
|
||||
console.error('Error loading objects:', error)
|
||||
throw error
|
||||
} finally {
|
||||
// Clean up the loader resources
|
||||
try {
|
||||
await loader.disposeAsync()
|
||||
console.log('ObjectLoader2 disposed successfully')
|
||||
} catch (disposeError) {
|
||||
console.warn('Error disposing ObjectLoader2:', disposeError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async downloadFromVersionId(versionId: string): Promise<SpeckleObject[]> {
|
||||
// For version IDs, we can't avoid GraphQL entirely as we need to resolve the referenced object
|
||||
// However, this method might not be used if we're getting object IDs directly from the data connector
|
||||
throw new Error('Version ID downloads not supported with weak tokens. Use object IDs directly.')
|
||||
}
|
||||
|
||||
async downloadMultipleModels(objectIds: string[]): Promise<SpeckleObject[][]> {
|
||||
const allObjects: SpeckleObject[][] = []
|
||||
|
||||
for (const objectId of objectIds) {
|
||||
const objects = await this.downloadObjectsWithChildren(objectId)
|
||||
allObjects.push(objects)
|
||||
}
|
||||
|
||||
return allObjects
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import {
|
||||
DefaultViewerParams,
|
||||
FilteringState,
|
||||
IntersectionQuery,
|
||||
CameraController,
|
||||
CanonicalView,
|
||||
SectionTool,
|
||||
SectionOutlines,
|
||||
ViewModes,
|
||||
CameraEvent,
|
||||
SpeckleView,
|
||||
@@ -13,13 +14,15 @@ import {
|
||||
SelectionExtension,
|
||||
FilteringExtension,
|
||||
UpdateFlags,
|
||||
ViewerEvent
|
||||
ViewerEvent,
|
||||
SelectionEvent
|
||||
} 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'
|
||||
import { createNanoEvents, Emitter } from 'nanoevents'
|
||||
import { Vector3 } from 'three'
|
||||
import { Box3, Vector3 } from 'three'
|
||||
|
||||
export interface IViewer {
|
||||
/**
|
||||
@@ -34,12 +37,19 @@ export interface Hit {
|
||||
point: { x: number; y: number; z: number }
|
||||
}
|
||||
|
||||
export interface ViewModeOptions {
|
||||
edges?: boolean
|
||||
outlineThickness?: number
|
||||
outlineOpacity?: number
|
||||
outlineColor?: number
|
||||
}
|
||||
|
||||
export interface IViewerEvents {
|
||||
ping: (message: string) => void
|
||||
setSelection: (objectIds: string[]) => void
|
||||
resetFilter: (objectIds: string[], ghost: boolean) => void
|
||||
filterSelection: (objectIds: string[], ghost: boolean) => void
|
||||
setViewMode: (viewMode: ViewMode) => void
|
||||
resetFilter: (objectIds: string[], ghost: boolean, zoom: boolean) => void
|
||||
filterSelection: (objectIds: string[], ghost: boolean, zoom: boolean) => void
|
||||
setViewMode: (viewMode: ViewMode, options?: ViewModeOptions) => void
|
||||
colorObjectsByGroup: (
|
||||
colorById: {
|
||||
objectIds: string[]
|
||||
@@ -51,7 +61,11 @@ export interface IViewerEvents {
|
||||
zoomExtends: () => void
|
||||
toggleProjection: () => void
|
||||
toggleGhostHidden: (ghost: boolean) => void
|
||||
toggleSectionBox: (enabled: boolean) => void
|
||||
setSectionBoxVisible: (visible: boolean) => void
|
||||
loadObjects: (objects: object[]) => void
|
||||
objectsLoaded: () => void
|
||||
objectClicked: (hit: Hit | null, isMultiSelect: boolean, mouseEvent?: PointerEvent) => void
|
||||
}
|
||||
|
||||
export type ColorBy = {
|
||||
@@ -64,7 +78,9 @@ export class ViewerHandler {
|
||||
public viewer: Viewer
|
||||
public cameraControls: CameraController
|
||||
public filtering: FilteringExtension
|
||||
public selection: SelectionExtension
|
||||
public selection: FilteredSelectionExtension
|
||||
public sectionTool: SectionTool
|
||||
public sectionOutlines: SectionOutlines
|
||||
private filteringState: FilteringState
|
||||
|
||||
constructor() {
|
||||
@@ -81,15 +97,20 @@ 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)
|
||||
this.emitter.on('toggleSectionBox', this.toggleSectionBox)
|
||||
this.emitter.on('setSectionBoxVisible', this.setSectionBoxVisible)
|
||||
}
|
||||
|
||||
async init(parent: HTMLElement) {
|
||||
this.viewer = await createViewer(parent)
|
||||
this.cameraControls = this.viewer.getExtension(CameraController)
|
||||
this.filtering = this.viewer.getExtension(FilteringExtension)
|
||||
this.selection = this.viewer.getExtension(SelectionExtension)
|
||||
this.selection = this.viewer.getExtension(FilteredSelectionExtension)
|
||||
this.sectionTool = this.viewer.getExtension(SectionTool)
|
||||
this.sectionOutlines = this.viewer.getExtension(SectionOutlines)
|
||||
|
||||
const store = useVisualStore()
|
||||
if (store.isOrthoProjection) {
|
||||
@@ -99,6 +120,14 @@ export class ViewerHandler {
|
||||
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)
|
||||
}
|
||||
|
||||
emit<E extends keyof IViewerEvents>(event: E, ...payload: Parameters<IViewerEvents[E]>): void {
|
||||
@@ -116,19 +145,70 @@ export class ViewerHandler {
|
||||
}
|
||||
public toggleProjection = () => this.cameraControls.toggleCameras()
|
||||
|
||||
public setView = (view: CanonicalView) => {
|
||||
public setView = (view: CanonicalView | SpeckleView) => {
|
||||
this.cameraControls.setCameraView(view, false)
|
||||
this.snapshotCameraPositionAndStore()
|
||||
}
|
||||
|
||||
public setSectionBox = (bboxActive: boolean, objectIds: string[]) => {
|
||||
// TODO
|
||||
return
|
||||
public toggleSectionBox = (enabled: boolean) => {
|
||||
this.setSectionEnabled(enabled)
|
||||
if (enabled) {
|
||||
const sceneBox = this.viewer.getRenderer().sceneBox
|
||||
this.sectionTool.setBox(sceneBox)
|
||||
this.sectionTool.visible = true
|
||||
}
|
||||
}
|
||||
|
||||
public setViewMode(viewMode: ViewMode) {
|
||||
public setSectionBoxVisible = (visible: boolean) => {
|
||||
this.sectionTool.visible = visible
|
||||
}
|
||||
|
||||
private setSectionEnabled(enabled: boolean): void {
|
||||
this.sectionTool.enabled = enabled
|
||||
this.sectionOutlines.enabled = enabled
|
||||
}
|
||||
|
||||
public getSectionBoxData = (): string | null => {
|
||||
if (!this.sectionTool.enabled) return null
|
||||
const { center, halfSize } = this.sectionTool.getBox()
|
||||
const min = new Vector3().copy(center).sub(halfSize)
|
||||
const max = new Vector3().copy(center).add(halfSize)
|
||||
return JSON.stringify({ min, max })
|
||||
}
|
||||
|
||||
public applySectionBox = (boxData: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(boxData)
|
||||
|
||||
// Validate parsed data structure
|
||||
if (!parsed?.min || !parsed?.max) {
|
||||
throw new Error('Invalid section box data: missing min/max properties')
|
||||
}
|
||||
|
||||
const box = new Box3(
|
||||
new Vector3(parsed.min.x, parsed.min.y, parsed.min.z),
|
||||
new Vector3(parsed.max.x, parsed.max.y, parsed.max.z)
|
||||
)
|
||||
|
||||
this.setSectionEnabled(true)
|
||||
this.sectionTool.setBox(box)
|
||||
this.sectionTool.visible = false
|
||||
this.viewer.requestRender(UpdateFlags.RENDER_RESET)
|
||||
// Force section outlines recomputation after geometry is rendered
|
||||
requestAnimationFrame(() => {
|
||||
this.sectionOutlines.requestUpdate(true)
|
||||
this.viewer.requestRender(UpdateFlags.RENDER_RESET)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to apply section box, disabling feature:', error)
|
||||
this.setSectionEnabled(false)
|
||||
// Visual continues loading normally without section box
|
||||
}
|
||||
}
|
||||
|
||||
public setViewMode(viewMode: ViewMode, options?: ViewModeOptions) {
|
||||
const viewModes = this.viewer.getExtension(ViewModes)
|
||||
viewModes.setViewMode(viewMode)
|
||||
viewModes.setViewMode(viewMode, options)
|
||||
}
|
||||
|
||||
public snapshotCameraPositionAndStore = () => {
|
||||
@@ -147,20 +227,24 @@ export class ViewerHandler {
|
||||
}
|
||||
}
|
||||
|
||||
public filterSelection = (objectIds: string[], ghost: boolean) => {
|
||||
public filterSelection = (objectIds: string[], ghost: boolean, zoom: boolean = true) => {
|
||||
console.log('🔗 Handling filterSelection inside ViewerHandler')
|
||||
if (objectIds) {
|
||||
this.unIsolateObjects()
|
||||
this.filteringState = this.filtering.isolateObjects(objectIds, 'powerbi', true, ghost)
|
||||
this.zoomObjects(objectIds, true)
|
||||
if (zoom) {
|
||||
this.zoomObjects(objectIds, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public resetFilter = (objectIds: string[], ghost: boolean) => {
|
||||
public resetFilter = (objectIds: string[], ghost: boolean, zoom: boolean = true) => {
|
||||
console.log('🔗 Handling filterSelection inside ViewerHandler')
|
||||
if (objectIds) {
|
||||
this.isolateObjects(objectIds, ghost)
|
||||
this.zoomObjects(objectIds, true)
|
||||
if (zoom) {
|
||||
this.zoomObjects(objectIds, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,34 +276,20 @@ 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[][]) => {
|
||||
// disable section box before unloading to prevent stale geometry references.
|
||||
// it will be re-applied from store after new objects are loaded (see applySectionBox below).
|
||||
this.toggleSectionBox(false)
|
||||
await this.viewer.unloadAll()
|
||||
// const stringifiedObject = JSON.stringify(objects)
|
||||
|
||||
const store = useVisualStore()
|
||||
const speckleViews = []
|
||||
|
||||
modelObjects.forEach(async (objects) => {
|
||||
// Use for...of loop to properly handle async operations
|
||||
for (const objects of modelObjects) {
|
||||
//@ts-ignore
|
||||
const loader = new SpeckleObjectsOfflineLoader(this.viewer.getWorldTree(), objects)
|
||||
|
||||
@@ -232,11 +302,27 @@ 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
|
||||
})
|
||||
|
||||
// Clean up loader resources after loading is complete
|
||||
if (loader.dispose) {
|
||||
await loader.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
store.setSpeckleViews(speckleViews)
|
||||
if (store.defaultViewModeInFile) {
|
||||
this.setViewMode(Number(store.defaultViewModeInFile))
|
||||
const viewMode = Number(store.defaultViewModeInFile) as ViewMode
|
||||
// Apply view mode with edges options from store (with safe defaults)
|
||||
const edgesEnabled = store.edgesEnabled ?? true
|
||||
const edgesWeight = store.edgesWeight ?? 1
|
||||
const edgesColor = store.edgesColor ?? 'auto'
|
||||
const options: ViewModeOptions = {
|
||||
edges: edgesEnabled,
|
||||
outlineThickness: edgesWeight,
|
||||
outlineOpacity: viewMode === ViewMode.PEN ? 1 : 0.75,
|
||||
outlineColor: edgesColor === 'auto' ? undefined : edgesColor
|
||||
}
|
||||
this.setViewMode(viewMode, options)
|
||||
}
|
||||
|
||||
Tracker.dataLoaded({
|
||||
@@ -257,38 +343,55 @@ export class ViewerHandler {
|
||||
)
|
||||
this.cameraControls.setCameraView({ position, target }, true)
|
||||
}
|
||||
|
||||
if (store.sectionBoxData) {
|
||||
this.applySectionBox(store.sectionBoxData)
|
||||
}
|
||||
|
||||
// Emit objects loaded event to trigger update
|
||||
this.emit('objectsLoaded')
|
||||
}
|
||||
|
||||
private handlePing = (message: string) => {
|
||||
console.log(message)
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// Emit the objectClicked event for ViewerWrapper to handle
|
||||
this.emit('objectClicked', hit, isMultiSelect, mouseEvent)
|
||||
}
|
||||
|
||||
|
||||
|
||||
public dispose() {
|
||||
this.viewer.getExtension(CameraController).dispose()
|
||||
this.viewer.dispose()
|
||||
@@ -304,11 +407,11 @@ const createViewer = async (parent: HTMLElement): Promise<Viewer> => {
|
||||
await viewer.init()
|
||||
|
||||
viewer.createExtension(HybridCameraController) // camera controller
|
||||
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(FilteringExtension) // filtering - must be created before FilteredSelectionExtension
|
||||
viewer.createExtension(FilteredSelectionExtension) // filtered selection helper - depends on FilteringExtension
|
||||
viewer.createExtension(SectionTool) // section tool
|
||||
viewer.createExtension(SectionOutlines) // section outlines
|
||||
// viewer.createExtension(MeasurementsExtension) // measurements, possibly not needed for now?
|
||||
viewer.createExtension(FilteringExtension) // filtering
|
||||
viewer.createExtension(ViewModes) // view modes
|
||||
|
||||
console.log('🎥 Viewer is created!')
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { formattingSettings as fs } from 'powerbi-visuals-utils-formattingmodel'
|
||||
|
||||
export class DataLoadingSettings extends fs.SimpleCard {
|
||||
name = 'dataLoading'
|
||||
displayName = 'Data Management'
|
||||
|
||||
public internalizeData = new fs.ToggleSwitch({
|
||||
name: 'internalizeData',
|
||||
displayName: 'Internalize Data',
|
||||
description: 'When enabled, objects are downloaded and stored in the Power BI file for offline access. When disabled, objects are loaded directly from Speckle servers (online mode).',
|
||||
value: false
|
||||
})
|
||||
|
||||
slices: fs.Slice[] = [this.internalizeData]
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
import { formattingSettings as fs } from 'powerbi-visuals-utils-formattingmodel'
|
||||
import { ColorSelectorSettings, ColorSettings } from 'src/settings/colorSettings'
|
||||
import { ColorSettings } from 'src/settings/colorSettings'
|
||||
import { CameraSettings } from 'src/settings/cameraSettings'
|
||||
import { LightingSettings } from 'src/settings/lightingSettings'
|
||||
import { DataLoadingSettings } from 'src/settings/dataLoadingSettings'
|
||||
|
||||
export class SpeckleVisualSettingsModel extends fs.Model {
|
||||
// Building my visual formatting settings card
|
||||
public color: ColorSettings = new ColorSettings()
|
||||
|
||||
public colorSelector: ColorSelectorSettings = new ColorSelectorSettings()
|
||||
public dataLoading: DataLoadingSettings = new DataLoadingSettings()
|
||||
|
||||
// public camera: CameraSettings = new CameraSettings()
|
||||
|
||||
// public lighting: LightingSettings = new LightingSettings()
|
||||
|
||||
cards = [this.color]
|
||||
cards = [this.color, this.dataLoading]
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Version } from '@src/composables/useUpdateConnector'
|
||||
import { ColorBy, IViewerEvents } from '@src/plugins/viewer'
|
||||
import { SpeckleVisualSettingsModel } from '@src/settings/visualSettingsModel'
|
||||
import { SpeckleDataInput } from '@src/types'
|
||||
import { zipModelObjects } from '@src/utils/compression'
|
||||
import { ReceiveInfo } from '@src/utils/matrixViewUtils'
|
||||
import { zipModelObjects } from '@src/utils/compression'
|
||||
import { defineStore } from 'pinia'
|
||||
import { Vector3 } from 'three'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
@@ -26,7 +26,10 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
const host = shallowRef<powerbi.extensibility.visual.IVisualHost>()
|
||||
const formattingSettings = ref<SpeckleVisualSettingsModel>()
|
||||
const loadingProgress = ref<LoadingProgress>(undefined)
|
||||
const objectsFromStore = ref<object[]>(undefined)
|
||||
const objectsFromStore = ref<object[][]>(undefined)
|
||||
|
||||
// State tracking for toggle reset prevention
|
||||
const previousToggleState = ref<boolean | undefined>(undefined)
|
||||
|
||||
const postFileSaveSkipNeeded = ref<boolean>(false)
|
||||
const postClickSkipNeeded = ref<boolean>(false)
|
||||
@@ -35,6 +38,8 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
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)
|
||||
|
||||
@@ -56,6 +61,12 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
|
||||
const cameraPosition = ref<number[]>(undefined)
|
||||
const defaultViewModeInFile = ref<string>(undefined)
|
||||
const sectionBoxData = ref<string>(undefined)
|
||||
|
||||
// Edges settings for view modes
|
||||
const edgesEnabled = ref<boolean>(true)
|
||||
const edgesWeight = ref<number>(1)
|
||||
const edgesColor = ref<number | 'auto'>('auto')
|
||||
|
||||
const speckleViews = ref<SpeckleView[]>([])
|
||||
|
||||
@@ -81,7 +92,19 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
host.value = hostToSet
|
||||
}
|
||||
|
||||
const setReceiveInfo = (newReceiveInfo: ReceiveInfo) => (receiveInfo.value = newReceiveInfo)
|
||||
const setReceiveInfo = (newReceiveInfo: ReceiveInfo) => {
|
||||
receiveInfo.value = newReceiveInfo
|
||||
|
||||
// Always save receiveInfo to file for credentials persistence (contains token and metadata)
|
||||
// This ensures weak tokens are available even when desktop service is unavailable
|
||||
if (formattingSettings.value?.dataLoading.internalizeData.value && objectsFromStore.value) {
|
||||
// If internalize is ON and we have objects, save both objects and receiveInfo together
|
||||
writeObjectsToFile(objectsFromStore.value)
|
||||
} else {
|
||||
// Otherwise just save receiveInfo alone (credentials only)
|
||||
writeReceiveInfoToFile()
|
||||
}
|
||||
}
|
||||
|
||||
const setLatestAvailableVersion = (version: Version | null) => {
|
||||
latestAvailableVersion.value = version
|
||||
@@ -94,6 +117,15 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
return false
|
||||
})
|
||||
|
||||
// detecting the env to control the visibility of update button
|
||||
// might use for different reasons in the future
|
||||
const isRunningInDesktop = computed(() => {
|
||||
// power bi hostEnv enum values:
|
||||
// web = 1, desktop = 4
|
||||
const hostEnv = host.value?.['hostEnv'] as number
|
||||
return hostEnv === 4
|
||||
})
|
||||
|
||||
/**
|
||||
* Ideally one time set when onMounted of `ViewerWrapper.vue` component
|
||||
* @param emit picky emit function to trigger events under `IViewerEvents` interface
|
||||
@@ -111,7 +143,7 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const setObjectsFromStore = (newObjectsFromStore: object[]) => {
|
||||
const setObjectsFromStore = (newObjectsFromStore: object[][]) => {
|
||||
objectsFromStore.value = newObjectsFromStore
|
||||
}
|
||||
|
||||
@@ -122,6 +154,23 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const filterColorByIdsForSelection = (colorByIds: ColorBy[] | null | undefined, selectedIds: string[]): ColorBy[] => {
|
||||
return colorByIds?.filter(colorGroup => {
|
||||
const filteredObjectIds = colorGroup.objectIds.filter(objId =>
|
||||
selectedIds.includes(objId)
|
||||
)
|
||||
if (filteredObjectIds.length > 0) {
|
||||
return { ...colorGroup, objectIds: filteredObjectIds }
|
||||
}
|
||||
return false
|
||||
}).map(colorGroup => ({
|
||||
...colorGroup,
|
||||
objectIds: colorGroup.objectIds.filter(objId =>
|
||||
selectedIds.includes(objId)
|
||||
)
|
||||
})) || []
|
||||
}
|
||||
|
||||
const clearLoadingProgress = () => {
|
||||
loadingProgress.value = undefined
|
||||
}
|
||||
@@ -132,17 +181,24 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
}
|
||||
|
||||
const loadObjectsFromFile = async (objects: object[][]) => {
|
||||
console.log('📁 loadObjectsFromFile called with:', objects.length, 'models')
|
||||
const savedVersionObjectId = objects.map((o) => (o[0] as SpeckleObject).id).join(',')
|
||||
lastLoadedRootObjectId.value = savedVersionObjectId
|
||||
viewerReloadNeeded.value = false
|
||||
console.log(`📦 Loading viewer from cached data with ${lastLoadedRootObjectId.value} id.`)
|
||||
console.log('📁 About to call viewerEmit loadObjects...')
|
||||
await viewerEmit.value('loadObjects', objects)
|
||||
console.log('📁 viewerEmit loadObjects completed')
|
||||
objectsFromStore.value = objects
|
||||
isViewerObjectsLoaded.value = true
|
||||
viewerReloadNeeded.value = false
|
||||
setIsLoadingFromFile(false)
|
||||
console.log('📁 loadObjectsFromFile completed successfully')
|
||||
}
|
||||
|
||||
const setIsLoadingFromFile = (newValue: boolean) => (isLoadingFromFile.value = newValue)
|
||||
|
||||
|
||||
/**
|
||||
* Sets upcoming data input into store to be able to pass it through viewer by evaluating the data.
|
||||
* @param newValue new data input that user dragged and dropped to the fields in visual
|
||||
@@ -157,20 +213,37 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
await viewerEmit.value('loadObjects', dataInput.value.modelObjects)
|
||||
viewerReloadNeeded.value = false
|
||||
isViewerObjectsLoaded.value = true
|
||||
setLoadingProgress('Storing objects into file', null)
|
||||
writeObjectsToFile(dataInput.value.modelObjects)
|
||||
|
||||
// Store the model objects for potential internalization
|
||||
if (dataInput.value.modelObjects && dataInput.value.modelObjects.length > 0) {
|
||||
console.log('📦 Storing modelObjects in visualStore for internalization:', dataInput.value.modelObjects.length, 'models')
|
||||
objectsFromStore.value = dataInput.value.modelObjects
|
||||
}
|
||||
|
||||
// Note: Object internalization is now handled by toggle in visual.ts
|
||||
loadingProgress.value = undefined
|
||||
}
|
||||
|
||||
if (dataInput.value.selectedIds.length > 0) {
|
||||
isFilterActive.value = true
|
||||
viewerEmit.value('filterSelection', dataInput.value.selectedIds, isGhostActive.value)
|
||||
viewerEmit.value('filterSelection', dataInput.value.selectedIds, isGhostActive.value, isZoomOnFilterActive.value)
|
||||
|
||||
// When filtering, only apply colors to the selected/isolated objects
|
||||
const filteredColorByIds = filterColorByIdsForSelection(dataInput.value.colorByIds, dataInput.value.selectedIds)
|
||||
viewerEmit.value('colorObjectsByGroup', filteredColorByIds)
|
||||
} else {
|
||||
isFilterActive.value = false
|
||||
latestColorBy.value = dataInput.value.colorByIds
|
||||
viewerEmit.value('resetFilter', dataInput.value.objectIds, isGhostActive.value)
|
||||
// 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')
|
||||
}
|
||||
// When not filtering, apply all colors including conditional formatting
|
||||
viewerEmit.value('colorObjectsByGroup', dataInput.value.colorByIds)
|
||||
}
|
||||
viewerEmit.value('colorObjectsByGroup', dataInput.value.colorByIds)
|
||||
}
|
||||
|
||||
const writeObjectsToFile = (modelObjects: object[][]) => {
|
||||
@@ -184,6 +257,23 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
objectName: 'storedData',
|
||||
properties: {
|
||||
speckleObjects: compressedChunks,
|
||||
receiveInfo: JSON.stringify(receiveInfo.value) // Keep receiveInfo in sync when storing objects
|
||||
},
|
||||
selector: null
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const writeReceiveInfoToFile = () => {
|
||||
// NOTE: need skipping the update function, it resets the viewer state unneccessarily.
|
||||
postFileSaveSkipNeeded.value = true
|
||||
|
||||
host.value.persistProperties({
|
||||
merge: [
|
||||
{
|
||||
objectName: 'storedData',
|
||||
properties: {
|
||||
receiveInfo: JSON.stringify(receiveInfo.value)
|
||||
},
|
||||
selector: null
|
||||
@@ -240,6 +330,22 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
@@ -272,6 +378,38 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
})
|
||||
}
|
||||
|
||||
const writeNavbarVisibilityToFile = (navbarHidden: boolean) => {
|
||||
// NOTE: need skipping the update function, it resets the viewer state unneccessarily.
|
||||
postFileSaveSkipNeeded.value = true
|
||||
host.value.persistProperties({
|
||||
merge: [
|
||||
{
|
||||
objectName: 'viewMode',
|
||||
properties: {
|
||||
navbarHidden: navbarHidden
|
||||
},
|
||||
selector: null
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const writeDataLoadingModeToFile = (internalizeData: boolean) => {
|
||||
// NOTE: need skipping the update function, it resets the viewer state unneccessarily.
|
||||
postFileSaveSkipNeeded.value = true
|
||||
host.value.persistProperties({
|
||||
merge: [
|
||||
{
|
||||
objectName: 'dataLoading',
|
||||
properties: {
|
||||
internalizeData: internalizeData
|
||||
},
|
||||
selector: null
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const writeCameraPositionToFile = (position: Vector3, target: Vector3) => {
|
||||
// NOTE: need skipping the update function, it resets the viewer state unneccessarily.
|
||||
postFileSaveSkipNeeded.value = true
|
||||
@@ -293,12 +431,28 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
})
|
||||
}
|
||||
|
||||
const writeSectionBoxToFile = (boxData: string | null) => {
|
||||
postFileSaveSkipNeeded.value = true
|
||||
host.value.persistProperties({
|
||||
merge: [
|
||||
{
|
||||
objectName: 'sectionBox',
|
||||
properties: {
|
||||
boxData: boxData
|
||||
},
|
||||
selector: null
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const setSectionBoxData = (newValue: string | null) => (sectionBoxData.value = newValue)
|
||||
|
||||
const setFieldInputState = (newFieldInputState: FieldInputState) =>
|
||||
(fieldInputState.value = newFieldInputState)
|
||||
|
||||
const clearDataInput = () => (dataInput.value = null)
|
||||
|
||||
const setIsLoadingFromFile = (newValue: boolean) => (isLoadingFromFile.value = newValue)
|
||||
|
||||
const setViewerReadyToLoad = (newValue: boolean) => (isViewerReadyToLoad.value = newValue)
|
||||
|
||||
@@ -313,6 +467,15 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
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
|
||||
}
|
||||
@@ -321,18 +484,60 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
isGhostActive.value = val
|
||||
}
|
||||
|
||||
const setIsZoomOnFilterActive = (val: boolean) => {
|
||||
isZoomOnFilterActive.value = val
|
||||
}
|
||||
|
||||
const setPostFileSaveSkipNeeded = (newValue: boolean) => (postFileSaveSkipNeeded.value = newValue)
|
||||
const setPostClickSkipNeeded = (newValue: boolean) => (postClickSkipNeeded.value = newValue)
|
||||
|
||||
const setCameraPositionInFile = (newValue: number[]) => (cameraPosition.value = newValue)
|
||||
const setDefaultViewModeInFile = (newValue: string) => (defaultViewModeInFile.value = newValue)
|
||||
|
||||
// Edges settings setters
|
||||
const setEdgesEnabled = (val: boolean) => {
|
||||
edgesEnabled.value = val
|
||||
}
|
||||
|
||||
const setEdgesWeight = (val: number) => {
|
||||
edgesWeight.value = val
|
||||
}
|
||||
|
||||
const setEdgesColor = (val: number | 'auto') => {
|
||||
edgesColor.value = val
|
||||
}
|
||||
|
||||
const writeEdgesSettingsToFile = () => {
|
||||
// NOTE: need skipping the update function, it resets the viewer state unnecessarily.
|
||||
postFileSaveSkipNeeded.value = true
|
||||
host.value.persistProperties({
|
||||
merge: [
|
||||
{
|
||||
objectName: 'viewMode',
|
||||
properties: {
|
||||
edgesEnabled: edgesEnabled.value,
|
||||
edgesWeight: edgesWeight.value,
|
||||
edgesColor: edgesColor.value === 'auto' ? -1 : edgesColor.value
|
||||
},
|
||||
selector: null
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const setSpeckleViews = (newSpeckleViews: SpeckleView[]) => (speckleViews.value = newSpeckleViews)
|
||||
const setFormattingSettings = (newFormattingSettings: SpeckleVisualSettingsModel) =>
|
||||
(formattingSettings.value = newFormattingSettings)
|
||||
|
||||
const resetFilters = () => {
|
||||
viewerEmit.value('resetFilter', dataInput.value.objectIds, isGhostActive.value)
|
||||
// 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')
|
||||
}
|
||||
// When resetting filters, apply all colors including conditional formatting
|
||||
if (latestColorBy.value !== null) {
|
||||
viewerEmit.value('colorObjectsByGroup', latestColorBy.value)
|
||||
}
|
||||
@@ -347,6 +552,46 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
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)
|
||||
|
||||
// When filtering, only apply colors to the selected/isolated objects
|
||||
const filteredColorByIds = filterColorByIdsForSelection(dataInput.value.colorByIds, dataInput.value.selectedIds)
|
||||
viewerEmit.value('colorObjectsByGroup', filteredColorByIds)
|
||||
} 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 for all objects when not filtering
|
||||
viewerEmit.value('colorObjectsByGroup', dataInput.value.colorByIds)
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger host data refresh to synchronize with Power BI
|
||||
host.value.refreshHostData()
|
||||
}
|
||||
|
||||
// Toggle state tracking functions
|
||||
const setPreviousToggleState = (state: boolean) => {
|
||||
previousToggleState.value = state
|
||||
}
|
||||
|
||||
return {
|
||||
host,
|
||||
receiveInfo,
|
||||
@@ -364,6 +609,10 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
isLoadingFromFile,
|
||||
cameraPosition,
|
||||
defaultViewModeInFile,
|
||||
sectionBoxData,
|
||||
edgesEnabled,
|
||||
edgesWeight,
|
||||
edgesColor,
|
||||
speckleViews,
|
||||
postFileSaveSkipNeeded,
|
||||
postClickSkipNeeded,
|
||||
@@ -373,19 +622,29 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
isBrandingHidden,
|
||||
isOrthoProjection,
|
||||
isGhostActive,
|
||||
isNavbarHidden,
|
||||
isZoomOnFilterActive,
|
||||
latestAvailableVersion,
|
||||
isConnectorUpToDate,
|
||||
isRunningInDesktop,
|
||||
commonError,
|
||||
previousToggleState,
|
||||
setCommonError,
|
||||
setLatestAvailableVersion,
|
||||
setIsOrthoProjection,
|
||||
setIsGhost,
|
||||
setIsZoomOnFilterActive,
|
||||
setFormattingSettings,
|
||||
setBrandingHidden,
|
||||
setNavbarHidden,
|
||||
setPostClickSkipNeeded,
|
||||
setPostFileSaveSkipNeeded,
|
||||
setCameraPositionInFile,
|
||||
setDefaultViewModeInFile,
|
||||
setEdgesEnabled,
|
||||
setEdgesWeight,
|
||||
setEdgesColor,
|
||||
writeEdgesSettingsToFile,
|
||||
setSpeckleViews,
|
||||
loadObjectsFromFile,
|
||||
setHost,
|
||||
@@ -395,11 +654,17 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
writeObjectsToFile,
|
||||
writeCameraViewToFile,
|
||||
writeIsGhostToFile,
|
||||
writeZoomOnFilterToFile,
|
||||
writeIsOrthoToFile,
|
||||
writeViewModeToFile,
|
||||
writeCameraPositionToFile,
|
||||
writeSectionBoxToFile,
|
||||
setSectionBoxData,
|
||||
writeHideBrandingToFile,
|
||||
writeNavbarVisibilityToFile,
|
||||
writeDataLoadingModeToFile,
|
||||
toggleBranding,
|
||||
toggleNavbar,
|
||||
setViewerEmitter,
|
||||
setDataInput,
|
||||
setFieldInputState,
|
||||
@@ -409,6 +674,8 @@ export const useVisualStore = defineStore('visualStore', () => {
|
||||
clearLoadingProgress,
|
||||
setIsLoadingFromFile,
|
||||
resetFilters,
|
||||
downloadLatestVersion
|
||||
downloadLatestVersion,
|
||||
handleObjectsLoadedComplete,
|
||||
setPreviousToggleState
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Interface for decoded user info data passed from the data connector
|
||||
* This data is base64-encoded in the "Version Object ID" field and decoded by the visual
|
||||
*/
|
||||
export interface DecodedUserInfo {
|
||||
rootObjectId: string
|
||||
server: string
|
||||
email: string
|
||||
projectId: string
|
||||
token: string // weak token with limited scopes
|
||||
workspaceId?: string | null
|
||||
workspaceName?: string | null
|
||||
workspaceLogo?: string | null
|
||||
version?: string
|
||||
sourceApplication?: string
|
||||
canHideBranding?: boolean
|
||||
versionId?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
|
||||
// Decodes a base64-encoded JSON string to extract userInfoData
|
||||
export function decodeUserInfo(encodedString: string): DecodedUserInfo {
|
||||
try {
|
||||
// Base64 decode using browser's atob()
|
||||
const decodedString = atob(encodedString)
|
||||
|
||||
// Parse JSON
|
||||
const userInfo = JSON.parse(decodedString) as DecodedUserInfo
|
||||
|
||||
// Validate required fields
|
||||
const requiredFields: (keyof DecodedUserInfo)[] = [
|
||||
'rootObjectId',
|
||||
'server',
|
||||
'email',
|
||||
'projectId',
|
||||
'token'
|
||||
]
|
||||
|
||||
const missingFields = requiredFields.filter((field) => !userInfo[field])
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
throw new Error(
|
||||
`Missing required fields in decoded user info: ${missingFields.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
return userInfo
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`Failed to decode user info: ${error.message}`)
|
||||
}
|
||||
throw new Error('Failed to decode user info: Unknown error')
|
||||
}
|
||||
}
|
||||
|
||||
// Decodes multiple base64-encoded userInfo strings (for federated models)
|
||||
|
||||
export function decodeMultipleUserInfo(encodedStrings: string): DecodedUserInfo[] {
|
||||
try {
|
||||
// Split by delimiter
|
||||
const segments = encodedStrings.split('|||')
|
||||
|
||||
// Decode each segment
|
||||
return segments.map((segment, index) => {
|
||||
try {
|
||||
return decodeUserInfo(segment.trim())
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to decode segment ${index + 1} of federated model data: ${
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
}`
|
||||
)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`Failed to decode multiple user info: ${error.message}`)
|
||||
}
|
||||
throw new Error('Failed to decode multiple user info: Unknown error')
|
||||
}
|
||||
}
|
||||
|
||||
// Checks if an encoded string contains multiple models (federated)
|
||||
export function isFederatedEncoding(encodedString: string): boolean {
|
||||
return encodedString.includes('|||')
|
||||
}
|
||||
|
||||
// Safely decodes userInfo, handling both single and federated models
|
||||
// Returns an array of DecodedUserInfo (single item for non-federated)
|
||||
export function decodeUserInfoSafe(encodedString: string): DecodedUserInfo[] {
|
||||
if (isFederatedEncoding(encodedString)) {
|
||||
return decodeMultipleUserInfo(encodedString)
|
||||
} else {
|
||||
return [decodeUserInfo(encodedString)]
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ import { FieldInputState, useVisualStore } from '@src/store/visualStore'
|
||||
import { delay } from 'lodash'
|
||||
import { getSlugFromHostAppNameAndVersion } from './hostAppSlug'
|
||||
import { useUpdateConnector } from '@src/composables/useUpdateConnector'
|
||||
import { SpeckleApiLoader } from '@src/loader/SpeckleApiLoader'
|
||||
import { unzipModelObjects } from './compression'
|
||||
import { decodeUserInfoSafe, DecodedUserInfo } from './decodeUserInfo'
|
||||
|
||||
export class AsyncPause {
|
||||
private lastPauseTime = 0
|
||||
@@ -129,7 +132,8 @@ function processObjectNode(
|
||||
console.log('⚠️ HAS objects', color)
|
||||
if (color) {
|
||||
res.color = color
|
||||
res.shouldColor = true
|
||||
// Don't override shouldColor for conditional formatting - keep the selection state
|
||||
// res.shouldColor = true // REMOVED: This was overriding cross-filter selection state
|
||||
}
|
||||
}
|
||||
return res
|
||||
@@ -158,168 +162,108 @@ export type ReceiveInfo = {
|
||||
workspaceName?: string
|
||||
canHideBranding: boolean
|
||||
version?: string
|
||||
token: string
|
||||
projectId?: 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> {
|
||||
/**
|
||||
* Extracts userInfoData from encoded string
|
||||
* Returns array of DecodedUserInfo for federated models, single item for single models
|
||||
*/
|
||||
function decodeUserInfoFromId(encodedId: string): DecodedUserInfo[] {
|
||||
try {
|
||||
const preGetObjectsRes = await fetch(`http://localhost:29364/pre-get-objects/${id}`)
|
||||
return decodeUserInfoSafe(encodedId)
|
||||
} catch (error) {
|
||||
console.error('Failed to decode user info from encoded ID:', error)
|
||||
throw new Error(`Invalid encoded user info data: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!preGetObjectsRes.body) {
|
||||
console.log('No response body for pre get objects')
|
||||
return {
|
||||
modelExists: false,
|
||||
objectCount: null
|
||||
} as PreGetObjects
|
||||
// Mark version as received
|
||||
async function markVersionAsReceived(
|
||||
versionId: string,
|
||||
projectId: string,
|
||||
serverUrl: string,
|
||||
token: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const mutation = `
|
||||
mutation MarkVersionReceived($input: MarkReceivedVersionInput!) {
|
||||
versionMutations {
|
||||
markReceived(input: $input)
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const variables = {
|
||||
input: {
|
||||
versionId: versionId,
|
||||
projectId: projectId,
|
||||
sourceApplication: 'powerbi'
|
||||
}
|
||||
}
|
||||
|
||||
return (await preGetObjectsRes.json()) as PreGetObjects
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
const response = await fetch(`${serverUrl}/graphql`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: mutation,
|
||||
variables: variables
|
||||
})
|
||||
})
|
||||
|
||||
async function getReceiveInfo(id) {
|
||||
try {
|
||||
const ids = (id as string).split(',')
|
||||
const response = await fetch(`http://localhost:29364/user-info/${ids[0]}`)
|
||||
if (!response.body) {
|
||||
console.error('No response body')
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`Failed to mark version as received (status ${response.status}). This is non-critical.`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
const result = await response.json()
|
||||
if (result.errors) {
|
||||
console.warn('Failed to mark version as received:', result.errors)
|
||||
} else {
|
||||
console.log(`✅ Marked version ${versionId} as received in PowerBI`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
console.log("User infp couldn't retrieved from local server.")
|
||||
// Non-critical error - log but don't throw
|
||||
console.warn('Failed to mark version as received:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStreamedData(commaSeparatedModelIds: string, totalObjectCount: number) {
|
||||
const modelIds = (commaSeparatedModelIds as string).split(',')
|
||||
async function fetchFromSpeckleApi(
|
||||
objectIds: string,
|
||||
serverUrl: string,
|
||||
projectId: string,
|
||||
token: string,
|
||||
versionIds?: string[]
|
||||
): Promise<object[][]> {
|
||||
const ids = objectIds.split(',')
|
||||
const modelObjects = []
|
||||
|
||||
let loadedObjectCount = 0
|
||||
|
||||
for await (const id of modelIds) {
|
||||
const objects = await fetchStreamedDataForModel(id, totalObjectCount, loadedObjectCount)
|
||||
modelObjects.push(objects)
|
||||
loadedObjectCount += objects.length
|
||||
}
|
||||
return modelObjects
|
||||
}
|
||||
|
||||
async function fetchStreamedDataForModel(
|
||||
id: string,
|
||||
totalObjectCount: number,
|
||||
loadedObjectCount: number
|
||||
) {
|
||||
console.log(loadedObjectCount, totalObjectCount)
|
||||
|
||||
try {
|
||||
const visualStore = useVisualStore()
|
||||
const response = await fetch(`http://localhost:29364/get-objects/${id}`)
|
||||
|
||||
if (!response.body) {
|
||||
console.error('No response body')
|
||||
return
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
const objects = []
|
||||
let buffer = ''
|
||||
|
||||
const start = performance.now()
|
||||
console.log('Streaming started...')
|
||||
for await (const chunk of readStream(reader)) {
|
||||
// chucks.push(chuck)
|
||||
buffer += decoder.decode(chunk, { stream: true })
|
||||
|
||||
let boundary
|
||||
while ((boundary = buffer.indexOf('\n')) !== -1) {
|
||||
const jsonString = buffer.slice(0, boundary)
|
||||
buffer = buffer.slice(boundary + 1)
|
||||
|
||||
try {
|
||||
const obj = JSON.parse(jsonString)
|
||||
objects.push(obj)
|
||||
visualStore.setLoadingProgress(
|
||||
'Loading objects from storage',
|
||||
(objects.length + loadedObjectCount) / totalObjectCount
|
||||
)
|
||||
// console.log('Loading', (objects.length + loadedObjectCount) / totalObjectCount)
|
||||
|
||||
// console.log('Received object:', jsonObject)
|
||||
} catch (e) {
|
||||
console.error('Invalid JSON chunk:', jsonString)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const objectId = ids[i]
|
||||
try {
|
||||
const obj = JSON.parse(buffer)
|
||||
objects.push(obj)
|
||||
// console.log('Received object:', jsonObject)
|
||||
} catch (e) {
|
||||
console.error('Invalid JSON chunk:', buffer)
|
||||
}
|
||||
console.log(`Downloading from Speckle API: ${objectId}`)
|
||||
const loader = new SpeckleApiLoader(serverUrl, projectId, token)
|
||||
const objects = await loader.downloadObjectsWithChildren(objectId)
|
||||
modelObjects.push(objects)
|
||||
console.log(`Downloaded ${objects.length} objects from Speckle`)
|
||||
|
||||
const end = performance.now()
|
||||
console.log(`Objects streamed in: ${(end - start) / 1000} s`)
|
||||
|
||||
const startObjectCleanup = performance.now()
|
||||
// Skips first element
|
||||
for (let i = 1; i < objects.length; i++) {
|
||||
const obj = objects[i]
|
||||
if (obj.speckle_type) {
|
||||
if (obj.speckle_type.includes('Objects.Data.DataObject')) {
|
||||
delete obj.properties
|
||||
}
|
||||
// Mark version as received (non-blocking, best effort)
|
||||
if (versionIds && versionIds[i]) {
|
||||
markVersionAsReceived(versionIds[i], projectId, serverUrl, token)
|
||||
}
|
||||
delete obj.__closure
|
||||
}
|
||||
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)
|
||||
console.error(`Failed to download objects from Speckle:`, error)
|
||||
throw error
|
||||
}
|
||||
|
||||
return objects
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
console.log("Objects couldn't retrieved from local server.")
|
||||
} finally {
|
||||
console.log('Streaming finished!')
|
||||
}
|
||||
}
|
||||
|
||||
async function* readStream(reader) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
yield value
|
||||
}
|
||||
return modelObjects
|
||||
}
|
||||
|
||||
export async function processMatrixView(
|
||||
@@ -327,7 +271,8 @@ export async function processMatrixView(
|
||||
host: powerbi.extensibility.visual.IVisualHost,
|
||||
hasColorFilter: boolean,
|
||||
settings: SpeckleVisualSettingsModel,
|
||||
onSelectionPair: (objId: string, selectionId: powerbi.extensibility.ISelectionId) => void
|
||||
onSelectionPair: (objId: string, selectionId: powerbi.extensibility.ISelectionId) => void,
|
||||
internalizedData?: string
|
||||
): Promise<SpeckleDataInput> {
|
||||
const visualStore = useVisualStore()
|
||||
const objectIds = [],
|
||||
@@ -340,60 +285,230 @@ export async function processMatrixView(
|
||||
const localMatrixView = matrixView.rows.root.children
|
||||
let id = null
|
||||
|
||||
if (hasColorFilter) {
|
||||
id = localMatrixView[0].children[0].values[0].value as unknown as string
|
||||
} else {
|
||||
id = localMatrixView[0].values[0].value as unknown as string
|
||||
// Safety check for matrix data structure
|
||||
if (!localMatrixView || localMatrixView.length === 0) {
|
||||
throw new Error('Matrix view has no data rows')
|
||||
}
|
||||
|
||||
// const id = localMatrixView[0].values[0].value as unknown as string
|
||||
console.log('🗝️ Root Object Id: ', id)
|
||||
console.log('Last laoded root object id', visualStore.lastLoadedRootObjectId)
|
||||
try {
|
||||
if (hasColorFilter) {
|
||||
if (
|
||||
!localMatrixView[0].children ||
|
||||
localMatrixView[0].children.length === 0 ||
|
||||
!localMatrixView[0].children[0].values
|
||||
) {
|
||||
throw new Error('Matrix view structure is incomplete for color filter mode')
|
||||
}
|
||||
id = localMatrixView[0].children[0].values[0].value as unknown as string
|
||||
} else {
|
||||
if (!localMatrixView[0].values || !localMatrixView[0].values[0]) {
|
||||
throw new Error('Matrix view structure is incomplete for normal mode')
|
||||
}
|
||||
id = localMatrixView[0].values[0].value as unknown as string
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error accessing matrix data:', error)
|
||||
throw new Error(`Failed to extract root object ID from matrix: ${error.message}`)
|
||||
}
|
||||
|
||||
// Check for internalized data but ONLY if it matches current matrix data
|
||||
let internalizedModelObjects: object[][] | undefined = undefined
|
||||
if (settings.dataLoading.internalizeData.value && internalizedData) {
|
||||
console.log('📁 Checking internalized data in processMatrixView')
|
||||
|
||||
try {
|
||||
internalizedModelObjects = unzipModelObjects(internalizedData)
|
||||
|
||||
if (internalizedModelObjects && internalizedModelObjects.length > 0) {
|
||||
// CRITICAL: Validate that internalized data matches current matrix data
|
||||
// Need to decode id first to get actual root object IDs for comparison
|
||||
try {
|
||||
const decodedForCheck = decodeUserInfoFromId(id)
|
||||
const actualRootIds = decodedForCheck.map((info) => info.rootObjectId).join(',')
|
||||
const internalizedRootId = (internalizedModelObjects[0][0] as any).id
|
||||
|
||||
if (internalizedRootId !== actualRootIds.split(',')[0]) {
|
||||
console.log(
|
||||
`📁 Internalized data mismatch: stored=${internalizedRootId}, current=${actualRootIds}. Using fresh data.`
|
||||
)
|
||||
internalizedModelObjects = undefined // Clear internalized data - use fresh data instead
|
||||
} else {
|
||||
console.log(
|
||||
'📁 Successfully validated internalized data matches current matrix:',
|
||||
internalizedModelObjects.length,
|
||||
'models'
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('📁 Failed to decode ID for internalized data check:', error)
|
||||
internalizedModelObjects = undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (internalizedModelObjects && internalizedModelObjects.length > 0) {
|
||||
// Set dummy receiveInfo to prevent UI errors
|
||||
if (!visualStore.receiveInfo) {
|
||||
visualStore.setReceiveInfo({
|
||||
userEmail: 'offline@speckle.systems',
|
||||
serverUrl: 'offline',
|
||||
sourceApplication: 'PowerBI Offline',
|
||||
workspaceId: 'offline',
|
||||
workspaceName: 'Offline Workspace',
|
||||
workspaceLogo: '',
|
||||
version: '1.0.0',
|
||||
canHideBranding: false,
|
||||
token: 'offline',
|
||||
projectId: 'offline'
|
||||
})
|
||||
}
|
||||
|
||||
// Only reload if switching models or not already loaded
|
||||
// Need to decode to get actual root object ID for comparison
|
||||
try {
|
||||
const decodedForReload = decodeUserInfoFromId(id)
|
||||
const actualRootIds = decodedForReload.map((info) => info.rootObjectId).join(',')
|
||||
|
||||
const needsReload =
|
||||
!visualStore.isViewerObjectsLoaded ||
|
||||
visualStore.lastLoadedRootObjectId !== actualRootIds
|
||||
if (needsReload) {
|
||||
console.log('🔄 Forcing viewer reload for internalized data (model switch or first load)')
|
||||
visualStore.setViewerReloadNeeded()
|
||||
visualStore.setViewerReadyToLoad(true)
|
||||
visualStore.setLoadingProgress('📁 Loading from file', null)
|
||||
} else {
|
||||
console.log('📁 Internalized data already loaded, skipping reload')
|
||||
}
|
||||
visualStore.lastLoadedRootObjectId = actualRootIds // Set to actual root IDs to skip API calls
|
||||
} catch (error) {
|
||||
console.error('📁 Failed to decode ID for reload check:', error)
|
||||
}
|
||||
} else {
|
||||
console.error('📁 Failed to unzip internalized data')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('📁 Error processing internalized data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the encoded string from matrix (id is now the base64 encoded userInfo)
|
||||
const encodedId = id
|
||||
console.log('🗝️ Encoded ID: ', encodedId.substring(0, 50) + '...')
|
||||
console.log('Last loaded root object id', visualStore.lastLoadedRootObjectId)
|
||||
|
||||
let modelObjects: object[][] = undefined
|
||||
|
||||
if (visualStore.isLoadingFromFile) {
|
||||
console.log('The data is loading from file, skipping the streaming it.')
|
||||
// Decode userInfo first to get actual root object IDs for comparison
|
||||
let decodedUserInfos: DecodedUserInfo[]
|
||||
let actualRootObjectIds: string
|
||||
|
||||
try {
|
||||
decodedUserInfos = decodeUserInfoFromId(encodedId)
|
||||
// Build comma-separated list of actual root object IDs
|
||||
actualRootObjectIds = decodedUserInfos.map((info) => info.rootObjectId).join(',')
|
||||
console.log(`🔓 Decoded ${decodedUserInfos.length} userInfo(s) - Root IDs: ${actualRootObjectIds}`)
|
||||
} catch (error) {
|
||||
console.error('Failed to decode user info:', error)
|
||||
visualStore.setCommonError(
|
||||
'Failed to decode user info from data connector. Please refresh the data.'
|
||||
)
|
||||
visualStore.setViewerReadyToLoad(false)
|
||||
return {
|
||||
modelObjects: [],
|
||||
objectIds: [],
|
||||
selectedIds: [],
|
||||
colorByIds: null,
|
||||
objectTooltipData: new Map(),
|
||||
isFromStore: false
|
||||
}
|
||||
}
|
||||
|
||||
if (visualStore.lastLoadedRootObjectId !== id && !visualStore.isLoadingFromFile) {
|
||||
// Check if we need to reload (compare actual root object IDs, not encoded strings)
|
||||
if (
|
||||
visualStore.lastLoadedRootObjectId !== actualRootObjectIds &&
|
||||
!visualStore.isLoadingFromFile &&
|
||||
!internalizedModelObjects
|
||||
) {
|
||||
const start = performance.now()
|
||||
|
||||
const getPreGetObjectsRes: PreGetObjects[] = await getPreGetObjects(id)
|
||||
// Use the first decoded userInfo for visual store (for federated, all have same credentials)
|
||||
const primaryUserInfo = decodedUserInfos[0]
|
||||
|
||||
if (getPreGetObjectsRes.some((preGetObjects) => preGetObjects.modelExists === false)) {
|
||||
visualStore.setReceiveInfo({
|
||||
userEmail: primaryUserInfo.email,
|
||||
serverUrl: primaryUserInfo.server,
|
||||
sourceApplication: getSlugFromHostAppNameAndVersion(primaryUserInfo.sourceApplication || ''),
|
||||
workspaceId: primaryUserInfo.workspaceId || undefined,
|
||||
workspaceName: primaryUserInfo.workspaceName || undefined,
|
||||
workspaceLogo: primaryUserInfo.workspaceLogo || undefined,
|
||||
version: primaryUserInfo.version,
|
||||
canHideBranding: primaryUserInfo.canHideBranding || false,
|
||||
token: primaryUserInfo.token,
|
||||
projectId: primaryUserInfo.projectId
|
||||
})
|
||||
console.log(`✅ Credentials loaded from encoded data`)
|
||||
|
||||
// Get credentials for Speckle API download
|
||||
const token = primaryUserInfo.token
|
||||
const serverUrl = primaryUserInfo.server
|
||||
const projectId = primaryUserInfo.projectId
|
||||
|
||||
if (!token || !serverUrl || !projectId) {
|
||||
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.'
|
||||
'Missing required credentials in encoded data. Please refresh the data from the data connector.'
|
||||
)
|
||||
visualStore.setViewerReadyToLoad(false)
|
||||
return
|
||||
return {
|
||||
modelObjects: [],
|
||||
objectIds: [],
|
||||
selectedIds: [],
|
||||
colorByIds: null,
|
||||
objectTooltipData: new Map(),
|
||||
isFromStore: false
|
||||
}
|
||||
}
|
||||
|
||||
const receiveInfo = await getReceiveInfo(id)
|
||||
if (receiveInfo) {
|
||||
visualStore.setReceiveInfo({
|
||||
userEmail: receiveInfo.email,
|
||||
serverUrl: receiveInfo.server,
|
||||
sourceApplication: getSlugFromHostAppNameAndVersion(receiveInfo.sourceApplication),
|
||||
workspaceId: receiveInfo.workspaceId,
|
||||
workspaceName: receiveInfo.workspaceName,
|
||||
workspaceLogo: receiveInfo.workspaceLogo,
|
||||
version: receiveInfo.version,
|
||||
canHideBranding: receiveInfo.canHideBranding
|
||||
})
|
||||
console.log(`Receive info retrieved from desktop service`, receiveInfo)
|
||||
}
|
||||
|
||||
const totalObjectCount = getPreGetObjectsRes.reduce((sum, obj) => {
|
||||
return sum + (obj.objectCount ?? 0)
|
||||
}, 0)
|
||||
|
||||
visualStore.setViewerReadyToLoad(true)
|
||||
// stream data
|
||||
modelObjects = await fetchStreamedData(id, totalObjectCount)
|
||||
|
||||
console.log('Downloading objects directly from Speckle API...')
|
||||
console.log(`Server: ${serverUrl}, Project: ${projectId}, Objects: ${actualRootObjectIds}`)
|
||||
try {
|
||||
// Extract versionIds for markAsReceived
|
||||
const versionIds = decodedUserInfos.map((info) => info.versionId).filter(Boolean) as string[]
|
||||
|
||||
modelObjects = await fetchFromSpeckleApi(
|
||||
actualRootObjectIds,
|
||||
serverUrl,
|
||||
projectId,
|
||||
token,
|
||||
versionIds.length > 0 ? versionIds : undefined
|
||||
)
|
||||
console.log('Successfully downloaded from Speckle API')
|
||||
|
||||
// Debug: Check what we're passing to the viewer
|
||||
if (modelObjects && modelObjects.length > 0 && modelObjects[0].length > 0) {
|
||||
console.log('ModelObjects structure:', {
|
||||
totalModels: modelObjects.length,
|
||||
firstModelObjectCount: modelObjects[0].length,
|
||||
firstObject: modelObjects[0][0]
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to download from Speckle API:', error)
|
||||
visualStore.setCommonError(`Failed to download objects from Speckle: ${error.message}`)
|
||||
visualStore.setViewerReadyToLoad(false)
|
||||
return {
|
||||
modelObjects: [],
|
||||
objectIds: [],
|
||||
selectedIds: [],
|
||||
colorByIds: null,
|
||||
objectTooltipData: new Map(),
|
||||
isFromStore: false
|
||||
}
|
||||
}
|
||||
|
||||
visualStore.setViewerReloadNeeded() // they should be marked as deferred action bc of update function complexity.
|
||||
visualStore.setLoadingProgress('Loading objects into viewer', null)
|
||||
visualStore.setLoadingProgress('🌍 Loading objects into viewer', null)
|
||||
console.log(`🚀 Upload is completed in ${(performance.now() - start) / 1000} s!`)
|
||||
}
|
||||
|
||||
@@ -459,6 +574,7 @@ export async function processMatrixView(
|
||||
localMatrixView.forEach((obj) => {
|
||||
const processedObjectIdLevels = processObjectIdLevel(obj, host, matrixView)
|
||||
|
||||
// Apply conditional formatting color if present, regardless of selection state
|
||||
if (processedObjectIdLevels.color) {
|
||||
let group = colorByIds.find((g) => g.color === processedObjectIdLevels.color)
|
||||
if (!group) {
|
||||
@@ -468,7 +584,11 @@ export async function processMatrixView(
|
||||
}
|
||||
colorByIds.push(group)
|
||||
}
|
||||
// Always add to color group if color is specified (conditional formatting)
|
||||
group.objectIds.push(processedObjectIdLevels.id)
|
||||
} else if (processedObjectIdLevels.shouldColor) {
|
||||
// Only use shouldColor flag when there's no conditional formatting
|
||||
// This preserves the original cross-filter coloring behavior
|
||||
}
|
||||
|
||||
objectIds.push(processedObjectIdLevels.id)
|
||||
@@ -538,11 +658,11 @@ export async function processMatrixView(
|
||||
previousPalette = host.colorPalette['colorPalette']
|
||||
|
||||
return {
|
||||
modelObjects,
|
||||
modelObjects: internalizedModelObjects || modelObjects, // Use internalized data if available
|
||||
objectIds,
|
||||
selectedIds,
|
||||
colorByIds: colorByIds.length > 0 ? colorByIds : null,
|
||||
objectTooltipData,
|
||||
isFromStore: false
|
||||
isFromStore: !!internalizedModelObjects // true if loaded from internalized data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { FilteringState } from '@speckle/viewer'
|
||||
import { OrthographicCamera, PerspectiveCamera } from 'three'
|
||||
|
||||
export function projectToScreen(cam: OrthographicCamera | PerspectiveCamera, loc) {
|
||||
@@ -16,17 +15,7 @@ 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'))
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<div class="flex flex-col justify-center space-y-1">
|
||||
<div class="flex flex-row space-x-2">
|
||||
<EyeIcon class="w-6"></EyeIcon>
|
||||
<p><b>Version Object ID</b></p>
|
||||
<p><b>Model Info</b></p>
|
||||
<ArrowRightIcon class="w-4"></ArrowRightIcon>
|
||||
<p>View your model</p>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { selectionHandlerKey, tooltipHandlerKey } from 'src/injectionKeys'
|
||||
import { SpeckleDataInput } from './types'
|
||||
import { processMatrixView, ReceiveInfo, validateMatrixView } from './utils/matrixViewUtils'
|
||||
import { SpeckleVisualSettingsModel } from './settings/visualSettingsModel'
|
||||
import { unzipModelObjects } from './utils/compression'
|
||||
|
||||
import TooltipHandler from './handlers/tooltipHandler'
|
||||
import SelectionHandler from './handlers/selectionHandler'
|
||||
@@ -21,7 +22,7 @@ import ITooltipService = powerbi.extensibility.ITooltipService
|
||||
|
||||
import { pinia } from './plugins/pinia'
|
||||
import { useVisualStore } from './store/visualStore'
|
||||
import { unzipModelObjects } from './utils/compression'
|
||||
import { SpeckleApiLoader } from './loader/SpeckleApiLoader'
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
export class Visual implements IVisual {
|
||||
@@ -88,13 +89,43 @@ export class Visual implements IVisual {
|
||||
|
||||
// @ts-ignore
|
||||
console.log('⤴️ Update type 👉', powerbi.VisualUpdateType[options.type])
|
||||
|
||||
this.formattingSettings = this.formattingSettingsService.populateFormattingSettingsModel(
|
||||
SpeckleVisualSettingsModel,
|
||||
options.dataViews[0]
|
||||
)
|
||||
|
||||
visualStore.setFormattingSettings(this.formattingSettings)
|
||||
console.log('Selector colors', this.formattingSettings.colorSelector)
|
||||
console.log(
|
||||
'Data Loading - Internalize Data:',
|
||||
this.formattingSettings.dataLoading.internalizeData.value
|
||||
)
|
||||
|
||||
// Handle toggle state changes
|
||||
const currentToggleState = this.formattingSettings.dataLoading.internalizeData.value
|
||||
const previousToggleState = visualStore.previousToggleState
|
||||
|
||||
// Detect user toggle changes
|
||||
if (previousToggleState !== undefined && currentToggleState !== previousToggleState) {
|
||||
console.log('🔄 User changed toggle from', previousToggleState, 'to', currentToggleState)
|
||||
|
||||
if (currentToggleState) {
|
||||
// Toggle switched ON - internalize via streaming
|
||||
if (visualStore.isViewerObjectsLoaded && visualStore.lastLoadedRootObjectId) {
|
||||
console.log('📁 Toggle ON - starting internalization')
|
||||
await this.internalizeCurrentViewerData()
|
||||
} else {
|
||||
console.log('📁 Toggle ON - no active session to internalize')
|
||||
}
|
||||
} else {
|
||||
// Toggle switched OFF - remove internalized data
|
||||
console.log('🗑️ Toggle OFF - removing internalized data')
|
||||
this.removeInternalizedData()
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: Always update the previous state for next comparison
|
||||
visualStore.setPreviousToggleState(currentToggleState)
|
||||
|
||||
try {
|
||||
const matrixView = options.dataViews[0].matrix
|
||||
@@ -114,52 +145,68 @@ export class Visual implements IVisual {
|
||||
return
|
||||
case powerbi.VisualUpdateType.Data:
|
||||
try {
|
||||
// read saved data from file if any
|
||||
if (
|
||||
!visualStore.isViewerObjectsLoaded &&
|
||||
this.isFirstViewerLoad &&
|
||||
options.dataViews[0].metadata.objects
|
||||
) {
|
||||
const chunks = options.dataViews[0].metadata.objects.storedData
|
||||
?.speckleObjects as string
|
||||
const objectsFromFile = unzipModelObjects(chunks)
|
||||
// read saved settings from file if any
|
||||
console.log('🔍 Checking for other saved settings:')
|
||||
|
||||
if (options.dataViews[0].metadata.objects.viewMode?.defaultViewMode as string) {
|
||||
console.log(
|
||||
`Default View Mode: ${
|
||||
options.dataViews[0].metadata.objects.viewMode?.defaultViewMode as string
|
||||
}`
|
||||
)
|
||||
if (!visualStore.isViewerObjectsLoaded && options.dataViews[0].metadata.objects) {
|
||||
const defaultViewMode = options.dataViews[0].metadata.objects.viewMode?.defaultViewMode
|
||||
if (defaultViewMode) {
|
||||
console.log(`Default View Mode: ${defaultViewMode as string}`)
|
||||
|
||||
visualStore.setDefaultViewModeInFile(
|
||||
options.dataViews[0].metadata.objects.viewMode?.defaultViewMode as string
|
||||
)
|
||||
visualStore.setDefaultViewModeInFile(defaultViewMode as string)
|
||||
}
|
||||
|
||||
if (options.dataViews[0].metadata.objects.workspace?.brandingHidden as boolean) {
|
||||
console.log(
|
||||
`Branding Hidden: ${
|
||||
options.dataViews[0].metadata.objects.workspace?.brandingHidden as boolean
|
||||
}`
|
||||
)
|
||||
const brandingHidden = options.dataViews[0].metadata.objects.workspace?.brandingHidden
|
||||
if (brandingHidden !== undefined) {
|
||||
console.log(`Branding Hidden: ${brandingHidden as boolean}`)
|
||||
|
||||
visualStore.setBrandingHidden(
|
||||
options.dataViews[0].metadata.objects.workspace?.brandingHidden as boolean
|
||||
)
|
||||
visualStore.setBrandingHidden(brandingHidden as boolean)
|
||||
}
|
||||
|
||||
if (options.dataViews[0].metadata.objects.cameraPosition?.positionX as string) {
|
||||
console.log(`Stored camera position is found`)
|
||||
const navbarHidden = options.dataViews[0].metadata.objects.viewMode?.navbarHidden
|
||||
if (navbarHidden !== undefined) {
|
||||
console.log(`Navbar Hidden: ${navbarHidden as boolean}`)
|
||||
|
||||
visualStore.setNavbarHidden(navbarHidden as boolean)
|
||||
}
|
||||
|
||||
// Load edges settings
|
||||
const viewModeSettings = options.dataViews[0].metadata.objects.viewMode
|
||||
if (viewModeSettings) {
|
||||
if ('edgesEnabled' in viewModeSettings) {
|
||||
console.log(`Edges Enabled: ${viewModeSettings.edgesEnabled as boolean}`)
|
||||
visualStore.setEdgesEnabled(viewModeSettings.edgesEnabled as boolean)
|
||||
}
|
||||
if ('edgesWeight' in viewModeSettings) {
|
||||
console.log(`Edges Weight: ${viewModeSettings.edgesWeight as number}`)
|
||||
visualStore.setEdgesWeight(viewModeSettings.edgesWeight as number)
|
||||
}
|
||||
if ('edgesColor' in viewModeSettings) {
|
||||
const colorVal = viewModeSettings.edgesColor as number
|
||||
console.log(`Edges Color: ${colorVal}`)
|
||||
visualStore.setEdgesColor(colorVal === -1 ? 'auto' : colorVal)
|
||||
}
|
||||
}
|
||||
|
||||
const cameraPositionData = options.dataViews[0].metadata.objects.cameraPosition
|
||||
if (cameraPositionData?.positionX) {
|
||||
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),
|
||||
Number(options.dataViews[0].metadata.objects.cameraPosition?.positionZ),
|
||||
Number(options.dataViews[0].metadata.objects.cameraPosition?.targetX),
|
||||
Number(options.dataViews[0].metadata.objects.cameraPosition?.targetY),
|
||||
Number(options.dataViews[0].metadata.objects.cameraPosition?.targetZ)
|
||||
Number(cameraPositionData.positionX),
|
||||
Number(cameraPositionData.positionY),
|
||||
Number(cameraPositionData.positionZ),
|
||||
Number(cameraPositionData.targetX),
|
||||
Number(cameraPositionData.targetY),
|
||||
Number(cameraPositionData.targetZ)
|
||||
])
|
||||
}
|
||||
|
||||
const sectionBoxData = options.dataViews[0].metadata.objects.sectionBox?.boxData
|
||||
if (sectionBoxData) {
|
||||
console.log('Stored section box is found')
|
||||
visualStore.setSectionBoxData(sectionBoxData as string)
|
||||
}
|
||||
|
||||
const camera = options.dataViews[0].metadata.objects.camera
|
||||
|
||||
if (camera && 'isOrtho' in camera) {
|
||||
@@ -184,31 +231,71 @@ export class Visual implements IVisual {
|
||||
)
|
||||
}
|
||||
|
||||
// get receive info from file for mixpanel
|
||||
if (camera && 'zoomOnFilter' in camera) {
|
||||
console.log(
|
||||
`Zoom on filter?: ${
|
||||
options.dataViews[0].metadata.objects.camera?.zoomOnFilter as boolean
|
||||
}`
|
||||
)
|
||||
|
||||
visualStore.setIsZoomOnFilterActive(
|
||||
options.dataViews[0].metadata.objects.camera?.zoomOnFilter as boolean
|
||||
)
|
||||
}
|
||||
|
||||
// Log persisted data loading setting but don't force sync
|
||||
if (
|
||||
options.dataViews[0].metadata.objects.dataLoading?.internalizeData !== undefined
|
||||
) {
|
||||
console.log(
|
||||
`Stored Data Loading - Internalize Data: ${
|
||||
options.dataViews[0].metadata.objects.dataLoading?.internalizeData as boolean
|
||||
}`
|
||||
)
|
||||
}
|
||||
|
||||
// get receive info from file for persistence
|
||||
try {
|
||||
const receiveInfoFromFile = JSON.parse(
|
||||
options.dataViews[0].metadata.objects.storedData?.receiveInfo as string
|
||||
) as ReceiveInfo
|
||||
visualStore.setReceiveInfo(receiveInfoFromFile)
|
||||
// Don't call setReceiveInfo here as it would trigger another save
|
||||
visualStore.receiveInfo = receiveInfoFromFile
|
||||
} catch (error) {
|
||||
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)
|
||||
console.log('missing stored receive info')
|
||||
}
|
||||
}
|
||||
|
||||
// Check for internalized data
|
||||
const internalizedData = options.dataViews[0].metadata.objects?.storedData
|
||||
?.speckleObjects as string
|
||||
|
||||
const input = await processMatrixView(
|
||||
matrixView,
|
||||
this.host,
|
||||
validationResult.colorBy,
|
||||
this.formattingSettings,
|
||||
(obj, id) => this.selectionHandler.set(obj, id)
|
||||
(obj, id) => this.selectionHandler.set(obj, id),
|
||||
internalizedData
|
||||
)
|
||||
this.updateViewer(input)
|
||||
|
||||
// Auto-internalize new API data if toggle is ON and this is fresh data (not from store)
|
||||
// Imagine that user has a visual and select internalizing data and changes the data source
|
||||
// This will automatically internalize the new data
|
||||
if (
|
||||
this.formattingSettings.dataLoading.internalizeData.value &&
|
||||
input.modelObjects &&
|
||||
input.modelObjects.length > 0 &&
|
||||
!input.isFromStore
|
||||
) {
|
||||
console.log('📦 Auto-internalizing new API data since toggle is ON')
|
||||
// Trigger internalization after objects are loaded
|
||||
setTimeout(() => {
|
||||
this.internalizeCurrentViewerData()
|
||||
}, 2000) // avoid a race condition (i know)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Data update error', error ?? 'Unknown')
|
||||
}
|
||||
@@ -236,9 +323,8 @@ export class Visual implements IVisual {
|
||||
}
|
||||
|
||||
public getFormattingModel(): powerbi.visuals.FormattingModel {
|
||||
console.log('Showing Formatting settings', this.formattingSettings)
|
||||
console.log('🎨 getFormattingModel called')
|
||||
const model = this.formattingSettingsService.buildFormattingModel(this.formattingSettings)
|
||||
console.log('Formatting model was created', model)
|
||||
return model
|
||||
}
|
||||
|
||||
@@ -254,14 +340,13 @@ export class Visual implements IVisual {
|
||||
// we should give some time to Vue to render ViewerWrapper component to be able to have proper emitter setup. Happiness level 6/10
|
||||
setTimeout(() => {
|
||||
visualStore.setDataInput(input)
|
||||
// visualStore.writeObjectsToFile(input.objects)
|
||||
}, 250)
|
||||
}
|
||||
}
|
||||
|
||||
private tryReadFromFile(objectsFromFile: object[][], visualStore) {
|
||||
visualStore.setViewerReadyToLoad(true)
|
||||
visualStore.setIsLoadingFromFile(true) // to block unnecessary streaming data if bg service is running
|
||||
visualStore.setIsLoadingFromFile(true)
|
||||
setTimeout(() => {
|
||||
visualStore.loadObjectsFromFile(objectsFromFile)
|
||||
this.isFirstViewerLoad = false
|
||||
@@ -269,6 +354,94 @@ export class Visual implements IVisual {
|
||||
console.log(`${objectsFromFile.length} objects retrieved from persistent properties!`)
|
||||
}
|
||||
|
||||
private async internalizeCurrentViewerData() {
|
||||
const visualStore = useVisualStore()
|
||||
|
||||
// Get the current root object ID from the last loaded data
|
||||
if (!visualStore.lastLoadedRootObjectId) {
|
||||
console.log('📁 No root object ID to internalize')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('📁 Starting internalization via Speckle API...')
|
||||
|
||||
visualStore.setLoadingProgress('📦 Internalizing data...', null)
|
||||
|
||||
// Get credentials from visualStore (already loaded from encoded data)
|
||||
const token = visualStore.receiveInfo?.token
|
||||
const serverUrl = visualStore.receiveInfo?.serverUrl
|
||||
const projectId = visualStore.receiveInfo?.projectId
|
||||
const rootObjectIds = visualStore.lastLoadedRootObjectId
|
||||
|
||||
if (!token || !serverUrl || !projectId) {
|
||||
console.error('📁 Missing credentials for internalization')
|
||||
visualStore.clearLoadingProgress()
|
||||
return
|
||||
}
|
||||
|
||||
// Handle federated models by processing each object ID separately
|
||||
const objectIds = rootObjectIds.split(',')
|
||||
let allObjects = []
|
||||
|
||||
for (const objectId of objectIds) {
|
||||
console.log(`📁 Downloading objects for ID: ${objectId}`)
|
||||
|
||||
const loader = new SpeckleApiLoader(serverUrl, projectId, token)
|
||||
const objects = await loader.downloadObjectsWithChildren(objectId)
|
||||
|
||||
console.log(`📁 Downloaded ${objects.length} objects for ID ${objectId}`)
|
||||
allObjects.push(objects)
|
||||
}
|
||||
|
||||
if (allObjects.length === 0 || allObjects.every((arr) => arr.length === 0)) {
|
||||
console.error('📁 No objects retrieved from Speckle API')
|
||||
visualStore.clearLoadingProgress()
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`📁 Retrieved ${allObjects.reduce((sum, arr) => sum + arr.length, 0)} total objects from Speckle API`)
|
||||
|
||||
// Use existing writeObjectsToFile method from visualStore
|
||||
// allObjects is already in the format object[][] expected by viewer
|
||||
visualStore.writeObjectsToFile(allObjects)
|
||||
|
||||
// Clear loading message immediately when done
|
||||
visualStore.clearLoadingProgress()
|
||||
|
||||
console.log('📁 Successfully internalized data via Speckle API!')
|
||||
} catch (error) {
|
||||
console.error('📁 Failed to internalize via Speckle API:', error)
|
||||
|
||||
// Clear loading message immediately on error
|
||||
visualStore.clearLoadingProgress()
|
||||
}
|
||||
}
|
||||
|
||||
private removeInternalizedData() {
|
||||
const visualStore = useVisualStore()
|
||||
|
||||
try {
|
||||
// Clear stored data from PowerBI file
|
||||
this.host.persistProperties({
|
||||
merge: [
|
||||
{
|
||||
objectName: 'storedData',
|
||||
properties: {
|
||||
speckleObjects: null,
|
||||
receiveInfo: null
|
||||
},
|
||||
selector: null
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
console.log('🗑️ Successfully removed internalized data from file!')
|
||||
} catch (error) {
|
||||
console.error('🗑️ Failed to remove internalized data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy() {
|
||||
await this.clear()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user