From 483b176b71a1e64cb60333ff078d0e767024b77d Mon Sep 17 00:00:00 2001 From: huanld Date: Mon, 11 May 2026 16:17:00 +0700 Subject: [PATCH] Improve viewer osnap and zoom tuning --- .claude/skills/speckle-viewer-osnap/SKILL.md | 68 ++++++ .../speckle-viewer-osnap/agents/openai.yaml | 4 + .../controls/SmoothOrbitControls.ts | 13 +- .../measurements/MeasurementsExtension.ts | 221 ++++++++++++++---- 4 files changed, 259 insertions(+), 47 deletions(-) create mode 100644 .claude/skills/speckle-viewer-osnap/SKILL.md create mode 100644 .claude/skills/speckle-viewer-osnap/agents/openai.yaml diff --git a/.claude/skills/speckle-viewer-osnap/SKILL.md b/.claude/skills/speckle-viewer-osnap/SKILL.md new file mode 100644 index 000000000..1fcec0ef7 --- /dev/null +++ b/.claude/skills/speckle-viewer-osnap/SKILL.md @@ -0,0 +1,68 @@ +--- +name: speckle-viewer-osnap +description: Speckle viewer measurement object-snap and orbit zoom maintenance for this repo. Use when changing packages/viewer measurement snapping, corner/edge snap candidate selection, large IFC snap performance, or SmoothOrbitControls zoom sensitivity/near-model wheel behavior. +--- + +# Speckle Viewer Osnap + +## Workflow + +1. Run GitNexus impact before editing viewer symbols. The index may not resolve TypeScript class methods; if it returns `UNKNOWN`, record that result and keep the edit scoped. +2. Inspect these files before changing behavior: + - `packages/viewer/src/modules/extensions/measurements/MeasurementsExtension.ts` + - `packages/viewer/src/modules/extensions/controls/SmoothOrbitControls.ts` + - `packages/viewer/src/modules/Intersections.ts` + - `packages/viewer/src/modules/objects/TopLevelAccelerationStructure.ts` +3. Treat osnap as screen-space selection first, geometry selection second. Candidate quality should be decided by projected pixel distance to the cursor, but candidate discovery must stay cheap. +4. After source edits, run: + - `yarn workspace @speckle/viewer lint:tsc` + - `yarn workspace @speckle/viewer build:dev` +5. If frontend is running from `packages/viewer/dist/index.js`, rebuild before browser testing. A transient Vite pre-transform error can appear while Rollup replaces `dist/index.js`; reload once and verify again. +6. Before commit, run `npx gitnexus detect-changes --repo speckle-server`. + +## Osnap Rules + +- Do not require the primary ray to land exactly on a vertex. A small screen-space probe around the cursor is acceptable when the primary ray misses geometry. +- Do not probe surrounding rays when the primary ray already has a precise mesh hit; that can make snap jump to nearby or behind objects. +- Keep probe count and scanned batch count low. Large IFCs can lag badly if every pointer frame scans many ray hits or very large edge lists. +- Do not classify every mesh vertex as a point snap. IFC meshes often split long edges into technical vertices. Point snap should prefer real feature corners: feature-edge junctions or vertices where adjacent feature-edge directions are not collinear. +- Treat vertices on straight feature edges as edge snap candidates, not point snap candidates. +- If point snap is found, skip expensive feature-edge scanning for that candidate batch unless edge snap is explicitly needed. +- Keep thresholds conservative. A practical starting point is point snap around `24px`, edge snap around `12px`, and only a few distinct batch objects per pointer frame. + +## Zoom Rules + +- Wheel zoom should scale from the current orbit radius, but close-range movement must not be clamped to a large fraction of the model/world diagonal. +- For large models, reduce the minimum zoom basis instead of only reducing wheel sensitivity. The previous near-model fix used `world.getRelativeOffset(0.00005)` and `ZOOM_SENSITIVITY = 0.05`. + +## Validation + +Use the target model page through the running frontend, for example: + +```powershell +@' +import puppeteer from 'puppeteer' + +const url = 'http://127.0.0.1:8081/projects/a4abd72149/models/252b555ee9' +const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox'] }) +const page = await browser.newPage() +await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 }) +const events = [] +page.on('console', (msg) => { + if (['error', 'warning'].includes(msg.type())) events.push({ type: `console:${msg.type()}`, text: msg.text() }) +}) +page.on('pageerror', (error) => events.push({ type: 'pageerror', text: error.stack || error.message })) +page.on('requestfailed', (request) => events.push({ type: 'requestfailed', url: request.url(), failure: request.failure()?.errorText })) +page.on('response', (response) => { + if (response.status() >= 400) events.push({ type: 'http', status: response.status(), url: response.url() }) +}) +await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 120000 }) +await new Promise((resolve) => setTimeout(resolve, 25000)) +console.log(JSON.stringify({ + events, + title: await page.title(), + canvasCount: await page.$$eval('canvas', (nodes) => nodes.length) +}, null, 2)) +await browser.close() +'@ | node --input-type=module - +``` diff --git a/.claude/skills/speckle-viewer-osnap/agents/openai.yaml b/.claude/skills/speckle-viewer-osnap/agents/openai.yaml new file mode 100644 index 000000000..3cdec809f --- /dev/null +++ b/.claude/skills/speckle-viewer-osnap/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: 'Speckle Viewer Osnap' + short_description: 'Tune Speckle viewer osnap and zoom behavior safely.' + default_prompt: 'Use $speckle-viewer-osnap when changing Speckle viewer measurement osnap, snap candidate selection, or orbit zoom behavior.' diff --git a/packages/viewer/src/modules/extensions/controls/SmoothOrbitControls.ts b/packages/viewer/src/modules/extensions/controls/SmoothOrbitControls.ts index 131844b76..9054d90f2 100644 --- a/packages/viewer/src/modules/extensions/controls/SmoothOrbitControls.ts +++ b/packages/viewer/src/modules/extensions/controls/SmoothOrbitControls.ts @@ -106,8 +106,9 @@ export interface SmoothOrbitControlsOptions { damperDecay?: number } -const ZOOM_SENSITIVITY = 0.08 +const ZOOM_SENSITIVITY = 0.05 const RADIUS_ZOOM_FACTOR = 0.375 +const MIN_ZOOM_BASIS_RELATIVE_OFFSET = 0.00005 export enum PointerChangeEvent { PointerChangeStart = 'pointer-change-start', @@ -547,7 +548,10 @@ export class SmoothOrbitControls extends SpeckleControls { // false // ) !== null - const zoomBasisRadius = Math.max(radius, this.world.getRelativeOffset(0.001)) + const zoomBasisRadius = Math.max( + radius, + this.world.getRelativeOffset(MIN_ZOOM_BASIS_RELATIVE_OFFSET) + ) const worldSizeOffset = clamp( zoomBasisRadius * Math.abs(deltaZoom) * RADIUS_ZOOM_FACTOR, 0, @@ -1247,10 +1251,7 @@ export class SmoothOrbitControls extends SpeckleControls { this.zoomControlCoord.set(x, y) const deltaZoom = - (event.deltaY * - (event.deltaMode === 1 ? 18 : 1) * - ZOOM_SENSITIVITY) / - 60 + (event.deltaY * (event.deltaMode === 1 ? 18 : 1) * ZOOM_SENSITIVITY) / 60 this.userAdjustOrbit(0, 0, deltaZoom) event.preventDefault() this.usePivotal = false diff --git a/packages/viewer/src/modules/extensions/measurements/MeasurementsExtension.ts b/packages/viewer/src/modules/extensions/measurements/MeasurementsExtension.ts index ea4e8fe61..5085d231f 100644 --- a/packages/viewer/src/modules/extensions/measurements/MeasurementsExtension.ts +++ b/packages/viewer/src/modules/extensions/measurements/MeasurementsExtension.ts @@ -46,23 +46,38 @@ enum MeasurementSnapKind { Edge } -const SNAP_POINT_PIXEL_THRESHOLD = 16 -const SNAP_EDGE_PIXEL_THRESHOLD = 8 -const SNAP_SEARCH_HIT_LIMIT = 6 -const SNAP_MAX_VERTEX_SCAN = 100_000 +const SNAP_POINT_PIXEL_THRESHOLD = 24 +const SNAP_EDGE_PIXEL_THRESHOLD = 12 +const SNAP_SEARCH_HIT_LIMIT = 4 +const SNAP_MAX_VERTEX_SCAN = 500_000 const SNAP_FEATURE_NORMAL_DOT_THRESHOLD = 0.995 +const SNAP_FEATURE_COLLINEAR_DOT_THRESHOLD = 0.995 const SNAP_FEATURE_KEY_SCALE = 100_000 -const SNAP_MAX_FEATURE_EDGE_COUNT = 160_000 +const SNAP_MAX_FEATURE_EDGE_COUNT = 40_000 +const SNAP_MAX_FEATURE_POINT_COUNT = 60_000 +const SNAP_PROBE_PIXEL_RADII = [SNAP_POINT_PIXEL_THRESHOLD] +const SNAP_PROBE_DIRECTIONS: ReadonlyArray = [ + [1, 0], + [-1, 0], + [0, 1], + [0, -1], + [Math.SQRT1_2, Math.SQRT1_2], + [-Math.SQRT1_2, Math.SQRT1_2], + [Math.SQRT1_2, -Math.SQRT1_2], + [-Math.SQRT1_2, -Math.SQRT1_2] +] type SnapFeatureEdge = { startIndex: number endIndex: number + startKey: string + endKey: string normal: Vector3 } type SnapFeatureGeometry = { featureEdges: SnapFeatureEdge[] - featureVertexIndices: number[] + featurePointIndices: number[] } export class MeasurementsExtension extends Extension { @@ -92,8 +107,8 @@ export class MeasurementsExtension extends Extension { private snapNdcBuff: Vector3 = new Vector3() private snapWorldBuff2: Vector3 = new Vector3() private snapScreenBuff2: Vector2 = new Vector2() - private snapFeatureCache: WeakMap = - new WeakMap() + private snapProbeNdcBuff: Vector2 = new Vector2() + private snapFeatureCache: WeakMap = new WeakMap() public get enabled(): boolean { return this._enabled @@ -231,14 +246,13 @@ export class MeasurementsExtension extends Extension { ) } - protected updateActiveMeasurementLocation(data: Vector2): boolean { - const camera = this.renderer.renderingCamera - if (!camera) return false + protected intersectMeasurementScene(data: Vector2): ExtendedMeshIntersection[] { + if (!this.renderer.renderingCamera) return [] - let result: ExtendedMeshIntersection[] = + const result: ExtendedMeshIntersection[] = this.renderer.intersections.intersect( this.renderer.scene, - camera, + this.renderer.renderingCamera, data, ObjectLayers.STREAM_CONTENT_MESH, true, @@ -248,16 +262,66 @@ export class MeasurementsExtension extends Extension { true ) || [] - result = result.filter((value: ExtendedMeshIntersection) => + return result.filter((value: ExtendedMeshIntersection) => this.isPreciseMeasurementHit(value) ) + } - if (!result.length) { + private collectNearbySnapIntersections( + data: Vector2, + intersections: ExtendedMeshIntersection[] + ): ExtendedMeshIntersection[] { + if (!this.renderer.renderingCamera || !this.screenBuff0.x || !this.screenBuff0.y) { + return intersections + } + + const expanded = intersections.slice() + const seenBatchObjects = new Set( + intersections.map((intersection) => intersection.batchObject) + ) + const pixelRatio = window.devicePixelRatio || 1 + const ndcPerPixelX = (pixelRatio * 2) / this.screenBuff0.x + const ndcPerPixelY = (pixelRatio * 2) / this.screenBuff0.y + + for (const radius of SNAP_PROBE_PIXEL_RADII) { + for (const [directionX, directionY] of SNAP_PROBE_DIRECTIONS) { + this.snapProbeNdcBuff.set( + data.x + directionX * radius * ndcPerPixelX, + data.y - directionY * radius * ndcPerPixelY + ) + + const probeIntersections = this.intersectMeasurementScene(this.snapProbeNdcBuff) + for (const intersection of probeIntersections) { + if (seenBatchObjects.has(intersection.batchObject)) continue + + seenBatchObjects.add(intersection.batchObject) + expanded.push(intersection) + if (seenBatchObjects.size >= SNAP_SEARCH_HIT_LIMIT) return expanded + } + } + } + + return expanded + } + + protected updateActiveMeasurementLocation(data: Vector2): boolean { + const camera = this.renderer.renderingCamera + if (!camera) return false + + const directIntersections = this.intersectMeasurementScene(data) + const directPrimaryHit = directIntersections[0] + let result = directIntersections + + if (!directPrimaryHit && this._options.vertexSnap) { + result = this.collectNearbySnapIntersections(data, result) + } + + const primaryHit = directPrimaryHit ?? result[0] + if (!primaryHit) { this.hideSnapPointView() return false } - const primaryHit = result[0] this.pointBuff.copy(primaryHit.point) this.normalBuff.copy(primaryHit.face.normal) @@ -276,6 +340,11 @@ export class MeasurementsExtension extends Extension { if (vertexSnap) { snapKind = this.snap(data, result, this.pointBuff, this.normalBuff) } + + if (!directPrimaryHit && snapKind === MeasurementSnapKind.None) { + this.hideSnapPointView() + return false + } this.updateSnapPointView(snapKind, this.pointBuff, this.normalBuff) if (!this._activeMeasurement) { @@ -610,9 +679,14 @@ export class MeasurementsExtension extends Extension { } const vertexKeys = new Map() + const vertexIndicesByKey = new Map() const readVertex = (index: number, target: Vector3) => { const offset = index * 3 - target.set(positionArray[offset], positionArray[offset + 1], positionArray[offset + 2]) + target.set( + positionArray[offset], + positionArray[offset + 1], + positionArray[offset + 2] + ) } const vertexKey = (index: number) => { let key = vertexKeys.get(index) @@ -623,6 +697,7 @@ export class MeasurementsExtension extends Extension { positionArray[offset + 1] * SNAP_FEATURE_KEY_SCALE )},${Math.round(positionArray[offset + 2] * SNAP_FEATURE_KEY_SCALE)}` vertexKeys.set(index, key) + if (!vertexIndicesByKey.has(key)) vertexIndicesByKey.set(key, index) return key } type EdgeEntry = SnapFeatureEdge & { @@ -647,6 +722,8 @@ export class MeasurementsExtension extends Extension { edges.set(edgeKey, { startIndex, endIndex, + startKey, + endKey, normal: normal.clone(), count: 1, sharp: false @@ -655,9 +732,7 @@ export class MeasurementsExtension extends Extension { } existing.count++ - if ( - Math.abs(existing.normal.dot(normal)) < SNAP_FEATURE_NORMAL_DOT_THRESHOLD - ) { + if (Math.abs(existing.normal.dot(normal)) < SNAP_FEATURE_NORMAL_DOT_THRESHOLD) { existing.sharp = true } } @@ -682,25 +757,79 @@ export class MeasurementsExtension extends Extension { } const featureEdges: SnapFeatureEdge[] = [] - const featureVertexIndices = new Set() + const featureVertexNeighbors = new Map>() + const addFeatureVertexNeighbor = (vertex: string, neighbor: string) => { + let neighbors = featureVertexNeighbors.get(vertex) + if (!neighbors) { + neighbors = new Set() + featureVertexNeighbors.set(vertex, neighbors) + } + neighbors.add(neighbor) + } + edges.forEach((edge) => { if (edge.count !== 1 && !edge.sharp) return featureEdges.push({ startIndex: edge.startIndex, endIndex: edge.endIndex, + startKey: edge.startKey, + endKey: edge.endKey, normal: edge.normal }) - featureVertexIndices.add(edge.startIndex) - featureVertexIndices.add(edge.endIndex) + addFeatureVertexNeighbor(edge.startKey, edge.endKey) + addFeatureVertexNeighbor(edge.endKey, edge.startKey) }) - if (!featureEdges.length || featureEdges.length > SNAP_MAX_FEATURE_EDGE_COUNT) { + if (!featureEdges.length) { + return null + } + + const featurePointIndices: number[] = [] + const center = new Vector3() + const neighbor = new Vector3() + const directionA = new Vector3() + const directionB = new Vector3() + featureVertexNeighbors.forEach((neighborKeys, vertexKeyValue) => { + const vertexIndex = vertexIndicesByKey.get(vertexKeyValue) + if (vertexIndex === undefined) return + + const neighborKeyList = [...neighborKeys] + if (neighborKeyList.length < 2) return + if (neighborKeyList.length >= 3) { + featurePointIndices.push(vertexIndex) + return + } + + readVertex(vertexIndex, center) + const neighborIndexA = vertexIndicesByKey.get(neighborKeyList[0]) + const neighborIndexB = vertexIndicesByKey.get(neighborKeyList[1]) + if (neighborIndexA === undefined || neighborIndexB === undefined) return + + readVertex(neighborIndexA, neighbor) + directionA.subVectors(neighbor, center) + readVertex(neighborIndexB, neighbor) + directionB.subVectors(neighbor, center) + if ( + directionA.lengthSq() <= Number.EPSILON || + directionB.lengthSq() <= Number.EPSILON + ) { + return + } + directionA.normalize() + directionB.normalize() + if (Math.abs(directionA.dot(directionB)) < SNAP_FEATURE_COLLINEAR_DOT_THRESHOLD) { + featurePointIndices.push(vertexIndex) + } + }) + + if (featurePointIndices.length > SNAP_MAX_FEATURE_POINT_COUNT) { return null } return { - featureEdges, - featureVertexIndices: [...featureVertexIndices] + featureEdges: + featureEdges.length <= SNAP_MAX_FEATURE_EDGE_COUNT ? featureEdges : [], + featurePointIndices } } @@ -803,7 +932,10 @@ export class MeasurementsExtension extends Extension { ) const edgeScreenX = this.snapScreenBuff.x + edgeX * t const edgeScreenY = this.snapScreenBuff.y + edgeY * t - const distance = Math.hypot(edgeScreenX - mouseScreen.x, edgeScreenY - mouseScreen.y) + const distance = Math.hypot( + edgeScreenX - mouseScreen.x, + edgeScreenY - mouseScreen.y + ) const threshold = SNAP_EDGE_PIXEL_THRESHOLD * window.devicePixelRatio if (distance > threshold || distance >= closestDistance) return @@ -828,11 +960,15 @@ export class MeasurementsExtension extends Extension { const readWorldVertex = (index: number, target: Vector3) => { const offset = index * 3 - target.set(positionArray[offset], positionArray[offset + 1], positionArray[offset + 2]) + target.set( + positionArray[offset], + positionArray[offset + 1], + positionArray[offset + 2] + ) accelerationStructure.transformOutput(target) } - for (const vertexIndex of featureGeometry.featureVertexIndices) { + for (const vertexIndex of featureGeometry.featurePointIndices) { readWorldVertex(vertexIndex, this.snapWorldBuff) testProjectedPoint( MeasurementSnapKind.Point, @@ -842,22 +978,27 @@ export class MeasurementsExtension extends Extension { ) } - for (const edge of featureGeometry.featureEdges) { - readWorldVertex(edge.startIndex, this.snapWorldBuff) - readWorldVertex(edge.endIndex, this.snapWorldBuff2) - testProjectedEdge( - this.snapWorldBuff, - this.snapWorldBuff2, - intersection.face.normal - ) + if (!closestPoint) { + for (const edge of featureGeometry.featureEdges) { + readWorldVertex(edge.startIndex, this.snapWorldBuff) + readWorldVertex(edge.endIndex, this.snapWorldBuff2) + testProjectedEdge( + this.snapWorldBuff, + this.snapWorldBuff2, + intersection.face.normal + ) + } } } - for (const intersection of intersections.slice(0, SNAP_SEARCH_HIT_LIMIT)) { + let scannedBatchObjectCount = 0 + for (const intersection of intersections) { if (!this.isPreciseMeasurementHit(intersection)) continue if (seenBatchObjects.has(intersection.batchObject)) continue seenBatchObjects.add(intersection.batchObject) scanFeatureGeometry(intersection) + scannedBatchObjectCount++ + if (scannedBatchObjectCount >= SNAP_SEARCH_HIT_LIMIT) break } if (closestPoint) { @@ -936,9 +1077,7 @@ export class MeasurementsExtension extends Extension { edgeLengthSq ) ) - const edgeWorldPoint = vertices[startIndex] - .clone() - .lerp(vertices[endIndex], t) + const edgeWorldPoint = vertices[startIndex].clone().lerp(vertices[endIndex], t) const edgeProjectedPoint = projectedVertices[startIndex] .clone() .lerp(projectedVertices[endIndex], t)