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..69d050740 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"] }, @@ -263,13 +264,13 @@ dependencies = [ dev = [ { name = "asyncpg-stubs" }, { name = "colorama" }, - { name = "colorful" }, { name = "ruff" }, ] [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" }, @@ -282,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" }, ] 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 () => { diff --git a/packages/viewer/src/modules/extensions/sections/SectionTool.ts b/packages/viewer/src/modules/extensions/sections/SectionTool.ts index c3ac87f1d..2e92f72df 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' @@ -39,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', @@ -56,6 +58,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 +137,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 +211,12 @@ 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 + protected sectionBoxHistory: OBB[] = [] + protected currentHistoryIndex = 0 + protected maxHistorySize = 100 /** Manadatory property for all extensions */ public get enabled() { @@ -317,6 +335,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 +535,92 @@ export class SectionTool extends Extension { ) } + /** + * 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 */ + this.shiftKeyPressed = false + + /** Store references to event listeners for cleanup */ + this.keydownHandler = (e: KeyboardEvent) => { + 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) => { + 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 */ @@ -541,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) @@ -572,14 +688,19 @@ 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 */ + 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 +1041,102 @@ 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 + } + + /** + * 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 + */ + public dispose() { + if (this.keydownHandler) { + document.removeEventListener('keydown', this.keydownHandler) + } + if (this.keyupHandler) { + document.removeEventListener('keyup', this.keyupHandler) + } + } } 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