From 3044c6b3429650e53ace49ec926aad618b5d2118 Mon Sep 17 00:00:00 2001 From: Benjamin Ottensten Date: Tue, 29 Jul 2025 13:05:34 +0200 Subject: [PATCH 1/5] Snap section box rotation to nearest 15 degrees (#5157) * Rotate by 15 degrees when shift key is pressed * Move consts outside * Make snap angle a configurable option * Add comment about support for snapped rotation --- .../extensions/sections/SectionTool.ts | 96 +++++++++++++++++-- 1 file changed, 90 insertions(+), 6 deletions(-) diff --git a/packages/viewer/src/modules/extensions/sections/SectionTool.ts b/packages/viewer/src/modules/extensions/sections/SectionTool.ts index c3ac87f1d..6f26a17c7 100644 --- a/packages/viewer/src/modules/extensions/sections/SectionTool.ts +++ b/packages/viewer/src/modules/extensions/sections/SectionTool.ts @@ -20,7 +20,8 @@ import { DynamicDrawUsage, Color, MeshBasicMaterial, - PlaneGeometry + PlaneGeometry, + Euler } from 'three' import { intersectObjectWithRay, TransformControls } from '../TransformControls.js' import { OBB } from 'three/examples/jsm/math/OBB.js' @@ -56,6 +57,8 @@ export interface SectionToolEventPayload { const _matrix4 = new Matrix4() const _quaternion = new Quaternion() const _vector3 = new Vector3() +const _tempEuler = new Euler() +const _tempQuaternion = new Quaternion() const unitCube = [ -1 * 0.5, @@ -133,6 +136,14 @@ export class SectionTool extends Extension { return [CameraController] } + /** Configurable rotation snap angle in radians. Set to null to disable snapping */ + public rotationSnapAngle: number | null = Math.PI / 12 // 15 degrees by default + + /** Note: Rotation snapping only applies to mouse interactions via TransformControls. + * Programmatic calls to setBox() will not apply rotation snapping. + * For complete snapping support, programmatic rotations will need to be handled separately. + */ + /** This is our data model. All we need is an OBB */ protected obb: OBB = new OBB() @@ -199,6 +210,9 @@ export class SectionTool extends Extension { /** Hit testing related */ protected raycaster: Raycaster protected dragging = false + protected shiftKeyPressed = false + protected keydownHandler: (e: KeyboardEvent) => void + protected keyupHandler: (e: KeyboardEvent) => void /** Manadatory property for all extensions */ public get enabled() { @@ -317,6 +331,9 @@ export class SectionTool extends Extension { /** Hook up to le click */ this.viewer.getRenderer().input.on(InputEvent.Click, this.clickHandler.bind(this)) + /** Add keyboard event listeners for shift key rotation snapping */ + this.setupKeyboardListeners() + /** Start off disabled */ this.enabled = false } @@ -514,6 +531,31 @@ export class SectionTool extends Extension { ) } + /** + * Sets up keyboard event listeners for shift key rotation snapping + */ + protected setupKeyboardListeners() { + /** Store shift state for use in changeHandler */ + this.shiftKeyPressed = false + + /** Store references to event listeners for cleanup */ + this.keydownHandler = (e: KeyboardEvent) => { + if (e.shiftKey && !this.shiftKeyPressed) { + this.shiftKeyPressed = true + } + } + + this.keyupHandler = (e: KeyboardEvent) => { + if (!e.shiftKey && this.shiftKeyPressed) { + this.shiftKeyPressed = false + } + } + + /** Event listeners */ + document.addEventListener('keydown', this.keydownHandler) + document.addEventListener('keyup', this.keyupHandler) + } + /** * Controls, outline and hitbox update based on the OBB model */ @@ -574,12 +616,17 @@ export class SectionTool extends Extension { protected changeHandler() { /** Just copy over position, rotation and scale*/ this.obb.center.copy(this.translationRotationAnchor.position) + + /** Apply rotation snapping if shift key is pressed */ + let quaternion = this.translationRotationAnchor.quaternion + if (this.shiftKeyPressed) { + quaternion = this.snapQuaternionToGrid(quaternion) + /** Update the anchor's quaternion to keep visual controls in sync */ + this.translationRotationAnchor.quaternion.copy(quaternion) + } + this.obb.rotation.copy( - new Matrix3().setFromMatrix4( - new Matrix4().makeRotationFromQuaternion( - this.translationRotationAnchor.quaternion - ) - ) + new Matrix3().setFromMatrix4(new Matrix4().makeRotationFromQuaternion(quaternion)) ) this.obb.halfSize.copy(this.scaleAnchor.scale) @@ -920,4 +967,41 @@ export class SectionTool extends Extension { ): box is OBB { return box instanceof OBB } + + /** + * Snaps a quaternion to the nearest grid based on rotationSnapAngle. + * This is useful for rotation snapping. + * @param q The quaternion to snap. + * @returns The snapped quaternion. + */ + protected snapQuaternionToGrid(q: Quaternion): Quaternion { + /** Convert quaternion to Euler angles using pooled object */ + _tempEuler.setFromQuaternion(q) + + /** Snap each axis to the configured angle increments */ + if (this.rotationSnapAngle !== null) { + _tempEuler.x = + Math.round(_tempEuler.x / this.rotationSnapAngle) * this.rotationSnapAngle + _tempEuler.y = + Math.round(_tempEuler.y / this.rotationSnapAngle) * this.rotationSnapAngle + _tempEuler.z = + Math.round(_tempEuler.z / this.rotationSnapAngle) * this.rotationSnapAngle + } + + /** Convert back to quaternion using pooled object */ + _tempQuaternion.setFromEuler(_tempEuler) + return _tempQuaternion + } + + /** + * Cleanup method to remove event listeners and prevent memory leaks + */ + public dispose() { + if (this.keydownHandler) { + document.removeEventListener('keydown', this.keydownHandler) + } + if (this.keyupHandler) { + document.removeEventListener('keyup', this.keyupHandler) + } + } } From 4579ec710911e118c9df0d1fa2bf001e3127b747 Mon Sep 17 00:00:00 2001 From: Benjamin Ottensten Date: Wed, 30 Jul 2025 11:56:25 +0200 Subject: [PATCH 2/5] Feat: Support for undo/redo in section tool (#5161) * Allow undoing rotations * Allow redoing undos * Improve how the very first rotation is stored * Track the history of any section box edit * Also update section outlines when undoing/redoing * Increase how much history we store * Start initial index at 0 * Rewrite some comments * Use existing OBB class instead * Get rid of fudge * Only support undo/redo when section tool is visible * Update naming --- .../extensions/sections/SectionTool.ts | 141 +++++++++++++++++- 1 file changed, 138 insertions(+), 3 deletions(-) diff --git a/packages/viewer/src/modules/extensions/sections/SectionTool.ts b/packages/viewer/src/modules/extensions/sections/SectionTool.ts index 6f26a17c7..2e92f72df 100644 --- a/packages/viewer/src/modules/extensions/sections/SectionTool.ts +++ b/packages/viewer/src/modules/extensions/sections/SectionTool.ts @@ -40,6 +40,7 @@ import SpeckleLineMaterial from '../../materials/SpeckleLineMaterial.js' import { LineSegments2 } from 'three/examples/jsm/lines/LineSegments2.js' import SpeckleStandardMaterial from '../../materials/SpeckleStandardMaterial.js' import { Extension } from '../Extension.js' +import { SectionOutlines } from './SectionOutlines.js' export enum SectionToolEvent { DragStart = 'section-box-drag-start', @@ -213,6 +214,9 @@ export class SectionTool extends Extension { protected shiftKeyPressed = false protected keydownHandler: (e: KeyboardEvent) => void protected keyupHandler: (e: KeyboardEvent) => void + protected sectionBoxHistory: OBB[] = [] + protected currentHistoryIndex = 0 + protected maxHistorySize = 100 /** Manadatory property for all extensions */ public get enabled() { @@ -532,7 +536,50 @@ export class SectionTool extends Extension { } /** - * Sets up keyboard event listeners for shift key rotation snapping + * Creates an OBB state from the current OBB + */ + protected createObbState(): OBB { + return new OBB().copy(this.obb) + } + + /** + * Applies an OBB state to the current OBB + */ + protected applyObbState(state: OBB): void { + this.obb.copy(state) + } + + /** + * Saves the current section box state to history + */ + protected saveToHistory(): void { + const currentState = this.createObbState() + + /** If we're not at the latest state and make a new change, remove all future states */ + if ( + this.currentHistoryIndex < this.sectionBoxHistory.length - 1 && + this.sectionBoxHistory.length > 1 + ) { + /** Keep the initial state and all states up to the current position */ + this.sectionBoxHistory = this.sectionBoxHistory.slice( + 0, + this.currentHistoryIndex + 1 + ) + } + + /** Add current state to history */ + this.sectionBoxHistory.push(currentState) + this.currentHistoryIndex = this.sectionBoxHistory.length - 1 + + /** Remove oldest states if we exceed the history limit */ + if (this.sectionBoxHistory.length > this.maxHistorySize) { + this.sectionBoxHistory.shift() + this.currentHistoryIndex = Math.max(0, this.currentHistoryIndex - 1) + } + } + + /** + * Sets up keyboard event listeners for shift key rotation snapping and undo/redo */ protected setupKeyboardListeners() { /** Store shift state for use in changeHandler */ @@ -543,6 +590,24 @@ export class SectionTool extends Extension { if (e.shiftKey && !this.shiftKeyPressed) { this.shiftKeyPressed = true } + + /** Handle Cmd/Ctrl+Z for section box undo */ + if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) { + /** Only allow undo/redo when section box controls are visible */ + if (this.enabled && this.visible) { + e.preventDefault() + this.undoSectionBox() + } + } + + /** Handle Cmd/Ctrl+Shift+Z for section box redo */ + if ((e.metaKey || e.ctrlKey) && e.key === 'z' && e.shiftKey) { + /** Only allow undo/redo when section box controls are visible */ + if (this.enabled && this.visible) { + e.preventDefault() + this.redoSectionBox() + } + } } this.keyupHandler = (e: KeyboardEvent) => { @@ -583,18 +648,27 @@ export class SectionTool extends Extension { } /** - * Triggers when dragging starts/stops + * Triggers when transform interactions start/stop * @param event Controls event */ //@ts-ignore protected draggingHandler(event) { this.dragging = event.value if (this.dragging) { + /** Save initial state when interaction starts (if this is the first change) */ + if (this.sectionBoxHistory.length === 0) { + this.sectionBoxHistory.push(this.createObbState()) + this.currentHistoryIndex = 0 + } + this.cameraProvider.enabled = false if (event.target === this.translateControls) this.rotateControls.detach() else if (event.target === this.rotateControls) this.translateControls.detach() this.emit(SectionToolEvent.DragStart) } else { + /** Save final state when interaction ends */ + this.saveToHistory() + this.cameraProvider.enabled = true if (event.target === this.translateControls) this.rotateControls.attach(this.translationRotationAnchor) @@ -614,7 +688,7 @@ export class SectionTool extends Extension { */ //@ts-ignore protected changeHandler() { - /** Just copy over position, rotation and scale*/ + /** Just copy over position, rotation and scale*/ this.obb.center.copy(this.translationRotationAnchor.position) /** Apply rotation snapping if shift key is pressed */ @@ -993,6 +1067,67 @@ export class SectionTool extends Extension { return _tempQuaternion } + /** + * Undoes the last section box change + */ + protected undoSectionBox() { + if (this.currentHistoryIndex > 0) { + /** Move cursor back */ + this.currentHistoryIndex-- + + /** Get the state at current cursor position */ + const previousState = this.sectionBoxHistory[this.currentHistoryIndex] + + if (previousState) { + /** Apply the previous state */ + this.applyObbState(previousState) + + /** Update visual state */ + this.updatePlanes() + this.updateVisual() + this.updateFaceControls(this.draggingFace) + + /** Update section outlines */ + const sectionOutlines = this.viewer.getExtension(SectionOutlines) + if (sectionOutlines && sectionOutlines.enabled) { + sectionOutlines.requestUpdate(true) + } + + this.viewer.requestRender() + } + } + } + + /** + * Redoes the last undone section box change + */ + protected redoSectionBox() { + if (this.currentHistoryIndex < this.sectionBoxHistory.length - 1) { + /** Move cursor forward */ + this.currentHistoryIndex++ + + /** Get the state at current cursor position */ + const nextState = this.sectionBoxHistory[this.currentHistoryIndex] + if (nextState) { + /** Apply the next state */ + this.applyObbState(nextState) + + /** Update visual state */ + this.updatePlanes() + this.updateVisual() + this.updateFaceControls(this.draggingFace) + + /** Update section outlines */ + const sectionOutlines = this.viewer.getExtension(SectionOutlines) + if (sectionOutlines && sectionOutlines.enabled) { + sectionOutlines.requestUpdate(true) + } + + this.viewer.requestRender() + } + } + } + /** * Cleanup method to remove event listeners and prevent memory leaks */ From e3d0c5aab311c83fc60c2d7a221772cfc7da736c Mon Sep 17 00:00:00 2001 From: Chuck Driesler Date: Wed, 30 Jul 2025 13:04:59 +0100 Subject: [PATCH 3/5] fix(tests): soften flaky test (#5164) * fix(tests): soften flaky test * fix(tests): use different syntax --- .../modules/core/tests/integration/subs.graph.spec.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/server/modules/core/tests/integration/subs.graph.spec.ts b/packages/server/modules/core/tests/integration/subs.graph.spec.ts index ee7121c6a..501f92814 100644 --- a/packages/server/modules/core/tests/integration/subs.graph.spec.ts +++ b/packages/server/modules/core/tests/integration/subs.graph.spec.ts @@ -431,8 +431,13 @@ describe('Core GraphQL Subscriptions (New)', () => { onUserStreamAdded.waitForMessage() ]) - expect(onUserProjectsUpdated.getMessages()).to.have.lengthOf(2) - expect(onUserStreamAdded.getMessages()).to.have.lengthOf(2) + const projectSubs = onUserProjectsUpdated.getMessages() + expect(projectSubs.length).to.be.gte(1) + expect(projectSubs.length).to.be.lte(2) + + const userSubs = onUserStreamAdded.getMessages() + expect(userSubs.length).to.be.gte(1) + expect(userSubs.length).to.be.lte(2) }) it('should notify me of a project ive just been added to (userProjectsUpdated/userStreamAdded)', async () => { From 174eef221d8d2862687734be040723dbb0a9b880 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= <57442769+gjedlicska@users.noreply.github.com> Date: Wed, 30 Jul 2025 19:22:15 +0200 Subject: [PATCH 4/5] feat(helm): add the rhino file importer feature flag to the chart (#5166) * feat(helm): add the rhino file importer feature flag to the chart * fix(ifc-importer): make colorfull available in the app --- packages/ifc-import-service/pyproject.toml | 54 +++++++++---------- packages/ifc-import-service/uv.lock | 2 + .../speckle-server/templates/_helpers.tpl | 6 +++ utils/helm/speckle-server/values.schema.json | 5 ++ utils/helm/speckle-server/values.yaml | 2 + 5 files changed, 40 insertions(+), 29 deletions(-) diff --git a/packages/ifc-import-service/pyproject.toml b/packages/ifc-import-service/pyproject.toml index e787505cf..a0124693e 100644 --- a/packages/ifc-import-service/pyproject.toml +++ b/packages/ifc-import-service/pyproject.toml @@ -5,44 +5,40 @@ description = "Speckle IFC importer worker app" readme = "README.md" requires-python = ">=3.12" dependencies = [ - "asyncpg>=0.30.0", - "typed-settings>=24.5.0", - "pydantic>=2.11.7", - "python-dotenv>=1.0.0", - "structlog>=25.4.0", - "structlog-to-seq>=21.0.0", - "specklepy[speckleifc]>=3.0.4.dev8", + "asyncpg>=0.30.0", + "typed-settings>=24.5.0", + "pydantic>=2.11.7", + "python-dotenv>=1.0.0", + "structlog>=25.4.0", + "structlog-to-seq>=21.0.0", + "specklepy[speckleifc]>=3.0.4.dev8", + "colorful>=0.5.7", ] [dependency-groups] -dev = [ - "asyncpg-stubs>=0.30.2", - "colorama>=0.4.6", - "colorful>=0.5.7", - "ruff>=0.12.2", -] +dev = ["asyncpg-stubs>=0.30.2", "colorama>=0.4.6", "ruff>=0.12.2"] [tool.ruff] exclude = [".venv", "**/*.yml"] [tool.ruff.lint] select = [ - "A", - # pycodestyle - "E", - # Pyflakes - "F", - # pyupgrade - "UP", - # flake8-bugbear - "B", - # flake8-simplify - "SIM", - # isort - "I", - # PEP8 naming - "N", - "ASYNC", + "A", + # pycodestyle + "E", + # Pyflakes + "F", + # pyupgrade + "UP", + # flake8-bugbear + "B", + # flake8-simplify + "SIM", + # isort + "I", + # PEP8 naming + "N", + "ASYNC", ] [build-system] diff --git a/packages/ifc-import-service/uv.lock b/packages/ifc-import-service/uv.lock index 46cf7fb59..baa6b1033 100644 --- a/packages/ifc-import-service/uv.lock +++ b/packages/ifc-import-service/uv.lock @@ -251,6 +251,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "asyncpg" }, + { name = "colorful" }, { name = "pydantic" }, { name = "python-dotenv" }, { name = "specklepy", extra = ["speckleifc"] }, @@ -270,6 +271,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "asyncpg", specifier = ">=0.30.0" }, + { name = "colorful", specifier = ">=0.5.7" }, { name = "pydantic", specifier = ">=2.11.7" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "specklepy", extras = ["speckleifc"], specifier = ">=3.0.4.dev8" }, diff --git a/utils/helm/speckle-server/templates/_helpers.tpl b/utils/helm/speckle-server/templates/_helpers.tpl index 13a142a38..9d7a8307a 100644 --- a/utils/helm/speckle-server/templates/_helpers.tpl +++ b/utils/helm/speckle-server/templates/_helpers.tpl @@ -1148,6 +1148,12 @@ Generate the environment variables for Speckle server and Speckle objects deploy - name: FF_NEXT_GEN_FILE_IMPORTER_ENABLED value: {{ .Values.featureFlags.nextGenFileImporterEnabled | quote }} {{- end }} + +{{- if .Values.featureFlags.rhinoFileImporterEnabled }} +- name: FF_RHINO_FILE_IMPORTER_ENABLED + value: {{ .Values.featureFlags.rhinoFileImporterEnabled | quote }} +{{- end }} + {{- if .Values.featureFlags.backgroundJobsEnabled }} - name: FILEIMPORT_QUEUE_POSTGRES_URL valueFrom: diff --git a/utils/helm/speckle-server/values.schema.json b/utils/helm/speckle-server/values.schema.json index a933f4934..1440ebe71 100644 --- a/utils/helm/speckle-server/values.schema.json +++ b/utils/helm/speckle-server/values.schema.json @@ -129,6 +129,11 @@ "type": "boolean", "description": "Enables the ability to run background jobs (such as the IFC importer) in Speckle", "default": false + }, + "rhinoFileImporterEnabled": { + "type": "boolean", + "description": "Enables the dedicated Rhino based file importer. This is not part of the deployment.", + "default": false } } }, diff --git a/utils/helm/speckle-server/values.yaml b/utils/helm/speckle-server/values.yaml index f2a691442..2e4ec73e3 100644 --- a/utils/helm/speckle-server/values.yaml +++ b/utils/helm/speckle-server/values.yaml @@ -73,6 +73,8 @@ featureFlags: legacyIfcImporterEnabled: false ## @param featureFlags.backgroundJobsEnabled Enables the ability to run background jobs (such as the IFC importer) in Speckle backgroundJobsEnabled: false + ## @param featureFlags.rhinoFileImporterEnabled Enables the dedicated Rhino based file importer. This is not part of the deployment. + rhinoFileImporterEnabled: false analytics: ## @param analytics.enabled Enable or disable analytics From 07fc2bf76d249ec6536490fc781f0f455163a166 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= <57442769+gjedlicska@users.noreply.github.com> Date: Wed, 30 Jul 2025 20:17:02 +0200 Subject: [PATCH 5/5] fix(ifc-importer): need to lock packages (#5170) --- packages/ifc-import-service/uv.lock | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/ifc-import-service/uv.lock b/packages/ifc-import-service/uv.lock index baa6b1033..69d050740 100644 --- a/packages/ifc-import-service/uv.lock +++ b/packages/ifc-import-service/uv.lock @@ -264,7 +264,6 @@ dependencies = [ dev = [ { name = "asyncpg-stubs" }, { name = "colorama" }, - { name = "colorful" }, { name = "ruff" }, ] @@ -284,7 +283,6 @@ requires-dist = [ dev = [ { name = "asyncpg-stubs", specifier = ">=0.30.2" }, { name = "colorama", specifier = ">=0.4.6" }, - { name = "colorful", specifier = ">=0.5.7" }, { name = "ruff", specifier = ">=0.12.2" }, ]