diff --git a/package.json b/package.json index acb951857..93f4047c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bldrs", - "version": "1.0.1877", + "version": "1.0.1887", "main": "src/index.jsx", "license": "AGPL-3.0", "homepage": "https://github.com/bldrs-ai/Share", diff --git a/src/Containers/CadView.jsx b/src/Containers/CadView.jsx index 3fc450617..08f1a530d 100644 --- a/src/Containers/CadView.jsx +++ b/src/Containers/CadView.jsx @@ -13,6 +13,7 @@ import {useIsMobile} from '../Components/Hooks' import {load} from '../loader/Loader' import useStore from '../store/useStore' import {getParentPathIdsForElement, setupLookupAndParentLinks} from '../utils/TreeUtils' +import {adaptCameraPlanes, adaptCameraZoomLimits} from '../utils/cameraPlanes' import {areDefinedAndNotNull, assertDefined} from '../utils/assert' import debug from '../utils/debug' import {disablePageReloadApprovalCheck} from '../utils/event' @@ -323,7 +324,20 @@ export default function CadView({ const isCamHashSet = onHash(location, viewer.IFC.context.ifcCamera.cameraControls) if (!isCamHashSet) { + console.log('CadView#loadModel: not isCamHashSet, fitting model to frame') viewer.IFC.context.ifcCamera.currentNavMode.fitModelToFrame() + // Adaptively adjust camera near/far planes based on model size + // This ensures users can zoom out far enough to see the entire model + const camera = viewer.context.getCamera() + adaptCameraPlanes(camera, loadedModel) + window.camera = camera + + // Adaptively adjust camera controls zoom limits (minDistance/maxDistance) + // This ensures mouse zoom can go out far enough to see the entire model + const cameraControls = viewer.IFC.context.ifcCamera.cameraControls + const scene = viewer.IFC.context.getScene() + console.log('CadView#loadModel: loadedModel', loadedModel) + adaptCameraZoomLimits(camera, cameraControls, loadedModel, scene) } // TODO(pablo): centralize capability check somewhere diff --git a/src/loader/Loader.js b/src/loader/Loader.js index 5e5c484b9..8086a568a 100644 --- a/src/loader/Loader.js +++ b/src/loader/Loader.js @@ -604,7 +604,7 @@ function newIfcLoader(viewer) { if (onProgress) { onProgress('Fitting model to frame...') } - this.context.fitToFrame() + // this.context.fitToFrame() if (onProgress) { onProgress('Gathering model statistics...') diff --git a/src/utils/cameraPlanes.test.ts b/src/utils/cameraPlanes.test.ts new file mode 100644 index 000000000..5ca4cd93a --- /dev/null +++ b/src/utils/cameraPlanes.test.ts @@ -0,0 +1,222 @@ +import {Box3, BufferGeometry, Mesh, Object3D, PerspectiveCamera, Vector3} from 'three' +import {adaptCameraPlanes, adaptCameraZoomLimits} from './cameraPlanes' + + +describe('cameraPlanes', () => { + describe('adaptCameraZoomLimits', () => { + it('should set minDistance and maxDistance based on model size', () => { + const cameraControls = { + minDistance: 0, + maxDistance: DEFAULT_FAR, + } + const model = createMockModel(MODEL_SIZE, MODEL_SIZE, MODEL_SIZE) + + adaptCameraZoomLimits(cameraControls, model) + + // Model size (diagonal) ≈ sqrt(10^2 + 10^2 + 10^2) ≈ 17.32 + // Default minDistanceFactor = 0.2, so calculatedMinDistance = 17.32 * 0.2 ≈ 3.464 + // Safety factor = 0.05, so minDistanceThreshold = max(3.464, 17.32 * 0.05) = max(3.464, 0.866) ≈ 3.464 + // Default maxDistanceFactor = 2, so maxDistance = 17.32 * 2 ≈ 34.64 + const modelSize = Math.sqrt((MODEL_SIZE * MODEL_SIZE) + (MODEL_SIZE * MODEL_SIZE) + (MODEL_SIZE * MODEL_SIZE)) + const expectedMinDistance = Math.max(modelSize * 0.2, modelSize * 0.05) + const expectedMaxDistance = modelSize * 2 + expect(cameraControls.minDistance).toBeCloseTo(expectedMinDistance, 1) + expect(cameraControls.maxDistance).toBeCloseTo(expectedMaxDistance, 1) + }) + + it('should use custom factors when provided', () => { + const cameraControls = { + minDistance: 0, + maxDistance: DEFAULT_FAR, + } + const model = createMockModel(MODEL_SIZE, MODEL_SIZE, MODEL_SIZE) + + adaptCameraZoomLimits(cameraControls, model, CUSTOM_MIN_FACTOR, CUSTOM_MAX_FACTOR) + + const modelSize = Math.sqrt((MODEL_SIZE * MODEL_SIZE) + (MODEL_SIZE * MODEL_SIZE) + (MODEL_SIZE * MODEL_SIZE)) + expect(cameraControls.minDistance).toBeCloseTo(modelSize * CUSTOM_MIN_FACTOR, 1) + expect(cameraControls.maxDistance).toBeCloseTo(modelSize * CUSTOM_MAX_FACTOR, 1) + }) + + it('should set limits even if values are already set', () => { + const model = createMockModel(MODEL_SIZE, MODEL_SIZE, MODEL_SIZE) + const modelSize = Math.sqrt((MODEL_SIZE * MODEL_SIZE) + (MODEL_SIZE * MODEL_SIZE) + (MODEL_SIZE * MODEL_SIZE)) + const expectedMinDistance = Math.max(modelSize * 0.2, modelSize * 0.05) + const expectedMaxDistance = modelSize * 2 + const cameraControls = { + minDistance: expectedMinDistance, + maxDistance: expectedMaxDistance, + } + + adaptCameraZoomLimits(cameraControls, model) + + // Values should be set to the calculated limits + expect(cameraControls.minDistance).toBeCloseTo(expectedMinDistance, 1) + expect(cameraControls.maxDistance).toBeCloseTo(expectedMaxDistance, 1) + }) + + it('should handle null/undefined inputs gracefully', () => { + const cameraControls = {minDistance: 0, maxDistance: DEFAULT_FAR} + + adaptCameraZoomLimits(null, createMockModel(MODEL_SIZE, MODEL_SIZE, MODEL_SIZE)) + expect(cameraControls.minDistance).toBe(0) + expect(cameraControls.maxDistance).toBe(DEFAULT_FAR) + + adaptCameraZoomLimits(cameraControls, null) + expect(cameraControls.minDistance).toBe(0) + expect(cameraControls.maxDistance).toBe(DEFAULT_FAR) + }) + + it('should handle models without bounding box', () => { + const cameraControls = {minDistance: 0, maxDistance: DEFAULT_FAR} + const emptyModel = new Object3D() + + adaptCameraZoomLimits(cameraControls, emptyModel as Object3D & {geometry?: {boundingBox?: Box3}}) + + // Should not change values if no bounding box + expect(cameraControls.minDistance).toBe(0) + expect(cameraControls.maxDistance).toBe(DEFAULT_FAR) + }) + + it('should only update properties that exist', () => { + interface PartialCameraControls { + minDistance?: number + maxDistance?: number + } + const cameraControls: PartialCameraControls = {} + const model = createMockModel(MODEL_SIZE, MODEL_SIZE, MODEL_SIZE) + + adaptCameraZoomLimits(cameraControls, model) + + // Should not throw, even if properties don't exist + expect(cameraControls.minDistance).toBeUndefined() + expect(cameraControls.maxDistance).toBeUndefined() + }) + }) + + describe('adaptCameraPlanes', () => { + it('should set near and far planes based on model size', () => { + const camera = new PerspectiveCamera(DEFAULT_FOV, DEFAULT_ASPECT, DEFAULT_NEAR, DEFAULT_FAR) + const model = createMockModel(MODEL_SIZE, MODEL_SIZE, MODEL_SIZE) + const updateProjectionMatrixSpy = jest.spyOn(camera, 'updateProjectionMatrix') + + adaptCameraPlanes(camera, model) + + // Near plane should be clamped between minNear (0.001) and 1 + expect(camera.near).toBeGreaterThanOrEqual(MIN_NEAR_PLANE) + expect(camera.near).toBeLessThanOrEqual(MAX_NEAR_PLANE) + + // Far plane should be large enough for the model + expect(camera.far).toBeGreaterThan(MIN_FAR_THRESHOLD) + expect(updateProjectionMatrixSpy).toHaveBeenCalled() + + updateProjectionMatrixSpy.mockRestore() + }) + + it('should use custom parameters when provided', () => { + const camera = new PerspectiveCamera(DEFAULT_FOV, DEFAULT_ASPECT, DEFAULT_NEAR, DEFAULT_FAR) + const model = createMockModel(MODEL_SIZE, MODEL_SIZE, MODEL_SIZE) + + adaptCameraPlanes(camera, model, CUSTOM_MIN_NEAR, CUSTOM_MAX_FAR, CUSTOM_PADDING_FACTOR) + + expect(camera.near).toBeGreaterThanOrEqual(CUSTOM_MIN_NEAR) + expect(camera.far).toBeLessThanOrEqual(CUSTOM_MAX_FAR) + }) + + it('should not update if values are within threshold', () => { + const camera = new PerspectiveCamera(DEFAULT_FOV, DEFAULT_ASPECT, CUSTOM_MIN_NEAR, DEFAULT_FAR) + camera.near = CUSTOM_MIN_NEAR + camera.far = DEFAULT_FAR + const model = createMockModel(MODEL_SIZE, MODEL_SIZE, MODEL_SIZE) + + // Mock updateProjectionMatrix to track calls + const updateProjectionMatrixSpy = jest.spyOn(camera, 'updateProjectionMatrix') + + adaptCameraPlanes(camera, model) + + // If values are very close, might not update + // But if they do update, updateProjectionMatrix should be called + if (updateProjectionMatrixSpy.mock.calls.length > 0) { + expect(updateProjectionMatrixSpy).toHaveBeenCalled() + } + + updateProjectionMatrixSpy.mockRestore() + }) + + it('should handle null/undefined inputs gracefully', () => { + const camera = new PerspectiveCamera(DEFAULT_FOV, DEFAULT_ASPECT, DEFAULT_NEAR, DEFAULT_FAR) + const originalNear = camera.near + const originalFar = camera.far + + adaptCameraPlanes(null, createMockModel(MODEL_SIZE, MODEL_SIZE, MODEL_SIZE)) + expect(camera.near).toBe(originalNear) + expect(camera.far).toBe(originalFar) + + adaptCameraPlanes(camera, null) + expect(camera.near).toBe(originalNear) + expect(camera.far).toBe(originalFar) + }) + + it('should handle large models', () => { + const camera = new PerspectiveCamera(DEFAULT_FOV, DEFAULT_ASPECT, DEFAULT_NEAR, DEFAULT_FAR) + const model = createMockModel(LARGE_MODEL_SIZE, LARGE_MODEL_SIZE, LARGE_MODEL_SIZE) + + adaptCameraPlanes(camera, model) + + // Far plane should accommodate large model + expect(camera.far).toBeGreaterThan(LARGE_MODEL_SIZE) + }) + + it('should handle small models', () => { + const camera = new PerspectiveCamera(DEFAULT_FOV, DEFAULT_ASPECT, DEFAULT_NEAR, DEFAULT_FAR) + const model = createMockModel(SMALL_MODEL_SIZE, SMALL_MODEL_SIZE, SMALL_MODEL_SIZE) + + adaptCameraPlanes(camera, model) + + // Near plane should be clamped to minimum + expect(camera.near).toBeGreaterThanOrEqual(MIN_NEAR_PLANE) + }) + }) +}) + + +/** + * Creates a mock model with a bounding box for testing. + * + * @param width - Width of the bounding box + * @param height - Height of the bounding box + * @param depth - Depth of the bounding box + * @return Mock model object + */ +function createMockModel(width: number, height: number, depth: number): Object3D & {geometry: {boundingBox: Box3}} { + const geometry = new BufferGeometry() + const mesh = new Mesh(geometry) + const boundingBox = new Box3() + boundingBox.setFromCenterAndSize( + new Vector3(0, 0, 0), + new Vector3(width, height, depth), + ) + mesh.geometry.boundingBox = boundingBox + + return mesh as Object3D & {geometry: {boundingBox: Box3}} +} + + +// Test constants +const DEFAULT_FOV = 75 +const DEFAULT_ASPECT = 1 +const DEFAULT_NEAR = 0.1 +const DEFAULT_FAR = 1000 +const MODEL_SIZE = 10 +const EXPECTED_MIN_DISTANCE = 1.73 +const EXPECTED_MAX_DISTANCE = 173.2 +const CUSTOM_MIN_FACTOR = 0.2 +const CUSTOM_MAX_FACTOR = 20 +const MIN_NEAR_PLANE = 0.001 +const MAX_NEAR_PLANE = 1 +const MIN_FAR_THRESHOLD = 100 +const LARGE_MODEL_SIZE = 1000 +const SMALL_MODEL_SIZE = 0.1 +const CUSTOM_MIN_NEAR = 0.01 +const CUSTOM_MAX_FAR = 10000 +const CUSTOM_PADDING_FACTOR = 100 diff --git a/src/utils/cameraPlanes.ts b/src/utils/cameraPlanes.ts new file mode 100644 index 000000000..9e71ec0d5 --- /dev/null +++ b/src/utils/cameraPlanes.ts @@ -0,0 +1,204 @@ +import { + Box3, + Box3Helper, + Color, + MeshBasicMaterial, + Mesh, + Object3D, + PerspectiveCamera, + Scene, + Sphere, + SphereGeometry, + Vector3, +} from 'three' +import { assertDefined } from './assert' + + +/** + * Adaptively sets camera controls zoom limits (minDistance/maxDistance) based on model size. + * Sets limits once when called - the camera controls library should respect these static limits. + * + * @param camera - Three.js PerspectiveCamera + * @param cameraControls - Camera controls object (e.g., OrbitControls) + * @param model - The loaded 3D model + * @param scene - The three.js scene + * @param minDistanceFactor - Minimum zoom distance as fraction of model size (default: 0.2) + * @param maxDistanceFactor - Maximum zoom distance as multiple of model size (default: 2) + */ +export function adaptCameraZoomLimits( + camera: PerspectiveCamera, + cameraControls: CameraControls, + model: ModelWithBoundingBox, + scene: Scene, + minDistanceFactor = 0.1, + maxDistanceFactor = 10, +): void { + assertDefined(camera, cameraControls, model, scene) + + cameraControls.dollyToCursor = false + cameraControls.infinityDolly = false + model.updateMatrixWorld(true) + let boundingBox = getModelBoundingBox(model) + if (!boundingBox) { + throw new Error('Bounding box is not defined') + } + const size = new Vector3() + boundingBox.getSize(size) + + // Calculate bounding sphere of model + const bboxCenter = new Vector3() + const bboxSize = new Vector3() + const modelSize = size.length() + boundingBox.getCenter(bboxCenter) + boundingBox.getSize(bboxSize) + const bsphereRadius = bboxSize.length() / 2 + const bsphere = new Sphere(bboxCenter, bsphereRadius) + if (!Number.isFinite(bsphereRadius) || bsphereRadius <= 0) { + throw new Error('Bounding sphere radius is invalid') + } + + + // create debug box + const debugBox = new Box3Helper(boundingBox, new Color(0x00ff00)) + debugBox.position.copy(bboxCenter) + scene.add(debugBox) + // create debug sphere + const debugSphere = new Mesh(new SphereGeometry(bsphereRadius, 32, 32), new MeshBasicMaterial({color: 0x00ff00, wireframe: true})) + debugSphere.position.copy(bboxCenter) + scene.add(debugSphere) + /**/ + + // Move camera to edge of bounding sphere + cameraControls.fitToSphere(bsphere, false) + + if (typeof cameraControls.minDistance === 'undefined') { + console.warn('cameraControls.minDistance is undefined') + } + cameraControls.minDistance = 1 // modelSize * minDistanceFactor + if (typeof cameraControls.maxDistance === 'undefined') { + console.warn('cameraControls.maxDistance is not defined') + } + cameraControls.maxDistance = modelSize * maxDistanceFactor +} + + +/** + * Adaptively sets camera near/far planes based on model size. + * Ensures the far plane is large enough to see the entire model when zoomed out. + * + * @param camera - Three.js PerspectiveCamera + * @param model - The loaded 3D model + * @param minNear - Minimum near plane distance (default: 0.001) + * @param maxFar - Maximum far plane distance (default: 10000000) + * @param paddingFactor - Multiplier for model size to ensure adequate zoom range (default: 1000) + */ +export function adaptCameraPlanes( + camera: PerspectiveCamera, + model: ModelWithBoundingBox, + minNear = 0.1, + maxFar = 1e5, + paddingFactor = 1e3, +): void { + assertDefined(camera, model) + const boundingBox = getModelBoundingBox(model) + if (!boundingBox) { + throw new Error('Bounding box is not defined') + } + + // Calculate model size (diagonal of bounding box) + const size = new Vector3() + boundingBox.getSize(size) + const modelSize = size.length() + + // Set near plane: small enough to see close details, but not too small + // Use a fraction of the model size, clamped to reasonable values + // Ensure near plane is never zero or negative (prevents clipping when zoomed in very close) + const minNearRatio = 0.001 + const calculatedNearPlane = modelSize * minNearRatio + // Ensure near plane is at least a small fraction of model size to prevent clipping when very close + const minNearPlane = Math.max(minNear, calculatedNearPlane) + const nearPlane = Math.min(minNearPlane, 1) + + // Set far plane: large enough to see the entire model when zoomed out + // Based on model size with padding, independent of camera position + const farPlane = Math.min( + maxFar, + modelSize * paddingFactor, + ) + + // Only update if values have changed significantly (avoid unnecessary updates) + const nearThreshold = 0.001 + const farThreshold = 1 + if ( + Math.abs(camera.near - nearPlane) > nearThreshold || + Math.abs(camera.far - farPlane) > farThreshold + ) { + camera.near = nearPlane + camera.far = farPlane + camera.updateProjectionMatrix() + } + console.log('nearPlane', nearPlane) + console.log('farPlane', farPlane) + console.log('camera.near', camera.near) + console.log('camera.far', camera.far) +} + + +/** + * Calculates the bounding box of a model, supporting both IFC and GLB formats. + * Uses local bounding sphere for IFC models (size is constant regardless of position/rotation). + * For other models, computes from object hierarchy. + * + * @param model - The 3D model object + * @return The bounding box, or null if unable to compute + */ +function getModelBoundingBox(model: ModelWithBoundingBox | null | undefined): Box3 | null { + if (!model) { + return null + } + + const box = new Box3() + + // IFC models have geometry.boundingBox (in local/model space) + // Use local space - size is constant regardless of world transform + if (model.geometry?.boundingBox) { + box.copy(model.geometry.boundingBox) + } else { + // GLB and other models - compute from object hierarchy + model.updateMatrixWorld(true) + box.setFromObject(model) + } + + if (box.isEmpty()) { + return null + } + + return box +} + + +/** + * Camera controls interface with zoom distance limits. + */ +interface CameraControls { + dampingFactor: number + dollyToCursor: boolean + draggingSmoothTime: number + infinityDolly: boolean + minDistance: number + maxDistance: number + smoothTime: number + addEventListener: (event: string, callback: () => void) => void + fitToBox: (box: Box3, animate: boolean) => void + fitToSphere: (sphere: Sphere, animate: boolean) => void +} + + +/** + * Model interface that may have geometry with bounding box. + */ +interface ModelWithBoundingBox extends Object3D { + geometry?: { + boundingBox?: Box3 + } +} diff --git a/src/view/View.spec.ts b/src/view/View.spec.ts new file mode 100644 index 000000000..022b24c33 --- /dev/null +++ b/src/view/View.spec.ts @@ -0,0 +1,150 @@ +import {expect, test} from '@playwright/test' +import {waitForModelReady} from '../tests/e2e/models' +import { + homepageSetup, + returningUserVisitsHomepageWaitForModel, +} from '../tests/e2e/utils' +import {expectScreen} from '../tests/screens' + + +const {beforeEach, describe} = test +/** + * Camera view tests - verifies camera near/far plane setup + */ +describe('View', () => { + describe('Camera planes setup', () => { + beforeEach(async ({page}) => { + await homepageSetup(page) + await returningUserVisitsHomepageWaitForModel(page) + }) + + test('Camera near and far planes are set correctly - Screenshot', async ({page}) => { + // Wait for model to be fully loaded + await waitForModelReady(page) + + // Get camera properties via page evaluation using the store + const cameraProps = await page.evaluate(() => { + const store = (window as unknown as WindowWithStore).store + if (!store) { + return null + } + const viewer = store.getState().viewer + if (!viewer) { + return null + } + const camera = viewer.context?.getCamera() + if (!camera) { + return null + } + return { + near: camera.near, + far: camera.far, + } + }) + + // Verify camera properties are set + expect(cameraProps).not.toBeNull() + if (cameraProps) { + expect(cameraProps.near).toBeGreaterThan(0) + expect(cameraProps.far).toBeGreaterThan(cameraProps.near) + // Far plane should be large enough for the model (at least 1000) + const minFarPlane = 1000 + expect(cameraProps.far).toBeGreaterThan(minFarPlane) + } + + // Take screenshot of the default view + await expectScreen(page, 'view-camera-default.png') + + // Verify camera controls zoom limits are set + const zoomLimits = await page.evaluate(() => { + const store = (window as unknown as WindowWithStore).store + if (!store) { + return null + } + const viewer = store.getState().viewer + if (!viewer) { + return null + } + const cameraControls = viewer.IFC?.context?.ifcCamera?.cameraControls + if (!cameraControls) { + return null + } + return { + minDistance: cameraControls.minDistance, + maxDistance: cameraControls.maxDistance, + } + }) + + // Verify zoom limits are set (if available) + if (zoomLimits) { + expect(zoomLimits.minDistance).toBeGreaterThan(0) + expect(zoomLimits.maxDistance).toBeGreaterThan(Number(zoomLimits.minDistance)) + } + }) + + test('Camera planes adapt to model size - Screenshot', async ({page}) => { + // Wait for model to be fully loaded + await waitForModelReady(page) + + // Get initial camera properties + const initialCameraProps = await page.evaluate(() => { + const store = (window as unknown as WindowWithStore).store + if (!store) { + return null + } + const viewer = store.getState().viewer + if (!viewer) { + return null + } + const camera = viewer.context?.getCamera() + if (!camera) { + return null + } + return { + near: camera.near, + far: camera.far, + } + }) + + expect(initialCameraProps).not.toBeNull() + + // Take screenshot showing the model fits in view + await expectScreen(page, 'view-camera-model-fits.png') + + // Verify the camera far plane is sufficient for the model + if (initialCameraProps) { + // The far plane should be large enough (at least several thousand units) + // for typical building models + const minFarPlane = 1000 + expect(initialCameraProps.far).toBeGreaterThan(minFarPlane) + } + }) + }) +}) + + +type WindowWithStore = Window & { + store?: { + getState: () => { + viewer?: { + context?: { + getCamera: () => { + near: number + far: number + } | null + } + IFC?: { + context?: { + ifcCamera?: { + cameraControls?: { + minDistance?: number + maxDistance?: number + } + } + } + } + } + } + } +} + diff --git a/src/view/View.spec.ts-snapshots/view-camera-default.png b/src/view/View.spec.ts-snapshots/view-camera-default.png new file mode 100644 index 000000000..ac8f31b91 Binary files /dev/null and b/src/view/View.spec.ts-snapshots/view-camera-default.png differ diff --git a/src/view/View.spec.ts-snapshots/view-camera-model-fits.png b/src/view/View.spec.ts-snapshots/view-camera-model-fits.png new file mode 100644 index 000000000..ac8f31b91 Binary files /dev/null and b/src/view/View.spec.ts-snapshots/view-camera-model-fits.png differ