Improve viewer osnap and zoom tuning
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user