Compare commits

...

14 Commits

Author SHA1 Message Date
Dogukan Karatas e0f4a4c02c Merge pull request #192 from specklesystems/dogukan/send-versionId-to-server
Build and deploy Connector and Visual / build-connector (push) Has been cancelled
Build and deploy Connector and Visual / build-visual (push) Has been cancelled
Build and deploy Connector and Visual / deploy-installers (push) Has been cancelled
feat (data): send `versionId` to the server
2025-07-29 10:14:11 +02:00
Dogukan Karatas 29773f9492 versionId added 2025-07-28 16:28:53 +02:00
Oğuzhan Koral 634df47a25 Revert "Merge pull request #186 from specklesystems/dogukan/cnx-2136-tag-as-markreceived-after-download-in-dataconnector" (#189)
Build and deploy Connector and Visual / build-connector (push) Has been cancelled
Build and deploy Connector and Visual / build-visual (push) Has been cancelled
Build and deploy Connector and Visual / deploy-installers (push) Has been cancelled
This reverts commit ffc0d8ef5e, reversing
changes made to c8d858d575.

Co-authored-by: bimgeek <mucahitbgoker@gmail.com>
2025-07-22 08:02:27 +01:00
Mucahit Bilal GOKER 9ad59bf1d3 Bilal/cnx 2115 configure oauth2 for data gateway usage (#183)
Build and deploy Connector and Visual / build-connector (push) Has been cancelled
Build and deploy Connector and Visual / build-visual (push) Has been cancelled
Build and deploy Connector and Visual / deploy-installers (push) Has been cancelled
* oauth2 implementation

* change code challenge to plain

* Reorder error checks

* implement proper PKCE security with SHA256
2025-07-21 16:56:44 +01:00
Mucahit Bilal GOKER ffc0d8ef5e Merge pull request #186 from specklesystems/dogukan/cnx-2136-tag-as-markreceived-after-download-in-dataconnector
fix: mark stream as received
2025-07-21 15:59:26 +03:00
Mucahit Bilal GOKER 94c80857a0 Merge branch 'dev' into dogukan/cnx-2136-tag-as-markreceived-after-download-in-dataconnector 2025-07-21 14:47:07 +03:00
Mucahit Bilal GOKER c8d858d575 Merge pull request #185 from specklesystems/dogukan/cnx-1933-disable-selection-of-ghosted-objects
fix (visual): disable selection of ghosted objects
2025-07-21 14:46:54 +03:00
Mucahit Bilal GOKER 36b9787b66 Merge branch 'dev' into dogukan/cnx-1933-disable-selection-of-ghosted-objects 2025-07-21 14:44:23 +03:00
Mucahit Bilal GOKER bde7a42c44 Merge branch 'dev' into dogukan/cnx-2136-tag-as-markreceived-after-download-in-dataconnector 2025-07-21 14:36:24 +03:00
Mucahit Bilal GOKER 1040f4622d Bilal/cnx 2033 update readme (#184)
* update readme

* remove button styling

* added code

* Update README.md

* Update README.md
2025-07-21 14:23:33 +03:00
Dogukan Karatas 91e799d006 mark received function added 2025-07-16 15:45:44 +02:00
Dogukan Karatas 8694666874 remove legacy implementation 2025-07-14 15:02:46 +02:00
Dogukan Karatas fa6ad8ec40 extended filtered selection 2025-07-11 17:04:01 +02:00
Dogukan Karatas 8e60249291 update viewer for ghosted objects 2025-07-11 15:27:29 +02:00
8 changed files with 484 additions and 136 deletions
+60 -26
View File
@@ -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&amp;style=flat-square&amp;logo=discourse&amp;logoColor=white" alt="Community forum users"></a> <a href="https://speckle.systems"><img src="https://img.shields.io/badge/https://-speckle.systems-royalblue?style=flat-square" alt="website"></a> <a href="https://speckle.guide/dev/"><img src="https://img.shields.io/badge/docs-speckle.guide-orange?style=flat-square&amp;logo=read-the-docs&amp;logoColor=white" alt="docs"></a></p>
<p align="center"><a href="https://twitter.com/SpeckleSystems"><img src="https://img.shields.io/twitter/follow/SpeckleSystems?style=social" alt="Twitter Follow"></a> <a href="https://speckle.community"><img src="https://img.shields.io/discourse/users?server=https%3A%2F%2Fspeckle.community&amp;style=flat-square&amp;logo=discourse&amp;logoColor=white" alt="Community forum users"></a> <a href="https://speckle.systems"><img src="https://img.shields.io/badge/https://-speckle.systems-royalblue?style=flat-square" alt="website"></a> <a href="https://docs.speckle.systems/"><img src="https://img.shields.io/badge/docs-speckle.systems-orange?style=flat-square&amp;logo=read-the-docs&amp;logoColor=white" alt="docs"></a></p>
> 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>
Speckles connection to Power BI consists of two parts:
- **Data Connector** fetches the data you uploaded from AEC apps to Speckle.
@@ -22,19 +30,19 @@ Speckles connection to Power BI consists of two parts:
![Desktop - 1 (1)](https://github.com/specklesystems/speckle-powerbi/assets/51519350/6d2c5224-965f-4eae-b869-be26cb48c6b2)
# 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 ![YouTube Video Views](https://img.shields.io/youtube/views/B9humiSpHzM?label=Speckle%20in%201%20minute%20video&style=social)
@@ -91,12 +127,10 @@ What is Speckle? Check our ![YouTube Video Views](https://img.shields.io/youtube
Give Speckle a try in no time by:
- [![app.speckle.systems](https://img.shields.io/badge/https://-speckle.xyz-0069ff?style=flat-square&logo=hackthebox&logoColor=white)](https://app.speckle.systems) ⇒ creating an account at our public server
- [![app.speckle.systems](https://img.shields.io/badge/https://-app.speckle.systems-0069ff?style=flat-square&logo=hackthebox&logoColor=white)](https://app.speckle.systems) ⇒ creating an account at our public server
### Resources
- [![Community forum users](https://img.shields.io/badge/community-forum-green?style=for-the-badge&logo=discourse&logoColor=white)](https://speckle.community) for help, feature requests or just to hang with other speckle enthusiasts, check out our community forum!
- [![website](https://img.shields.io/badge/tutorials-speckle.systems-royalblue?style=for-the-badge&logo=youtube)](https://speckle.systems) our tutorials portal is full of resources to get you started using Speckle
- [![docs](https://img.shields.io/badge/docs-speckle.guide-orange?style=for-the-badge&logo=read-the-docs&logoColor=white)](https://speckle.guide/dev/) reference on almost any end-user and developer functionality
![Untitled](https://user-images.githubusercontent.com/2679513/132021739-15140299-624d-4410-98dc-b6ae6d9027ab.png)
- [![docs](https://img.shields.io/badge/docs-speckle.systems-orange?style=for-the-badge&logo=read-the-docs&logoColor=white)](https://docs.speckle.systems) reference on almost any end-user and developer functionality
+129 -21
View File
@@ -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)
}
}
+43 -48
View File
@@ -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 -12
View File
@@ -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'))