Improve viewer osnap and zoom tuning

This commit is contained in:
2026-05-11 16:17:00 +07:00
parent 840080c85b
commit 483b176b71
4 changed files with 259 additions and 47 deletions
@@ -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 -
```
@@ -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.'
@@ -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
@@ -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<readonly [number, number]> = [
[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<object, SnapFeatureGeometry | null> =
new WeakMap()
private snapProbeNdcBuff: Vector2 = new Vector2()
private snapFeatureCache: WeakMap<object, SnapFeatureGeometry | null> = 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<unknown>(
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<number, string>()
const vertexIndicesByKey = new Map<string, number>()
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<number>()
const featureVertexNeighbors = new Map<string, Set<string>>()
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)