diff --git a/apps/web/src/lib/components/Calculator3dShell.svelte b/apps/web/src/lib/components/Calculator3dShell.svelte index 7f42fa5..79488a8 100644 --- a/apps/web/src/lib/components/Calculator3dShell.svelte +++ b/apps/web/src/lib/components/Calculator3dShell.svelte @@ -7,6 +7,7 @@ } from '$lib/document/presets-3d'; import { Scene3dPresentationController } from '$lib/engine/scene3d-presentation-controller'; import type { Bounds3d, Expression3d, GraphDocument3d, RenderScene3d } from '$lib/engine/types'; + import { scaleBounds3d } from '$lib/graph/canvas/bounds-3d'; import ExpressionPanel3d from './ExpressionPanel3d.svelte'; import GraphCanvas3d from './GraphCanvas3d.svelte'; @@ -22,6 +23,8 @@ } let bounds = $state({ ...gyroidPreset3d.bounds }); + let resetBounds = $state({ ...gyroidPreset3d.bounds }); + let fitViewKey = $state(0); let expressions = $state(expressionsForPreset(gyroidPreset3d)); let scene = $state(null); let busy = $state(false); @@ -76,10 +79,27 @@ if (!preset) return; expressions = expressionsForPreset(preset); bounds = { ...preset.bounds }; + resetBounds = { ...preset.bounds }; + fitViewKey += 1; variableOverrides = {}; selectedId = expressions[0]?.id ?? null; nextId = expressions.length + 1; } + function updateBounds(next: Bounds3d) { + if ( + bounds.x_min === next.x_min && bounds.x_max === next.x_max && + bounds.y_min === next.y_min && bounds.y_max === next.y_max && + bounds.z_min === next.z_min && bounds.z_max === next.z_max + ) return; + bounds = { ...next }; + } + function resizeBounds(factor: number) { + const next = scaleBounds3d(bounds, factor); + if (next) bounds = next; + } + function restoreBounds() { + bounds = { ...resetBounds }; + } @@ -100,8 +120,12 @@ onreorder={reorderExpression} onvariablechange={updateVariable} onpreset={loadPreset} + {bounds} + onboundschange={updateBounds} + onboundsreset={restoreBounds} + onboundsresize={resizeBounds} /> -
+
diff --git a/apps/web/src/lib/document/presets-3d.test.ts b/apps/web/src/lib/document/presets-3d.test.ts index 2e5e8f0..dacdc56 100644 --- a/apps/web/src/lib/document/presets-3d.test.ts +++ b/apps/web/src/lib/document/presets-3d.test.ts @@ -71,5 +71,8 @@ describe('3D graph presets', () => { expect(findGraphPreset3d('orbitals-2p-z')?.expressions.slice(1).map(({ color }) => color)) .toEqual(['#2563eb', '#dc2626']); expect(findGraphPreset3d('orbitals-3d-z2')?.expressions).toHaveLength(3); + expect(findGraphPreset3d('orbitals-3d-z2')?.bounds).toEqual({ + x_min: -24, x_max: 24, y_min: -24, y_max: 24, z_min: -27, z_max: 27 + }); }); }); diff --git a/apps/web/src/lib/document/presets-3d.ts b/apps/web/src/lib/document/presets-3d.ts index f15968f..63859ec 100644 --- a/apps/web/src/lib/document/presets-3d.ts +++ b/apps/web/src/lib/document/presets-3d.ts @@ -30,11 +30,12 @@ const DEFAULT_BOUNDS_3D: Bounds3d = { const preset = ( id: string, name: string, - expressions: PresetExpression3d | PresetExpression3d[] + expressions: PresetExpression3d | PresetExpression3d[], + bounds: Bounds3d = DEFAULT_BOUNDS_3D ): GraphPreset3d => ({ id, name, - bounds: { ...DEFAULT_BOUNDS_3D }, + bounds: { ...bounds }, expressions: Array.isArray(expressions) ? expressions : [expressions] }); @@ -259,7 +260,7 @@ export const presetGroups3d: GraphPresetGroup3d[] = [ source: '(2*z^2-x^2-y^2)*exp(-sqrt(x^2+y^2+z^2)/3)=-c', color: '#dc2626' } - ]) + ], { x_min: -24, x_max: 24, y_min: -24, y_max: 24, z_min: -27, z_max: 27 }) ] }, { diff --git a/apps/web/src/lib/engine/scene3d-presentation-controller.test.ts b/apps/web/src/lib/engine/scene3d-presentation-controller.test.ts index 93f151b..654be0b 100644 --- a/apps/web/src/lib/engine/scene3d-presentation-controller.test.ts +++ b/apps/web/src/lib/engine/scene3d-presentation-controller.test.ts @@ -131,6 +131,22 @@ describe('Scene3dPresentationController', () => { expect(test.publish).toHaveBeenCalledTimes(1); }); + it('treats a bounds-only edit as a new preview instead of an override update', async () => { + const test = harness(); + test.controller.setDocument(document); + test.previews[0].resolve(scene()); + await test.previews[0].promise; + + test.controller.setDocument({ + ...document, + bounds: { ...bounds, z_min: -2, z_max: 2 } + }); + + expect(test.cancel).toHaveBeenCalledTimes(1); + expect(test.previews).toHaveLength(2); + expect(test.publish).toHaveBeenCalledTimes(1); + }); + it('publishes several refined parts once per animation frame', async () => { const test = harness(); test.controller.setDocument(document); diff --git a/apps/web/src/lib/graph/canvas/bounds-3d.test.ts b/apps/web/src/lib/graph/canvas/bounds-3d.test.ts new file mode 100644 index 0000000..1c57400 --- /dev/null +++ b/apps/web/src/lib/graph/canvas/bounds-3d.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { + boundsCenter3d, + cameraFitForBounds, + isValidBounds3d, + parseBounds3dDrafts, + scaleBounds3d +} from './bounds-3d'; + +const bounds = { x_min: -2, x_max: 6, y_min: 3, y_max: 5, z_min: -10, z_max: 2 }; + +describe('3D bounds helpers', () => { + it('validates finite ordered rectangular ranges', () => { + expect(isValidBounds3d(bounds)).toBe(true); + expect(isValidBounds3d({ ...bounds, x_min: Number.NaN })).toBe(false); + expect(isValidBounds3d({ ...bounds, y_min: 5 })).toBe(false); + }); + + it('scales each span around its existing center', () => { + expect(scaleBounds3d(bounds, 0.5)).toEqual({ + x_min: 0, x_max: 4, y_min: 3.5, y_max: 4.5, z_min: -7, z_max: -1 + }); + expect(boundsCenter3d(bounds)).toEqual([2, 4, -4]); + }); + + it('rejects invalid or numerically collapsed scaling', () => { + expect(scaleBounds3d(bounds, 0)).toBeUndefined(); + expect(scaleBounds3d(bounds, Number.POSITIVE_INFINITY)).toBeUndefined(); + expect(scaleBounds3d({ + x_min: Number.MAX_VALUE / 2, + x_max: Number.MAX_VALUE, + y_min: 0, + y_max: 1, + z_min: 0, + z_max: 1 + }, 4)).toBeUndefined(); + }); + + it('parses drafts and rejects invalid values', () => { + expect(parseBounds3dDrafts({ + x_min: '-2', x_max: '6', y_min: '3', y_max: '5', z_min: '-10', z_max: '2' + })).toEqual(bounds); + expect(parseBounds3dDrafts({ + x_min: 'x', x_max: '6', y_min: '3', y_max: '5', z_min: '-10', z_max: '2' + })).toBeUndefined(); + expect(parseBounds3dDrafts({ + x_min: '', x_max: '6', y_min: '3', y_max: '5', z_min: '-10', z_max: '2' + })).toBeUndefined(); + expect(parseBounds3dDrafts({ + x_min: '-2e1', x_max: '6e1', y_min: '3', y_max: '5', z_min: '-10', z_max: '2' + })).toMatchObject({ x_min: -20, x_max: 60 }); + }); + + it('produces a finite fit for tall rectangular bounds', () => { + const fit = cameraFitForBounds({ x_min: -24, x_max: 24, y_min: -24, y_max: 24, z_min: -27, z_max: 27 }, 0.8); + expect(fit.distance).toBeGreaterThan(0); + expect(fit.near).toBeGreaterThan(0); + expect(fit.far).toBeGreaterThan(fit.distance); + }); +}); diff --git a/apps/web/src/lib/graph/canvas/bounds-3d.ts b/apps/web/src/lib/graph/canvas/bounds-3d.ts new file mode 100644 index 0000000..180a115 --- /dev/null +++ b/apps/web/src/lib/graph/canvas/bounds-3d.ts @@ -0,0 +1,72 @@ +import type { Bounds3d } from '$lib/engine/types'; + +export const BOUNDS_3D_KEYS = ['x_min', 'x_max', 'y_min', 'y_max', 'z_min', 'z_max'] as const; +export type Bounds3dKey = (typeof BOUNDS_3D_KEYS)[number]; + +export function isValidBounds3d(bounds: Bounds3d): boolean { + return BOUNDS_3D_KEYS.every((key) => Number.isFinite(bounds[key])) && + bounds.x_min < bounds.x_max && bounds.y_min < bounds.y_max && bounds.z_min < bounds.z_max; +} + +export function scaleBounds3d(bounds: Bounds3d, factor: number): Bounds3d | undefined { + if (!isValidBounds3d(bounds) || !Number.isFinite(factor) || factor <= 0) return undefined; + const scale = (min: number, max: number) => { + const center = (min + max) / 2; + const halfSpan = (max - min) * factor / 2; + return [center - halfSpan, center + halfSpan] as const; + }; + const [x_min, x_max] = scale(bounds.x_min, bounds.x_max); + const [y_min, y_max] = scale(bounds.y_min, bounds.y_max); + const [z_min, z_max] = scale(bounds.z_min, bounds.z_max); + const scaled = { x_min, x_max, y_min, y_max, z_min, z_max }; + return isValidBounds3d(scaled) ? scaled : undefined; +} + +export function parseBounds3dDrafts(drafts: Record): Bounds3d | undefined { + if (BOUNDS_3D_KEYS.some((key) => drafts[key].trim() === '')) return undefined; + const values: Bounds3d = { + x_min: Number(drafts.x_min), x_max: Number(drafts.x_max), + y_min: Number(drafts.y_min), y_max: Number(drafts.y_max), + z_min: Number(drafts.z_min), z_max: Number(drafts.z_max) + }; + return isValidBounds3d(values) ? values : undefined; +} + +export interface CameraFit3d { + distance: number; + near: number; + far: number; +} + +/** A conservative distance that fits the bounds from any camera direction. */ +export function cameraFitForBounds( + bounds: Bounds3d, + aspect: number, + verticalFovDegrees = 45, + padding = 1.2 +): CameraFit3d { + const dx = bounds.x_max - bounds.x_min; + const dy = bounds.y_max - bounds.y_min; + const dz = bounds.z_max - bounds.z_min; + const radius = Math.max(1e-6, Math.sqrt(dx * dx + dy * dy + dz * dz) / 2); + const safeAspect = Math.max(1e-3, aspect); + const verticalFov = (verticalFovDegrees * Math.PI) / 180; + const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * safeAspect); + const distance = Math.max( + radius / Math.sin(verticalFov / 2), + radius / Math.sin(horizontalFov / 2) + ) * Math.max(1, padding); + return { + distance, + near: Math.max(0.01, distance - radius * 2.5), + far: Math.max(distance + radius * 4, distance * 2) + }; +} + +export function boundsCenter3d(bounds: Bounds3d): [number, number, number] { + return [ + (bounds.x_min + bounds.x_max) / 2, + (bounds.y_min + bounds.y_max) / 2, + (bounds.z_min + bounds.z_max) / 2 + ]; +} diff --git a/apps/web/src/lib/graph/canvas/canvas-renderer-3d.ts b/apps/web/src/lib/graph/canvas/canvas-renderer-3d.ts index b1b42e0..3193835 100644 --- a/apps/web/src/lib/graph/canvas/canvas-renderer-3d.ts +++ b/apps/web/src/lib/graph/canvas/canvas-renderer-3d.ts @@ -5,6 +5,7 @@ import { LineSegments2 } from 'three/examples/jsm/lines/LineSegments2.js'; import { LineSegmentsGeometry } from 'three/examples/jsm/lines/LineSegmentsGeometry.js'; import type { Bounds3d, GeometryResource3d, RenderScene3d } from '$lib/engine/types'; +import { boundsCenter3d, cameraFitForBounds, isValidBounds3d } from './bounds-3d'; const GPU_SURFACE_GRID_SIZE = 257; @@ -28,6 +29,11 @@ export class CanvasRenderer3d { private guides?: THREE.Group; private root?: HTMLElement; private resources = new Map(); + private wheelCanvas?: HTMLCanvasElement; + private wheelHandler?: (event: WheelEvent) => void; + private wheelFrame?: number; + private pendingBoundsScale = 1; + private onBoundsScale?: (factor: number) => void; private gpuGrid?: THREE.BufferGeometry; private bounds: Bounds3d = { x_min: -10, @@ -38,8 +44,16 @@ export class CanvasRenderer3d { z_max: 10 }; - attach(root: HTMLElement, canvas: HTMLCanvasElement, onChange: () => void) { + attach( + root: HTMLElement, + canvas: HTMLCanvasElement, + onChange: () => void, + initialBounds?: Bounds3d, + onBoundsScale?: (factor: number) => void + ) { this.root = root; + if (initialBounds && isValidBounds3d(initialBounds)) this.bounds = { ...initialBounds }; + this.onBoundsScale = onBoundsScale; if (!canvas.getContext('webgl2')) { throw new Error('WebGL2 is unavailable in this browser or has been disabled'); } @@ -57,12 +71,32 @@ export class CanvasRenderer3d { // animation loop) must remain disabled. this.controls.enableDamping = false; this.controls.addEventListener('change', onChange); + this.wheelCanvas = canvas; + this.wheelHandler = (event: WheelEvent) => { + if (!event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return; + event.preventDefault(); + // OrbitControls also listens on this canvas. Stop listeners on the same + // target so Shift+wheel never performs a camera dolly as well. + event.stopImmediatePropagation(); + const delta = Number.isFinite(event.deltaY) && event.deltaY !== 0 ? event.deltaY : event.deltaX; + if (!Number.isFinite(delta) || delta === 0) return; + this.pendingBoundsScale *= Math.exp(Math.max(-0.2, Math.min(0.2, delta * 0.0015))); + if (this.wheelFrame !== undefined) return; + this.wheelFrame = requestAnimationFrame(() => { + this.wheelFrame = undefined; + const factor = this.pendingBoundsScale; + this.pendingBoundsScale = 1; + if (factor !== 1) this.onBoundsScale?.(factor); + }); + }; + canvas.addEventListener('wheel', this.wheelHandler, { capture: true, passive: false }); this.scene.add(new THREE.AmbientLight(0xffffff, 1.4)); const directional = new THREE.DirectionalLight(0xffffff, 2.2); directional.position.set(10, 16, 12); this.scene.add(directional); this.installGuides(this.bounds); this.resize(); + this.fitView(); } resize() { @@ -75,20 +109,56 @@ export class CanvasRenderer3d { this.renderer.setSize(width, height, false); } - resetView() { + setBounds(bounds: Bounds3d) { + if (!isValidBounds3d(bounds) || sameBounds(bounds, this.bounds)) return; + this.bounds = { ...bounds }; + this.installGuides(this.bounds); + this.updateCameraPlanes(); + const clipping = clippingPlanes(this.bounds); + for (const retained of this.resources.values()) { + for (const material of retained.materials) material.clippingPlanes = clipping; + if (retained.gpu) { + const uniforms = retained.gpu.material.uniforms; + (uniforms.u_bounds_min?.value as THREE.Vector3 | undefined)?.set(this.bounds.x_min, this.bounds.y_min, this.bounds.z_min); + (uniforms.u_bounds_max?.value as THREE.Vector3 | undefined)?.set(this.bounds.x_max, this.bounds.y_max, this.bounds.z_max); + } + } + } + + fitView() { if (!this.camera || !this.controls) return; - this.camera.position.set(18, 14, 18); - this.controls.target.set(0, 0, 0); + const [x, y, z] = boundsCenter3d(this.bounds); + const center = new THREE.Vector3(x, y, z); + const direction = this.camera.position.clone().sub(this.controls.target); + if (direction.lengthSq() < 1e-8) direction.set(1, 0.78, 1); + direction.normalize(); + const fit = cameraFitForBounds(this.bounds, this.camera.aspect, this.camera.fov, 1.2); + this.camera.near = fit.near; + this.camera.far = fit.far; + this.camera.updateProjectionMatrix(); + this.controls.target.copy(center); + this.camera.position.copy(center).addScaledVector(direction, fit.distance); this.controls.update(); } + private updateCameraPlanes() { + if (!this.camera) return; + const [x, y, z] = boundsCenter3d(this.bounds); + const center = new THREE.Vector3(x, y, z); + const radius = Math.max(1e-6, boundsDiagonal(this.bounds) / 2); + const distance = this.camera.position.distanceTo(center); + this.camera.near = Math.max(0.01, distance - radius * 2.5); + this.camera.far = Math.max(distance + radius * 4, distance * 2); + this.camera.updateProjectionMatrix(); + } + + resetView() { + this.fitView(); + } + draw(scene: RenderScene3d | null) { if (!this.renderer || !this.scene || !this.camera) return; const startedAt = performance.now(); - if (scene && !sameBounds(scene.bounds, this.bounds)) { - this.bounds = scene.bounds; - this.installGuides(scene.bounds); - } const reconciliationAt = performance.now(); if (scene) this.reconcileResources(scene); const reconciledAt = performance.now(); @@ -111,6 +181,13 @@ export class CanvasRenderer3d { this.gpuGrid?.dispose(); this.gpuGrid = undefined; this.controls?.dispose(); + if (this.wheelCanvas && this.wheelHandler) this.wheelCanvas.removeEventListener('wheel', this.wheelHandler, { capture: true }); + if (this.wheelFrame !== undefined) cancelAnimationFrame(this.wheelFrame); + this.wheelCanvas = undefined; + this.wheelHandler = undefined; + this.wheelFrame = undefined; + this.pendingBoundsScale = 1; + this.onBoundsScale = undefined; if (this.guides) disposeObject(this.guides); this.renderer?.dispose(); this.scene?.clear(); @@ -126,22 +203,23 @@ export class CanvasRenderer3d { const guides = new THREE.Group(); guides.userData.cartesiumGuide = true; this.guides = guides; - const size = Math.max(bounds.x_max - bounds.x_min, bounds.y_max - bounds.y_min, bounds.z_max - bounds.z_min); const center = new THREE.Vector3( (bounds.x_min + bounds.x_max) / 2, (bounds.y_min + bounds.y_max) / 2, (bounds.z_min + bounds.z_max) / 2 ); - const grid = new THREE.GridHelper( - Math.max(bounds.x_max - bounds.x_min, bounds.y_max - bounds.y_min), - 20, - 0x94a3b8, - 0xcbd5e1 - ); - // Three's GridHelper starts in XZ; Cartesium uses Z as the up axis. - grid.rotation.x = Math.PI / 2; - grid.position.set(center.x, center.y, Math.max(bounds.z_min, Math.min(0, bounds.z_max))); - guides.add(grid); + const gridZ = Math.max(bounds.z_min, Math.min(0, bounds.z_max)); + const gridPositions: number[] = []; + const gridSteps = 10; + for (let index = 0; index <= gridSteps; index += 1) { + const y = bounds.y_min + (bounds.y_max - bounds.y_min) * index / gridSteps; + gridPositions.push(bounds.x_min, y, gridZ, bounds.x_max, y, gridZ); + const x = bounds.x_min + (bounds.x_max - bounds.x_min) * index / gridSteps; + gridPositions.push(x, bounds.y_min, gridZ, x, bounds.y_max, gridZ); + } + const gridGeometry = new THREE.BufferGeometry(); + gridGeometry.setAttribute('position', new THREE.Float32BufferAttribute(gridPositions, 3)); + guides.add(new THREE.LineSegments(gridGeometry, new THREE.LineBasicMaterial({ color: 0xcbd5e1, transparent: true, opacity: 0.8 }))); const edges = new THREE.LineSegments( new THREE.EdgesGeometry(new THREE.BoxGeometry( bounds.x_max - bounds.x_min, @@ -152,8 +230,20 @@ export class CanvasRenderer3d { ); edges.position.copy(center); guides.add(edges); - const axes = new THREE.AxesHelper(size * 0.55); - guides.add(axes); + const addAxis = (positions: number[], color: number) => { + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); + guides.add(new THREE.LineSegments(geometry, new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.8 }))); + }; + if (bounds.y_min <= 0 && bounds.y_max >= 0 && bounds.z_min <= 0 && bounds.z_max >= 0) { + addAxis([bounds.x_min, 0, 0, bounds.x_max, 0, 0], 0xdc2626); + } + if (bounds.x_min <= 0 && bounds.x_max >= 0 && bounds.z_min <= 0 && bounds.z_max >= 0) { + addAxis([0, bounds.y_min, 0, 0, bounds.y_max, 0], 0x16a34a); + } + if (bounds.x_min <= 0 && bounds.x_max >= 0 && bounds.y_min <= 0 && bounds.y_max >= 0) { + addAxis([0, 0, bounds.z_min, 0, 0, bounds.z_max], 0x2563eb); + } this.scene.add(guides); } @@ -205,7 +295,7 @@ export class CanvasRenderer3d { roughness: 0.7, metalness: 0.05, side: THREE.DoubleSide, - clippingPlanes: clippingPlanes(scene.bounds) + clippingPlanes: clippingPlanes(this.bounds) }); return { object: new THREE.Mesh(geometry, material), @@ -224,7 +314,7 @@ export class CanvasRenderer3d { color: resource.color, worldUnits: false, alphaToCoverage: true, - clippingPlanes: clippingPlanes(scene.bounds) + clippingPlanes: clippingPlanes(this.bounds) }); (material as LineMaterial & { linewidth: number }).linewidth = 2.5; const object = new LineSegments2(geometry, material); @@ -238,14 +328,14 @@ export class CanvasRenderer3d { if (resource.type === 'vector') { const data = packed.vectors.find((candidate) => candidate.geometryId === resource.geometry_id); if (!data || data.positions.length < 6) return undefined; - const clipping = clippingPlanes(scene.bounds); + const clipping = clippingPlanes(this.bounds); const object = new THREE.Group(); const geometries: THREE.BufferGeometry[] = []; const materials: THREE.Material[] = []; const shaftPositions: number[] = []; const zeroPositions: number[] = []; const coneInstances: Array<{ tip: THREE.Vector3; direction: THREE.Vector3; headLength: number }> = []; - const headLimit = 0.04 * boundsDiagonal(scene.bounds); + const headLimit = 0.04 * boundsDiagonal(this.bounds); for (let index = 0; index + 5 < data.positions.length; index += 6) { const tail = new THREE.Vector3(data.positions[index], data.positions[index + 1], data.positions[index + 2]); const tip = new THREE.Vector3(data.positions[index + 3], data.positions[index + 4], data.positions[index + 5]); @@ -335,7 +425,7 @@ export class CanvasRenderer3d { color: resource.color, size: 7, sizeAttenuation: false, - clippingPlanes: clippingPlanes(scene.bounds) + clippingPlanes: clippingPlanes(this.bounds) }); return { object: new THREE.Points(geometry, material), @@ -351,8 +441,8 @@ export class CanvasRenderer3d { const geometry = this.gpuGrid ?? (this.gpuGrid = createGpuGrid(GPU_SURFACE_GRID_SIZE)); const valueUniforms = new Map(); const uniforms: Record = { - u_bounds_min: { value: new THREE.Vector3(resource.bounds.x_min, resource.bounds.y_min, resource.bounds.z_min) }, - u_bounds_max: { value: new THREE.Vector3(resource.bounds.x_max, resource.bounds.y_max, resource.bounds.z_max) }, + u_bounds_min: { value: new THREE.Vector3(this.bounds.x_min, this.bounds.y_min, this.bounds.z_min) }, + u_bounds_max: { value: new THREE.Vector3(this.bounds.x_max, this.bounds.y_max, this.bounds.z_max) }, u_color: { value: new THREE.Color(resource.color) } }; for (const uniform of resource.uniforms) { @@ -428,7 +518,7 @@ export class CanvasRenderer3d { } `, side: THREE.DoubleSide, - clippingPlanes: clippingPlanes(scene.bounds) + clippingPlanes: clippingPlanes(this.bounds) }); const object = new THREE.Mesh(geometry, material); object.frustumCulled = false; diff --git a/docs/architecture.md b/docs/architecture.md index 798d923..cb35ea9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -764,6 +764,14 @@ limited. ### Progressive 3D implicit surfaces +The 3D shell owns rectangular mathematical bounds independently from the +perspective camera. Ordinary wheel input dollies the camera without rebuilding +geometry; Shift+wheel and the bounds editor change the document bounds and use +the normal cancellable preview/refinement path. Camera fitting targets the +bounds center and adjusts projection planes without changing those mathematical +ranges. Enlarging the bounds reduces samples per world-space unit while the +implicit quality levels remain fixed. + Implicit 3D scenes have two fixed quality levels. Quality `0` samples a 33³ point lattice (eight cells per fixed brick) for the immediate preview; quality `1` samples the existing 65³ lattice (sixteen cells per brick) for settled diff --git a/docs/project-status.md b/docs/project-status.md index f7be22b..46ba8e7 100644 --- a/docs/project-status.md +++ b/docs/project-status.md @@ -831,8 +831,15 @@ bounded two-level quadtree refinement. Parametric surfaces retain their 65×65 grid. Both use finite-sample filtering and Rust-generated smooth normals and retain geometry identities across color-only changes. -The 3D route is intentionally memory-only and uses fixed `[-10,10]^3` bounds. -Camera orbit/pan/zoom never rebuilds Rust geometry. Equalities such as +The 3D route is intentionally memory-only and starts with rectangular preset +bounds (the default is `[-10,10]^3`, while the 3d_z² preset uses a taller +z-range). Plot bounds are independently editable in the 3D settings panel; +Expand/Contract buttons and Shift+wheel scale all three ranges around their +centers, while ordinary wheel zoom remains camera dolly. Fit camera targets the +current bounds and updates clipping and camera planes without changing the +mathematical ranges. Camera orbit/pan/zoom never rebuilds Rust geometry. +Enlarging bounds currently lowers implicit samples per world unit because the +fixed lattice remains 65 points per axis. Equalities such as `x^2 + y^2 + z^2 = 25` and the 3D shorthand `y = f(x,z)` now produce fixed implicit meshes. The 65-point global lattice is partitioned into 4×4×4 fixed bricks; only non-empty brick meshes are transferred, with symbolic-gradient