Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0f4a4c02c | |||
| 29773f9492 | |||
| 634df47a25 | |||
| 9ad59bf1d3 | |||
| ffc0d8ef5e | |||
| 94c80857a0 | |||
| c8d858d575 | |||
| 36b9787b66 | |||
| bde7a42c44 | |||
| 1040f4622d | |||
| 91e799d006 | |||
| 8694666874 | |||
| fa6ad8ec40 | |||
| 8e60249291 |
@@ -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.Ascii)),
|
||||
// Convert to base64url encoding
|
||||
challenge = Base64UrlEncode(hash)
|
||||
in
|
||||
challenge;
|
||||
|
||||
// function to load `pqm` files - this is essential and must be kept
|
||||
shared Speckle.LoadFunction = (fileName as text) =>
|
||||
let
|
||||
@@ -177,14 +250,21 @@ Speckle = [
|
||||
let
|
||||
server = Text.Combine(
|
||||
{Uri.Parts(dataSourcePath)[Scheme], "://", Uri.Parts(dataSourcePath)[Host]}
|
||||
)
|
||||
),
|
||||
// Generate PKCE parameters for enhanced security
|
||||
codeVerifier = GeneratePKCEVerifier(),
|
||||
codeChallenge = GeneratePKCEChallenge(codeVerifier),
|
||||
// Build authorization URL with PKCE parameters
|
||||
authUrl = Text.Combine({server, "authn", "verify", AuthAppId, state}, "/") &
|
||||
"?code_challenge=" & codeChallenge &
|
||||
"&code_challenge_method=S256"
|
||||
in
|
||||
[
|
||||
LoginUri = 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]
|
||||
],
|
||||
FinishLogin = (clientApplication, dataSourcePath, context, callbackUri, state) =>
|
||||
let
|
||||
@@ -192,20 +272,22 @@ Speckle = [
|
||||
{Uri.Parts(dataSourcePath)[Scheme], "://", Uri.Parts(dataSourcePath)[Host]}
|
||||
),
|
||||
Parts = Uri.Parts(callbackUri)[Query],
|
||||
// Extract code verifier from context for PKCE
|
||||
codeVerifier = if context <> null then context[code_verifier] else null,
|
||||
// Build token request with PKCE parameters
|
||||
tokenRequest = [
|
||||
accessCode = Parts[access_code],
|
||||
appId = AuthAppId,
|
||||
appSecret = AuthAppSecret,
|
||||
challenge = state
|
||||
] & (if codeVerifier <> null then [code_verifier = codeVerifier] else []),
|
||||
Source = Web.Contents(
|
||||
Text.Combine({server, "auth", "token"}, "/"),
|
||||
[
|
||||
Headers = [
|
||||
#"Content-Type" = "application/json"
|
||||
],
|
||||
Content = Json.FromValue(
|
||||
[
|
||||
accessCode = Parts[access_code],
|
||||
appId = AuthAppId,
|
||||
appSecret = AuthAppSecret,
|
||||
challenge = state
|
||||
]
|
||||
)
|
||||
Content = Json.FromValue(tokenRequest)
|
||||
]
|
||||
),
|
||||
json = Json.Document(Source)
|
||||
@@ -221,7 +303,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 = [
|
||||
@@ -233,17 +316,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]
|
||||
]
|
||||
result
|
||||
],
|
||||
Key = [
|
||||
KeyLabel = "Personal Access Token",
|
||||
|
||||
@@ -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()[Key] otherwise 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
|
||||
@@ -47,6 +47,7 @@
|
||||
SourceApplication = modelInfo[sourceApplication],
|
||||
Token = apiKey,
|
||||
Version = connectorVersion,
|
||||
VersionId = parsedUrl[versionId],
|
||||
WorkspaceId = workspaceInfo[workspaceId],
|
||||
WorkspaceName = workspaceInfo[workspaceName],
|
||||
WorkspaceLogo = workspaceInfo[workspaceLogo],
|
||||
|
||||
@@ -162,6 +162,10 @@ onMounted(async () => {
|
||||
console.log('Viewer Wrapper mounted')
|
||||
viewerHandler = new ViewerHandler()
|
||||
await viewerHandler.init(container.value)
|
||||
|
||||
// Set up event listener for object clicks from the FilteredSelectionExtension
|
||||
viewerHandler.emitter.on('objectClicked', handleObjectClicked)
|
||||
|
||||
visualStore.setViewerEmitter(viewerHandler.emit)
|
||||
})
|
||||
|
||||
@@ -169,43 +173,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,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,7 +1,6 @@
|
||||
import {
|
||||
DefaultViewerParams,
|
||||
FilteringState,
|
||||
IntersectionQuery,
|
||||
CameraController,
|
||||
CanonicalView,
|
||||
ViewModes,
|
||||
@@ -13,8 +12,10 @@ 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'
|
||||
@@ -53,6 +54,7 @@ export interface IViewerEvents {
|
||||
toggleGhostHidden: (ghost: boolean) => void
|
||||
loadObjects: (objects: object[]) => void
|
||||
objectsLoaded: () => void
|
||||
objectClicked: (hit: Hit | null, isMultiSelect: boolean, mouseEvent?: PointerEvent) => void
|
||||
}
|
||||
|
||||
export type ColorBy = {
|
||||
@@ -65,7 +67,7 @@ export class ViewerHandler {
|
||||
public viewer: Viewer
|
||||
public cameraControls: CameraController
|
||||
public filtering: FilteringExtension
|
||||
public selection: SelectionExtension
|
||||
public selection: FilteredSelectionExtension
|
||||
private filteringState: FilteringState
|
||||
|
||||
constructor() {
|
||||
@@ -91,7 +93,7 @@ export class ViewerHandler {
|
||||
this.viewer = await createViewer(parent)
|
||||
this.cameraControls = this.viewer.getExtension(CameraController)
|
||||
this.filtering = this.viewer.getExtension(FilteringExtension)
|
||||
this.selection = this.viewer.getExtension(SelectionExtension)
|
||||
this.selection = this.viewer.getExtension(FilteredSelectionExtension)
|
||||
|
||||
const store = useVisualStore()
|
||||
if (store.isOrthoProjection) {
|
||||
@@ -101,6 +103,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 {
|
||||
@@ -198,25 +208,7 @@ export class ViewerHandler {
|
||||
}
|
||||
}
|
||||
|
||||
public intersect = (coords: { x: number; y: number }) => {
|
||||
const point = this.viewer.Utils.screenToNDC(coords.x, coords.y)
|
||||
|
||||
const intQuery: IntersectionQuery = {
|
||||
operation: 'Pick',
|
||||
point
|
||||
}
|
||||
|
||||
const res = this.viewer.query(intQuery)
|
||||
|
||||
if (!res) {
|
||||
this.selection.clearSelection()
|
||||
return
|
||||
}
|
||||
return {
|
||||
hit: this.pickViewableHit(res.objects),
|
||||
objects: res.objects
|
||||
}
|
||||
}
|
||||
|
||||
public loadObjects = async (modelObjects: object[][]) => {
|
||||
await this.viewer.unloadAll()
|
||||
@@ -264,7 +256,7 @@ export class ViewerHandler {
|
||||
)
|
||||
this.cameraControls.setCameraView({ position, target }, true)
|
||||
}
|
||||
|
||||
|
||||
// Emit objects loaded event to trigger update
|
||||
this.emit('objectsLoaded')
|
||||
}
|
||||
@@ -280,32 +272,35 @@ export class ViewerHandler {
|
||||
store.handleObjectsLoadedComplete()
|
||||
}
|
||||
|
||||
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 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()
|
||||
@@ -321,11 +316,11 @@ const createViewer = async (parent: HTMLElement): Promise<Viewer> => {
|
||||
await viewer.init()
|
||||
|
||||
viewer.createExtension(HybridCameraController) // camera controller
|
||||
viewer.createExtension(SelectionExtension) // selection helper
|
||||
viewer.createExtension(FilteringExtension) // filtering - must be created before FilteredSelectionExtension
|
||||
viewer.createExtension(FilteredSelectionExtension) // filtered selection helper - depends on FilteringExtension
|
||||
// viewer.createExtension(SectionTool) // section tool, possibly not needed for now?
|
||||
// viewer.createExtension(SectionOutlines) // section tool, possibly not needed for now?
|
||||
// viewer.createExtension(MeasurementsExtension) // measurements, possibly not needed for now?
|
||||
viewer.createExtension(FilteringExtension) // filtering
|
||||
viewer.createExtension(ViewModes) // view modes
|
||||
|
||||
console.log('🎥 Viewer is created!')
|
||||
|
||||
@@ -1,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'))
|
||||
|
||||
Reference in New Issue
Block a user