From f6883e80c4acb63d86342a159caf18a3644896e1 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Wed, 25 Feb 2026 23:05:26 +1300 Subject: [PATCH 01/14] Cursor: Apply local changes for cloud agent --- .../kernels/replicad/replicad.kernel.test.ts | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/kernels/src/kernels/replicad/replicad.kernel.test.ts b/packages/kernels/src/kernels/replicad/replicad.kernel.test.ts index c3e99ae7c..c16a7a548 100644 --- a/packages/kernels/src/kernels/replicad/replicad.kernel.test.ts +++ b/packages/kernels/src/kernels/replicad/replicad.kernel.test.ts @@ -1704,20 +1704,12 @@ export default function main() { // --- Library frames: replicad call chain with source-mapped TS positions --- const libraryFrames = issue.stackFrames?.filter((frame) => frame.context === 'library'); - expect(libraryFrames).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - functionName: 'Sketch.extrude', - fileName: 'replicad/src/sketches/Sketch.ts', - context: 'library', - }), - expect.objectContaining({ - functionName: 'basicFaceExtrusion', - fileName: 'replicad/src/addThickness.ts', - context: 'library', - }), - ]), - ); + const sketchExtrudeFrame = libraryFrames?.find((frame) => frame.functionName === 'Sketch.extrude'); + const basicFaceExtrusionFrame = libraryFrames?.find((frame) => frame.functionName === 'basicFaceExtrusion'); + expect(sketchExtrudeFrame).toBeDefined(); + expect(basicFaceExtrusionFrame).toBeDefined(); + expect(sketchExtrudeFrame?.fileName).toMatch(/replicad\/(src\/sketches\/Sketch\.ts|dist\/replicad\.js)/); + expect(basicFaceExtrusionFrame?.fileName).toMatch(/replicad\/(src\/addThickness\.ts|dist\/replicad\.js)/); // --- Framework frames: proxy infrastructure --- const frameworkNames = issue.stackFrames From fc05db40c968418ab017fbd41891811119876dae Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 25 Feb 2026 10:17:39 +0000 Subject: [PATCH 02/14] feat(three): add utility files from apps/ui geometry components Copy utility files from apps/ui/app/components/geometry/graphics/three/utils/ to packages/three/src/utils/: - camera.utils.ts, camera.utils.test.ts - gizmo.utils.ts - lights.utils.ts, lights.utils.test.ts - math.utils.ts, math.utils.test.ts - rotation.utils.ts - snap-detection.utils.ts - spatial.utils.ts Add color.utils.ts with adjustHexColorBrightness (from apps/ui color.utils.ts). Update all internal imports: #components/geometry/graphics/three/ -> # Co-authored-by: richard --- packages/three/src/utils/camera.utils.test.ts | 638 +++++++++++++++ packages/three/src/utils/camera.utils.ts | 339 ++++++++ packages/three/src/utils/color.utils.ts | 36 + packages/three/src/utils/gizmo.utils.ts | 135 ++++ packages/three/src/utils/lights.utils.test.ts | 762 ++++++++++++++++++ packages/three/src/utils/lights.utils.ts | 311 +++++++ packages/three/src/utils/math.utils.test.ts | 383 +++++++++ packages/three/src/utils/math.utils.ts | 195 +++++ packages/three/src/utils/rotation.utils.ts | 67 ++ .../three/src/utils/snap-detection.utils.ts | 697 ++++++++++++++++ packages/three/src/utils/spatial.utils.ts | 18 + 11 files changed, 3581 insertions(+) create mode 100644 packages/three/src/utils/camera.utils.test.ts create mode 100644 packages/three/src/utils/camera.utils.ts create mode 100644 packages/three/src/utils/color.utils.ts create mode 100644 packages/three/src/utils/gizmo.utils.ts create mode 100644 packages/three/src/utils/lights.utils.test.ts create mode 100644 packages/three/src/utils/lights.utils.ts create mode 100644 packages/three/src/utils/math.utils.test.ts create mode 100644 packages/three/src/utils/math.utils.ts create mode 100644 packages/three/src/utils/rotation.utils.ts create mode 100644 packages/three/src/utils/snap-detection.utils.ts create mode 100644 packages/three/src/utils/spatial.utils.ts diff --git a/packages/three/src/utils/camera.utils.test.ts b/packages/three/src/utils/camera.utils.test.ts new file mode 100644 index 000000000..b4e726b90 --- /dev/null +++ b/packages/three/src/utils/camera.utils.test.ts @@ -0,0 +1,638 @@ +import { describe, expect, it, vi } from 'vitest'; +import * as THREE from 'three'; +import { computeViewFittingZoom, updateCameraFov, resetCamera } from '#utils/camera.utils.js'; +import { calculateFovFromAngle, calculateFovDistanceCompensation, tanEpsilon } from '#utils/math.utils.js'; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +/** Creates a PerspectiveCamera with sensible defaults. */ +function createTestCamera(fov = 54, distance = 10): THREE.PerspectiveCamera { + const camera = new THREE.PerspectiveCamera(fov, 16 / 9, 0.1, 1000); + camera.position.set(0, 0, distance); + camera.lookAt(0, 0, 0); + camera.updateMatrixWorld(true); + return camera; +} + +/** Creates an axis-aligned bounding box centred at `center` with given half-extents. */ +// eslint-disable-next-line max-params -- test helper, simple positional args are clearer here +function makeBox(center: THREE.Vector3, hx: number, hy: number, hz: number): THREE.Box3 { + return new THREE.Box3( + new THREE.Vector3(center.x - hx, center.y - hy, center.z - hz), + new THREE.Vector3(center.x + hx, center.y + hy, center.z + hz), + ); +} + +/** Builds the default perspective config used by resetCamera tests. */ +function defaultPerspective( + overrides?: Partial<{ + offsetRatio: number; + zoomLevel: number; + nearPlane: number; + minimumFarPlane: number; + farPlaneRadiusMultiplier: number; + }>, +): { + offsetRatio: number; + zoomLevel: number; + nearPlane: number; + minimumFarPlane: number; + farPlaneRadiusMultiplier: number; +} { + return { + offsetRatio: 2, + zoomLevel: 1, + nearPlane: 1e-3, + minimumFarPlane: 10_000_000_000, + farPlaneRadiusMultiplier: 5, + ...overrides, + }; +} + +// ── computeViewFittingZoom ────────────────────────────────────────────────── + +describe('computeViewFittingZoom', () => { + const fov = 45; + const squareAspect = 1; + + describe('perspective correctness', () => { + it('should produce lower zoom for a tall box viewed from below than orthographic formula', () => { + const distance = 20; + const halfZ = 8; + const halfXy = 2; + const tallBox = makeBox(new THREE.Vector3(0, 0, 0), halfXy, halfXy, halfZ); + const tanHalf = Math.tan((fov / 2) * (Math.PI / 180)); + + const zoom = computeViewFittingZoom({ + cameraPosition: new THREE.Vector3(0, 0, -distance), + target: new THREE.Vector3(0, 0, 0), + boundingBox: tallBox, + fovDeg: fov, + aspectRatio: squareAspect, + paddingFactor: 1, + }); + + // Orthographic would give d * tan / halfXY + const orthographicZoom = (distance * tanHalf) / halfXy; + // Perspective: closest corners at z = -halfZ, forward distance = distance - halfZ = 12 + const perspectiveZoom = ((distance - halfZ) * tanHalf) / halfXy; + + expect(zoom).toBeCloseTo(perspectiveZoom, 5); + expect(zoom).toBeLessThan(orthographicZoom); + }); + + it('should match orthographic formula for a flat box with no depth along viewing axis', () => { + const distance = 10; + const halfExtent = 3; + const flatBox = makeBox(new THREE.Vector3(0, 0, 0), halfExtent, halfExtent, 0.001); + const tanHalf = Math.tan((fov / 2) * (Math.PI / 180)); + + const zoom = computeViewFittingZoom({ + cameraPosition: new THREE.Vector3(0, 0, distance), + target: new THREE.Vector3(0, 0, 0), + boundingBox: flatBox, + fovDeg: fov, + aspectRatio: squareAspect, + paddingFactor: 1, + }); + + const orthographicZoom = (distance * tanHalf) / halfExtent; + expect(zoom).toBeCloseTo(orthographicZoom, 1); + }); + }); + + describe('symmetric cube at target', () => { + it('should agree with perspective formula for cube centered at target', () => { + const distance = 10; + const halfExtent = 1; + const tanHalf = Math.tan((fov / 2) * (Math.PI / 180)); + // Closest corners at forward distance (d - halfExtent) + const expectedZoom = ((distance - halfExtent) * tanHalf) / halfExtent; + + const zoom = computeViewFittingZoom({ + cameraPosition: new THREE.Vector3(0, 0, distance), + target: new THREE.Vector3(0, 0, 0), + boundingBox: makeBox(new THREE.Vector3(0, 0, 0), halfExtent, halfExtent, halfExtent), + fovDeg: fov, + aspectRatio: squareAspect, + paddingFactor: 1, + }); + + expect(zoom).toBeCloseTo(expectedZoom, 5); + }); + }); + + describe('aspect ratio', () => { + it('should produce higher zoom for landscape than portrait', () => { + const box = makeBox(new THREE.Vector3(0, 0, 0), 2, 1, 1); + const camera = new THREE.Vector3(0, 0, 10); + const target = new THREE.Vector3(0, 0, 0); + + const landscapeZoom = computeViewFittingZoom({ + cameraPosition: camera, + target, + boundingBox: box, + fovDeg: fov, + aspectRatio: 16 / 9, + paddingFactor: 1, + }); + + const portraitZoom = computeViewFittingZoom({ + cameraPosition: camera, + target, + boundingBox: box, + fovDeg: fov, + aspectRatio: 9 / 16, + paddingFactor: 1, + }); + + expect(landscapeZoom).toBeGreaterThan(portraitZoom); + }); + + it('should be constrained horizontally for a wide object with square aspect', () => { + const wideBox = makeBox(new THREE.Vector3(0, 0, 0), 4, 1, 1); + const tanHalf = Math.tan((fov / 2) * (Math.PI / 180)); + + const zoom = computeViewFittingZoom({ + cameraPosition: new THREE.Vector3(0, 0, 10), + target: new THREE.Vector3(0, 0, 0), + boundingBox: wideBox, + fovDeg: fov, + aspectRatio: squareAspect, + paddingFactor: 1, + }); + + // Closest corners at forward distance 9, horizontal tangent = 4/9 + // zoomH = aspect * tanHalf / (4/9) = 9 * tanHalf / 4 + const closestForward = 9; + const zoomH = (squareAspect * closestForward * tanHalf) / 4; + expect(zoom).toBeCloseTo(zoomH, 5); + }); + }); + + describe('viewpoint dependence', () => { + it('should zoom in more from the top than from the side for a tall box', () => { + const tallBox = makeBox(new THREE.Vector3(0, 0, 0), 1, 1, 5); + const target = new THREE.Vector3(0, 0, 0); + + const sideZoom = computeViewFittingZoom({ + cameraPosition: new THREE.Vector3(10, 0, 0), + target, + boundingBox: tallBox, + fovDeg: fov, + aspectRatio: squareAspect, + paddingFactor: 1, + }); + + const topZoom = computeViewFittingZoom({ + cameraPosition: new THREE.Vector3(0, 0, 10), + target, + boundingBox: tallBox, + fovDeg: fov, + aspectRatio: squareAspect, + paddingFactor: 1, + }); + + // Top view sees a small X x Y face -> higher zoom + expect(topZoom).toBeGreaterThan(sideZoom); + }); + }); + + describe('off-center geometry', () => { + it('should produce the same zoom when camera + target + bbox are shifted together', () => { + const halfExtent = 2; + + const centeredZoom = computeViewFittingZoom({ + cameraPosition: new THREE.Vector3(0, 0, 10), + target: new THREE.Vector3(0, 0, 0), + boundingBox: makeBox(new THREE.Vector3(0, 0, 0), halfExtent, halfExtent, halfExtent), + fovDeg: fov, + aspectRatio: squareAspect, + paddingFactor: 1, + }); + + const offset = new THREE.Vector3(100, 50, -30); + const offCenterZoom = computeViewFittingZoom({ + cameraPosition: new THREE.Vector3(offset.x, offset.y, 10 + offset.z), + target: offset.clone(), + boundingBox: makeBox(offset.clone(), halfExtent, halfExtent, halfExtent), + fovDeg: fov, + aspectRatio: squareAspect, + paddingFactor: 1, + }); + + expect(offCenterZoom).toBeCloseTo(centeredZoom, 5); + }); + }); + + describe('padding factor', () => { + it('should scale linearly with padding factor', () => { + const baseParameters = { + cameraPosition: new THREE.Vector3(0, 0, 10), + target: new THREE.Vector3(0, 0, 0), + boundingBox: makeBox(new THREE.Vector3(0, 0, 0), 1, 1, 1), + fovDeg: fov, + aspectRatio: squareAspect, + }; + + const zoomFull = computeViewFittingZoom({ ...baseParameters, paddingFactor: 1 }); + const zoomPadded = computeViewFittingZoom({ ...baseParameters, paddingFactor: 0.8 }); + + expect(zoomPadded / zoomFull).toBeCloseTo(0.8, 5); + }); + + it('should default to 0.9 when not specified', () => { + const baseParameters = { + cameraPosition: new THREE.Vector3(0, 0, 10), + target: new THREE.Vector3(0, 0, 0), + boundingBox: makeBox(new THREE.Vector3(0, 0, 0), 1, 1, 1), + fovDeg: fov, + aspectRatio: squareAspect, + }; + + const zoomDefault = computeViewFittingZoom(baseParameters); + const zoomExplicit = computeViewFittingZoom({ ...baseParameters, paddingFactor: 0.9 }); + + expect(zoomDefault).toBeCloseTo(zoomExplicit, 10); + }); + }); + + describe('degenerate cases', () => { + it('should return 1 when camera is at the target', () => { + const zoom = computeViewFittingZoom({ + cameraPosition: new THREE.Vector3(5, 5, 5), + target: new THREE.Vector3(5, 5, 5), + boundingBox: makeBox(new THREE.Vector3(5, 5, 5), 1, 1, 1), + fovDeg: fov, + aspectRatio: squareAspect, + }); + + expect(zoom).toBe(1); + }); + + it('should return 1 for a zero-extent bounding box', () => { + const zoom = computeViewFittingZoom({ + cameraPosition: new THREE.Vector3(0, 0, 10), + target: new THREE.Vector3(0, 0, 0), + boundingBox: new THREE.Box3(new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, 0)), + fovDeg: fov, + aspectRatio: squareAspect, + }); + + expect(zoom).toBe(1); + }); + + it('should handle camera looking straight down the up axis without error', () => { + const zoom = computeViewFittingZoom({ + cameraPosition: new THREE.Vector3(0, 0, 10), + target: new THREE.Vector3(0, 0, 0), + boundingBox: makeBox(new THREE.Vector3(0, 0, 0), 2, 3, 1), + fovDeg: fov, + aspectRatio: squareAspect, + paddingFactor: 1, + }); + + expect(zoom).toBeGreaterThan(0); + expect(Number.isFinite(zoom)).toBe(true); + }); + + it('should gracefully handle bbox corners behind the camera', () => { + // Camera very close; some bbox corners behind the camera plane + const zoom = computeViewFittingZoom({ + cameraPosition: new THREE.Vector3(0, 0, 2), + target: new THREE.Vector3(0, 0, 0), + boundingBox: makeBox(new THREE.Vector3(0, 0, 0), 1, 1, 5), + fovDeg: fov, + aspectRatio: squareAspect, + paddingFactor: 1, + }); + + // Corners at z = +5 are behind camera at z = 2 looking toward z = 0. + // Should still produce a valid positive zoom based on the visible corners. + expect(zoom).toBeGreaterThan(0); + expect(Number.isFinite(zoom)).toBe(true); + }); + }); +}); + +// ── updateCameraFov ───────────────────────────────────────────────────────── + +describe('updateCameraFov', () => { + it('should set the camera FOV from the given angle', () => { + const camera = createTestCamera(54, 10); + const invalidate = vi.fn(); + + updateCameraFov({ camera, cameraFovAngle: 60, invalidate }); + + expect(camera.fov).toBeCloseTo(calculateFovFromAngle(60), 10); + }); + + it('should adjust distance to maintain perceived size', () => { + const camera = createTestCamera(54, 10); + const invalidate = vi.fn(); + const oldDistance = camera.position.length(); + const oldFov = camera.fov; + + updateCameraFov({ camera, cameraFovAngle: 30, invalidate }); + + const newFov = camera.fov; + const expectedDistance = calculateFovDistanceCompensation(oldFov, newFov, oldDistance); + expect(camera.position.length()).toBeCloseTo(expectedDistance, 5); + }); + + it('should preserve camera direction after FOV change', () => { + const camera = createTestCamera(54, 10); + camera.position.set(3, 4, 5); + camera.updateMatrixWorld(true); + const invalidate = vi.fn(); + + const directionBefore = camera.position.clone().normalize(); + + updateCameraFov({ camera, cameraFovAngle: 75, invalidate }); + + const directionAfter = camera.position.clone().normalize(); + expect(directionAfter.x).toBeCloseTo(directionBefore.x, 10); + expect(directionAfter.y).toBeCloseTo(directionBefore.y, 10); + expect(directionAfter.z).toBeCloseTo(directionBefore.z, 10); + }); + + it('should call invalidate', () => { + const camera = createTestCamera(54, 10); + const invalidate = vi.fn(); + + updateCameraFov({ camera, cameraFovAngle: 45, invalidate }); + + expect(invalidate).toHaveBeenCalledOnce(); + }); + + it('should not modify a non-PerspectiveCamera', () => { + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 100); + camera.position.set(0, 0, 10); + const invalidate = vi.fn(); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + updateCameraFov({ camera, cameraFovAngle: 45, invalidate }); + + expect(errorSpy).toHaveBeenCalledOnce(); + expect(invalidate).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + it('should not adjust distance when camera is at origin', () => { + const camera = createTestCamera(54, 0); + camera.position.set(0, 0, 0); + camera.updateMatrixWorld(true); + const invalidate = vi.fn(); + + updateCameraFov({ camera, cameraFovAngle: 60, invalidate }); + + // Position should remain at origin (no direction to scale) + expect(camera.position.length()).toBeLessThan(tanEpsilon); + }); +}); + +// ── resetCamera ───────────────────────────────────────────────────────────── + +describe('resetCamera', () => { + const origin = new THREE.Vector3(0, 0, 0); + const defaultRotation = { side: -Math.PI / 4, vertical: Math.PI / 6 }; + + it('should position camera at geometryCenter + spherical offset', () => { + const camera = createTestCamera(); + const center = new THREE.Vector3(10, 5, 3); + const invalidate = vi.fn(); + const setSceneRadius = vi.fn(); + + resetCamera({ + camera, + geometryRadius: 100, + geometryCenter: center, + rotation: defaultRotation, + perspective: defaultPerspective(), + setSceneRadius, + invalidate, + cameraFovAngle: 60, + }); + + // Camera should be at some offset from the center, not at the origin + const distFromCenter = camera.position.distanceTo(center); + expect(distFromCenter).toBeGreaterThan(0); + }); + + it('should set FOV from the cameraFovAngle', () => { + const camera = createTestCamera(); + const invalidate = vi.fn(); + const setSceneRadius = vi.fn(); + + resetCamera({ + camera, + geometryRadius: 100, + geometryCenter: origin, + rotation: defaultRotation, + perspective: defaultPerspective(), + setSceneRadius, + invalidate, + cameraFovAngle: 45, + }); + + expect(camera.fov).toBeCloseTo(calculateFovFromAngle(45), 10); + }); + + it('should set zoom from perspective.zoomLevel', () => { + const camera = createTestCamera(); + const invalidate = vi.fn(); + const setSceneRadius = vi.fn(); + + resetCamera({ + camera, + geometryRadius: 100, + geometryCenter: origin, + rotation: defaultRotation, + perspective: defaultPerspective({ zoomLevel: 2.5 }), + setSceneRadius, + invalidate, + cameraFovAngle: 60, + }); + + expect(camera.zoom).toBe(2.5); + }); + + it('should look at geometry center, not origin', () => { + const camera = createTestCamera(); + const center = new THREE.Vector3(50, 50, 50); + const invalidate = vi.fn(); + const setSceneRadius = vi.fn(); + + resetCamera({ + camera, + geometryRadius: 100, + geometryCenter: center, + rotation: defaultRotation, + perspective: defaultPerspective(), + setSceneRadius, + invalidate, + cameraFovAngle: 60, + }); + + // After lookAt(center), the forward vector should point toward center + const forward = new THREE.Vector3(); + camera.getWorldDirection(forward); + const toCenter = center.clone().sub(camera.position).normalize(); + expect(forward.dot(toCenter)).toBeCloseTo(1, 3); + }); + + it('should update orbit controls target and call update', () => { + const camera = createTestCamera(); + const center = new THREE.Vector3(10, 20, 30); + const invalidate = vi.fn(); + const setSceneRadius = vi.fn(); + const controls = { + target: new THREE.Vector3(), + update: vi.fn(), + }; + + resetCamera({ + camera, + geometryRadius: 100, + geometryCenter: center, + rotation: defaultRotation, + perspective: defaultPerspective(), + setSceneRadius, + invalidate, + cameraFovAngle: 60, + controls, + }); + + expect(controls.target.x).toBeCloseTo(center.x); + expect(controls.target.y).toBeCloseTo(center.y); + expect(controls.target.z).toBeCloseTo(center.z); + expect(controls.update).toHaveBeenCalledOnce(); + }); + + it('should increase distance for portrait viewport aspect', () => { + const camera1 = createTestCamera(); + const camera2 = createTestCamera(); + const invalidate = vi.fn(); + const setSceneRadius = vi.fn(); + + // Landscape + resetCamera({ + camera: camera1, + geometryRadius: 100, + geometryCenter: origin, + rotation: defaultRotation, + perspective: defaultPerspective(), + setSceneRadius, + invalidate, + cameraFovAngle: 60, + viewportAspect: 16 / 9, + }); + + // Portrait + resetCamera({ + camera: camera2, + geometryRadius: 100, + geometryCenter: origin, + rotation: defaultRotation, + perspective: defaultPerspective(), + setSceneRadius, + invalidate, + cameraFovAngle: 60, + viewportAspect: 9 / 16, + }); + + const distLandscape = camera1.position.distanceTo(origin); + const distPortrait = camera2.position.distanceTo(origin); + expect(distPortrait).toBeGreaterThan(distLandscape); + }); + + it('should maintain current direction when enableConfiguredAngles is false', () => { + const camera = createTestCamera(54, 10); + camera.position.set(5, 5, 5); + camera.updateMatrixWorld(true); + const invalidate = vi.fn(); + const setSceneRadius = vi.fn(); + const directionBefore = camera.position.clone().normalize(); + + resetCamera({ + camera, + geometryRadius: 100, + geometryCenter: origin, + rotation: defaultRotation, + perspective: defaultPerspective(), + setSceneRadius, + invalidate, + cameraFovAngle: 60, + enableConfiguredAngles: false, + }); + + const directionAfter = camera.position.clone().normalize(); + expect(directionAfter.x).toBeCloseTo(directionBefore.x, 5); + expect(directionAfter.y).toBeCloseTo(directionBefore.y, 5); + expect(directionAfter.z).toBeCloseTo(directionBefore.z, 5); + }); + + it('should default geometry radius to 1000 when radius is 0', () => { + const camera = createTestCamera(); + const invalidate = vi.fn(); + const setSceneRadius = vi.fn(); + + resetCamera({ + camera, + geometryRadius: 0, + geometryCenter: origin, + rotation: defaultRotation, + perspective: defaultPerspective(), + setSceneRadius, + invalidate, + cameraFovAngle: 60, + }); + + // Camera should be positioned far out (distance proportional to 1000) + const dist = camera.position.distanceTo(origin); + expect(dist).toBeGreaterThan(500); + }); + + it('should not modify a non-PerspectiveCamera', () => { + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 100); + camera.position.set(0, 0, 10); + const invalidate = vi.fn(); + const setSceneRadius = vi.fn(); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + resetCamera({ + camera, + geometryRadius: 100, + geometryCenter: origin, + rotation: defaultRotation, + perspective: defaultPerspective(), + setSceneRadius, + invalidate, + cameraFovAngle: 60, + }); + + expect(errorSpy).toHaveBeenCalledOnce(); + expect(invalidate).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + it('should call invalidate and setSceneRadius', () => { + const camera = createTestCamera(); + const invalidate = vi.fn(); + const setSceneRadius = vi.fn(); + + resetCamera({ + camera, + geometryRadius: 42, + geometryCenter: origin, + rotation: defaultRotation, + perspective: defaultPerspective(), + setSceneRadius, + invalidate, + cameraFovAngle: 60, + }); + + expect(invalidate).toHaveBeenCalledOnce(); + expect(setSceneRadius).toHaveBeenCalledWith(42); + }); +}); diff --git a/packages/three/src/utils/camera.utils.ts b/packages/three/src/utils/camera.utils.ts new file mode 100644 index 000000000..5a6e36245 --- /dev/null +++ b/packages/three/src/utils/camera.utils.ts @@ -0,0 +1,339 @@ +import * as THREE from 'three'; +import { calculateFovFromAngle, calculateFovDistanceCompensation, tanEpsilon } from '#utils/math.utils.js'; + +/** + * Calculates a 3D position from spherical coordinates. + * Converts distance (radius), horizontal angle (phi), and vertical angle (theta) into x, y, z coordinates. + * Supports X-up, Y-up, and Z-up coordinate systems by checking THREE.Object3D.DEFAULT_UP. + */ +function calculatePositionFromSphericalCoordinates({ + distance, + horizontalAngle, + verticalAngle, +}: { + distance: number; + horizontalAngle: number; + verticalAngle: number; +}): THREE.Vector3 { + const cosTheta = Math.cos(verticalAngle); + const sinTheta = Math.sin(verticalAngle); + + // Determine which axis is up by checking THREE.Object3D.DEFAULT_UP + const isXaxisUp = THREE.Object3D.DEFAULT_UP.x === 1; + const isYaxisUp = THREE.Object3D.DEFAULT_UP.y === 1; + + if (isXaxisUp) { + // X-up: X is the vertical axis, Y and Z are horizontal + const x = distance * sinTheta; + const y = distance * cosTheta * Math.cos(horizontalAngle); + const z = distance * cosTheta * Math.sin(horizontalAngle); + return new THREE.Vector3(x, y, z); + } + + if (isYaxisUp) { + // Y-up: Y is the vertical axis, X and Z are horizontal (Z negated to match coordinate handedness) + const x = distance * cosTheta * Math.cos(horizontalAngle); + const y = distance * sinTheta; + const z = -distance * cosTheta * Math.sin(horizontalAngle); + return new THREE.Vector3(x, y, z); + } + + // Z-up: Z is the vertical axis, X and Y are horizontal + const x = distance * cosTheta * Math.cos(horizontalAngle); + const y = distance * cosTheta * Math.sin(horizontalAngle); + const z = distance * sinTheta; + return new THREE.Vector3(x, y, z); +} + +/** + * Computes the optimal zoom for a PerspectiveCamera so that the bounding box + * tightly fills the frame in both horizontal and vertical dimensions. + * + * Uses **perspective-correct** angular extents: each bounding-box corner is + * projected from the camera position, and the tangent of the angle from the + * optical axis is computed per-corner (rightDist / forwardDist). Corners that + * are closer to the camera subtend a larger angle and therefore limit the zoom + * more than corners further away. This prevents clipping that the simpler + * orthographic approximation (constant `distance` divisor) would miss. + * + * Frustum visibility condition for a point at camera-relative (r, u, f): + * |r/f| <= aspect * tan(fov/2) / zoom (horizontal) + * |u/f| <= tan(fov/2) / zoom (vertical) + * + * @returns The zoom value to set on the camera. Always >= a small floor to + * prevent degenerate values. + */ +export function computeViewFittingZoom({ + cameraPosition, + target, + boundingBox, + fovDeg, + aspectRatio, + paddingFactor = 0.9, +}: { + cameraPosition: THREE.Vector3; + target: THREE.Vector3; + boundingBox: THREE.Box3; + fovDeg: number; + aspectRatio: number; + paddingFactor?: number; +}): number { + const distance = cameraPosition.distanceTo(target); + if (distance < tanEpsilon) { + return 1; + } + + // Camera forward direction (from camera toward target) + const forward = new THREE.Vector3().subVectors(target, cameraPosition).normalize(); + + // Camera right = forward x worldUp (same pattern as computeHeadlampTransform) + const worldUp = THREE.Object3D.DEFAULT_UP.clone(); + const right = new THREE.Vector3().crossVectors(forward, worldUp); + + // Handle degenerate case: camera looking along up axis (e.g. TOP/BOTTOM view) + if (right.lengthSq() < 1e-6) { + // Pick an arbitrary perpendicular — use the axis with the smallest + // component of forward so the cross product is well-conditioned. + const absX = Math.abs(forward.x); + const absY = Math.abs(forward.y); + const absZ = Math.abs(forward.z); + const fallback = + absX <= absY && absX <= absZ + ? new THREE.Vector3(1, 0, 0) + : absY <= absZ + ? new THREE.Vector3(0, 1, 0) + : new THREE.Vector3(0, 0, 1); + right.crossVectors(forward, fallback); + } + + right.normalize(); + + // Camera up = right x forward + const up = new THREE.Vector3().crossVectors(right, forward).normalize(); + + const tanHalfFov = Math.tan((fovDeg / 2) * (Math.PI / 180)); + if (tanHalfFov < tanEpsilon) { + return 1; + } + + // Compute perspective-correct angular extents for each bounding-box corner. + // For each corner we measure the tangent of the angle from the optical axis + // (right/forward and up/forward ratios), which correctly accounts for corners + // at different depths from the camera. + const { min, max } = boundingBox; + let maxRightTan = 0; + let maxUpTan = 0; + let validCorners = 0; + + for (let i = 0; i < 8; i++) { + const corner = new THREE.Vector3( + // eslint-disable-next-line no-bitwise -- bit mask selects min/max per axis + i & 1 ? max.x : min.x, + // eslint-disable-next-line no-bitwise -- bit mask selects min/max per axis + i & 2 ? max.y : min.y, + // eslint-disable-next-line no-bitwise -- bit mask selects min/max per axis + i & 4 ? max.z : min.z, + ); + + // Vector from camera to this corner + const toCorner = corner.sub(cameraPosition); + const forwardDist = toCorner.dot(forward); + + // Skip corners behind or at the camera plane + if (forwardDist <= tanEpsilon) { + continue; + } + + // Tangent of horizontal and vertical angles from the optical axis + maxRightTan = Math.max(maxRightTan, Math.abs(toCorner.dot(right) / forwardDist)); + maxUpTan = Math.max(maxUpTan, Math.abs(toCorner.dot(up) / forwardDist)); + validCorners++; + } + + // Guard against no visible corners or zero-extent projected geometry + if (validCorners === 0 || maxRightTan < tanEpsilon || maxUpTan < tanEpsilon) { + return 1; + } + + // Max zoom that keeps every corner inside the frustum: + // zoom_v = tan(fov/2) / maxUpTan + // zoom_h = aspect * tan(fov/2) / maxRightTan + const zoomVertical = tanHalfFov / maxUpTan; + const zoomHorizontal = (aspectRatio * tanHalfFov) / maxRightTan; + + // Tighter constraint wins (smaller zoom = less visible area) + const tightZoom = Math.min(zoomVertical, zoomHorizontal) * paddingFactor; + + // Floor to prevent degenerate near-zero zoom + return Math.max(tightZoom, tanEpsilon); +} + +/** + * Updates only the camera FOV based on angle, adjusting distance to maintain perceived size. + * Does NOT reset camera position or viewing angle - preserves user's current view. + */ +export function updateCameraFov({ + camera, + cameraFovAngle, + invalidate, +}: { + camera: THREE.Camera; + cameraFovAngle: number; + invalidate: () => void; +}): void { + if (!(camera instanceof THREE.PerspectiveCamera)) { + console.error('updateCameraFov requires PerspectiveCamera'); + return; + } + + // Store old FOV before changing + const oldFov = camera.fov; + + // Calculate and apply the new FOV + const newFov = calculateFovFromAngle(cameraFovAngle); + camera.fov = newFov; + + // Adjust camera distance to maintain perceived size. + // This keeps objects the same apparent size when FOV changes. + if (camera.position.lengthSq() >= tanEpsilon) { + const currentDistance = camera.position.length(); + const newDistance = calculateFovDistanceCompensation(oldFov, newFov, currentDistance); + + if (newDistance !== currentDistance) { + const direction = camera.position.clone().normalize(); + camera.position.copy(direction.multiplyScalar(newDistance)); + } + } + + camera.updateProjectionMatrix(); + invalidate(); +} + +/** + * Resets the camera to a standard position and orientation based on geometry dimensions + * Adjusts for FOV to maintain consistent framing regardless of perspective setting + */ +export function resetCamera({ + camera, + geometryRadius, + geometryCenter, + rotation, + perspective, + setSceneRadius, + invalidate, + enableConfiguredAngles, + cameraFovAngle, + controls, + viewportAspect, +}: { + camera: THREE.Camera; + geometryRadius: number; + /** + * The center of the geometry's bounding box. The camera will orbit around + * and look at this point rather than the world origin, allowing geometry + * to remain at its absolute coordinates. + */ + geometryCenter: THREE.Vector3; + rotation: { side: number; vertical: number }; + perspective: { + offsetRatio: number; + zoomLevel: number; + nearPlane: number; + minimumFarPlane: number; + farPlaneRadiusMultiplier: number; + }; + setSceneRadius: (radius: number) => void; + invalidate: () => void; + enableConfiguredAngles?: boolean; + cameraFovAngle: number; + controls?: { target: THREE.Vector3; update: () => void } | undefined; + /** + * The viewport width / height ratio. When the viewport is in portrait + * orientation (aspect < 1), the camera distance is increased so the model + * is not clipped horizontally. + */ + viewportAspect?: number; +}): void { + if (!(camera instanceof THREE.PerspectiveCamera)) { + console.error('resetCamera requires PerspectiveCamera'); + return; + } + + // If the geometry radius is less than or requal to 0, we didn't get an object to render. + // Leaving it at 0 or less results in undefined camera behavior, so we set it to 1000. + const adjustedGeometryRadius = geometryRadius <= 0 ? 1000 : geometryRadius; + + const useConfiguredAngles = enableConfiguredAngles ?? true; + + // Calculate and apply the FOV + const calculatedFov = calculateFovFromAngle(cameraFovAngle); + camera.fov = calculatedFov; + + // Calculate the effective FOV that will be active after this reset, due to perspective.zoomLevel + let effectiveFovForAdjustment = calculatedFov; + if (useConfiguredAngles) { + effectiveFovForAdjustment = + THREE.MathUtils.RAD2DEG * + 2 * + Math.atan(Math.tan((THREE.MathUtils.DEG2RAD * calculatedFov) / 2) / perspective.zoomLevel); + } + + const standardFov = 60; + // Distance compensation ratio: pass distance=1 to get the pure tan(std/2)/tan(eff/2) ratio + const adjustedOffsetRatio = + perspective.offsetRatio * calculateFovDistanceCompensation(standardFov, effectiveFovForAdjustment, 1); + let newDistance = adjustedGeometryRadius * adjustedOffsetRatio; + + // Compensate for narrow (portrait) viewports so the model isn't clipped horizontally. + // The base distance ensures the model fits vertically. When the viewport is narrower + // than it is tall, the horizontal FOV shrinks and the model may exceed the horizontal + // frustum. Scale the distance by the ratio of vertical-to-horizontal half-FOV tangents + // so both dimensions are covered. + if (viewportAspect !== undefined && viewportAspect > 0 && viewportAspect < 1) { + const vFovRad = (effectiveFovForAdjustment / 2) * (Math.PI / 180); + const hFovHalf = Math.atan(viewportAspect * Math.tan(vFovRad)); + newDistance *= Math.tan(vFovRad) / Math.tan(hFovHalf); + } + + if (useConfiguredAngles) { + // Use configured rotation angles (side and vertical) for positioning + // Offset from the geometry center so the camera orbits around it + const offset = calculatePositionFromSphericalCoordinates({ + distance: newDistance, + horizontalAngle: rotation.side, + verticalAngle: rotation.vertical, + }); + camera.position.copy(geometryCenter).add(offset); + } else if (camera.position.distanceToSquared(geometryCenter) >= 1e-9) { + // Maintain current viewing direction if not at center, only adjust distance + const currentDirection = camera.position.clone().sub(geometryCenter).normalize(); + camera.position.copy(geometryCenter).add(currentDirection.multiplyScalar(newDistance)); + } else { + // Fallback for non-configured angle mode: If at center or too close, use configured angles to set an initial safe direction. + const offset = calculatePositionFromSphericalCoordinates({ + distance: newDistance, + horizontalAngle: rotation.side, + verticalAngle: rotation.vertical, + }); + camera.position.copy(geometryCenter).add(offset); + } + + camera.zoom = perspective.zoomLevel; + camera.near = perspective.nearPlane; + camera.far = Math.max(perspective.minimumFarPlane, adjustedGeometryRadius * perspective.farPlaneRadiusMultiplier); + + // Aim the camera at the geometry center + camera.lookAt(geometryCenter); + + // Update orbit controls target so the user orbits around the geometry center + if (controls) { + controls.target.copy(geometryCenter); + controls.update(); + } + + // Update the scene radius + setSceneRadius(geometryRadius); + + camera.updateProjectionMatrix(); + invalidate(); +} diff --git a/packages/three/src/utils/color.utils.ts b/packages/three/src/utils/color.utils.ts new file mode 100644 index 000000000..e7233d596 --- /dev/null +++ b/packages/three/src/utils/color.utils.ts @@ -0,0 +1,36 @@ +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +/** + * Darken or lighten a hexadecimal color by a given amount. Applies clamp to ensure the color stays within the valid 0-255 range. + * + * @param hex - Hexadecimal color string (e.g., "#RRGGBB" or "RRGGBB") + * @param amount - Amount to darken (positive) or lighten (negative) the color (-1 to 1, where 0 = no change, 1 = subtract 255, -1 = add 255) + * @returns Modified hexadecimal color string with # prefix + */ +export function adjustHexColorBrightness(hex: string, amount = 0.1): string { + // Remove # if present + const cleanHex = hex.replace('#', ''); + + // Parse RGB components + const r = Number.parseInt(cleanHex.slice(0, 2), 16); + const g = Number.parseInt(cleanHex.slice(2, 4), 16); + const b = Number.parseInt(cleanHex.slice(4, 6), 16); + + // Normalize amount to 0-255 range and apply to all channels equally + // Positive amount darkens (subtracts), negative amount lightens (adds) + const normalizedAmount = amount * 255; + + // Clamp values to ensure they stay in valid 0-255 range + const modifiedR = Math.floor(clamp(r - normalizedAmount, 0, 255)); + const modifiedG = Math.floor(clamp(g - normalizedAmount, 0, 255)); + const modifiedB = Math.floor(clamp(b - normalizedAmount, 0, 255)); + + // Convert back to hex with padding + const modifiedHex = [modifiedR, modifiedG, modifiedB] + .map((channel) => channel.toString(16).padStart(2, '0')) + .join(''); + + return `#${modifiedHex}`; +} diff --git a/packages/three/src/utils/gizmo.utils.ts b/packages/three/src/utils/gizmo.utils.ts new file mode 100644 index 000000000..944710ef7 --- /dev/null +++ b/packages/three/src/utils/gizmo.utils.ts @@ -0,0 +1,135 @@ +/** + * Shared utilities for viewport gizmo components. + * + * Extracts common logic (canvas/renderer creation, FOV synchronization, + * container resolution, resource cleanup) so that the individual gizmo + * components only need to declare their configuration differences. + */ + +import type { ViewportGizmo } from 'three-viewport-gizmo'; +import * as THREE from 'three'; +import { + calculateGizmoFovFromAngle, + calculateFovDistanceCompensation, + gizmoBaseFov, + gizmoBaseDistance, + gizmoDepthMargin, + gizmoFocusOffset, +} from '#utils/math.utils.js'; + +// ── FOV synchronization ───────────────────────────────────────────────────── + +/** + * Synchronize the gizmo's internal camera FOV with the viewport camera FOV. + * + * The `three-viewport-gizmo` library creates its own internal PerspectiveCamera + * (accessed via the private `_camera` property) with hardcoded defaults (FOV=26, + * distance=7). This function updates that internal camera so the gizmo shows the + * same perspective as the main viewport, while compensating the camera distance to + * keep the gizmo cube at a consistent apparent size. + * + * NOTE: `_camera` is a non-public property. If the library ever exposes a public + * FOV API, this should be migrated. The runtime `instanceof` guard ensures a + * silent no-op if the internal structure changes. + */ +export function syncGizmoFov(gizmo: ViewportGizmo, cameraFovAngle: number): void { + const internalCamera = (gizmo as unknown as { _camera: THREE.PerspectiveCamera })._camera; + if (!(internalCamera instanceof THREE.PerspectiveCamera)) { + return; + } + + const gizmoFov = calculateGizmoFovFromAngle(cameraFovAngle); + const newDistance = calculateFovDistanceCompensation(gizmoBaseFov, gizmoFov, gizmoBaseDistance, gizmoFocusOffset); + + internalCamera.fov = gizmoFov; + internalCamera.position.set(0, 0, newDistance); + internalCamera.near = Math.max(0.01, newDistance - gizmoDepthMargin); + internalCamera.far = newDistance + gizmoDepthMargin; + internalCamera.updateProjectionMatrix(); +} + +// ── Container resolution ──────────────────────────────────────────────────── + +/** + * Resolve the gizmo container element from a string selector, an element + * reference, or fall back to the renderer's parent element. + * + * @returns The resolved container, or `undefined` if none could be found. + */ +export function resolveGizmoContainer( + container: HTMLElement | string | undefined, + glDomElement: HTMLCanvasElement, +): HTMLElement | undefined { + if (typeof container === 'string') { + return document.querySelector(container) ?? undefined; + } + + return container ?? glDomElement.parentElement ?? undefined; +} + +// ── Canvas & renderer creation ────────────────────────────────────────────── + +/** + * Create and configure a canvas element for a viewport gizmo overlay. + * + * The canvas is absolutely positioned at bottom-right with a z-index of 10, + * matching the convention used by all gizmo variants. + */ +export function createGizmoCanvas(className: string): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + canvas.className = className; + canvas.style.position = 'absolute'; + canvas.style.bottom = '0'; + canvas.style.right = '0'; + canvas.style.zIndex = '10'; + return canvas; +} + +/** + * Create and configure a WebGL renderer for a viewport gizmo. + * + * Uses alpha + antialias, caps pixel ratio at 2, and clears to transparent. + */ +export function createGizmoRenderer(canvas: HTMLCanvasElement, size: number): THREE.WebGLRenderer { + const renderer = new THREE.WebGLRenderer({ + canvas, + alpha: true, + antialias: true, + }); + renderer.setSize(size, size); + const dpr = Math.min(globalThis.devicePixelRatio, 2); + renderer.setPixelRatio(dpr); + renderer.setClearColor(0x00_00_00, 0); + return renderer; +} + +// ── Resource cleanup ──────────────────────────────────────────────────────── + +/** + * Dispose all resources created for a viewport gizmo. + * + * Removes event listeners, disposes the gizmo and renderer, removes the + * canvas from the DOM, and forces WebGL context loss to prevent GPU context + * exhaustion. + */ +export function disposeGizmoResources({ + gizmo, + renderer, + canvas, + handleChange, +}: { + gizmo: ViewportGizmo; + renderer: THREE.WebGLRenderer; + canvas: HTMLCanvasElement; + handleChange: () => void; +}): void { + gizmo.removeEventListener('change', handleChange); + gizmo.dispose(); + + if (canvas.parentElement) { + canvas.remove(); + } + + renderer.forceContextLoss(); + renderer.dispose(); +} diff --git a/packages/three/src/utils/lights.utils.test.ts b/packages/three/src/utils/lights.utils.test.ts new file mode 100644 index 000000000..ee59f2615 --- /dev/null +++ b/packages/three/src/utils/lights.utils.test.ts @@ -0,0 +1,762 @@ +import { describe, expect, it } from 'vitest'; +import * as THREE from 'three'; +import { + computeEnvironmentRotation, + computeHeadlampTransform, + applyLightingForCamera, + findTaggedLights, + defaultHeadlampConfig, + ambientBaseIntensity, + headlampBaseIntensity, + environmentBaseIntensity, + lightingUserDataKeys, + poleFadeAngleDeg, +} from '#utils/lights.utils.js'; +import type { HeadlampConfig, LightingConfig } from '#utils/lights.utils.js'; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +/** Creates a PerspectiveCamera positioned along +Z looking at origin. */ +function createTestCamera(fov = 54, distance = 10): THREE.PerspectiveCamera { + const camera = new THREE.PerspectiveCamera(fov, 16 / 9, 0.1, 1000); + camera.position.set(0, 0, distance); + camera.lookAt(0, 0, 0); + camera.updateMatrixWorld(true); + return camera; +} + +/** Creates a minimal Scene with environment rotation support. */ +function createTestScene(): THREE.Scene { + const scene = new THREE.Scene(); + return scene; +} + +/** Creates the default lighting config used in most tests. */ +function createDefaultLightingConfig(overrides?: Partial): LightingConfig { + return { + sceneRadius: 5, + upDirection: 'z', + headlampIntensity: headlampBaseIntensity, + ambientIntensity: ambientBaseIntensity, + environmentIntensity: environmentBaseIntensity, + headlampConfig: defaultHeadlampConfig, + ...overrides, + }; +} + +// ── computeEnvironmentRotation ────────────────────────────────────────────── + +/** + * Builds an orbit-camera quaternion: Q_yaw(azimuth) * Q_pitch(polar). + * + * For Z-up the orbit is: rotate around Z by `azimuth`, then tilt around + * the resulting local X by `polar`. + */ +function orbitQuaternion(azimuth: number, polar: number, up: 'x' | 'y' | 'z' = 'z'): THREE.Quaternion { + const upAxis = + up === 'z' ? new THREE.Vector3(0, 0, 1) : up === 'y' ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3(1, 0, 0); + + const pitchAxis = + up === 'z' ? new THREE.Vector3(1, 0, 0) : up === 'y' ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 0, 1); + + const qYaw = new THREE.Quaternion().setFromAxisAngle(upAxis, azimuth); + const qPitch = new THREE.Quaternion().setFromAxisAngle(pitchAxis, polar); + return qYaw.multiply(qPitch); +} + +/** Polar angle (from top) within which the pole-fade is fully active (yaw → 0). */ +const poleFadeRad = (poleFadeAngleDeg * Math.PI) / 180; + +describe('computeEnvironmentRotation', () => { + // ── Basic identity and order ──────────────────────────────────────────── + + describe('identity camera', () => { + it('should produce a near-zero Euler for an identity quaternion', () => { + // Identity quaternion sits at the top pole — pole fade drives yaw to 0. + const identityQuat = new THREE.Quaternion(); // (0, 0, 0, 1) + const euler = computeEnvironmentRotation(identityQuat, 'z'); + + expect(euler.x).toBeCloseTo(0, 6); + expect(euler.y).toBeCloseTo(0, 6); + expect(euler.z).toBeCloseTo(0, 6); + }); + }); + + describe('euler order selection', () => { + it('should use ZXY order for z-up', () => { + const quat = new THREE.Quaternion(); + const euler = computeEnvironmentRotation(quat, 'z'); + expect(euler.order).toBe('ZXY'); + }); + + it('should use YXZ order for y-up', () => { + const quat = new THREE.Quaternion(); + const euler = computeEnvironmentRotation(quat, 'y'); + expect(euler.order).toBe('YXZ'); + }); + + it('should use XZY order for x-up', () => { + const quat = new THREE.Quaternion(); + const euler = computeEnvironmentRotation(quat, 'x'); + expect(euler.order).toBe('XZY'); + }); + }); + + // ── Azimuth extraction (non-pole cameras) ─────────────────────────────── + + describe('azimuth-only extraction', () => { + it('should extract correct yaw for each up direction at equatorial polar', () => { + // Use polar = π/2 (equator) where blend = 1 → full yaw + const angle = Math.PI / 3; + + const eulerZ = computeEnvironmentRotation(orbitQuaternion(angle, Math.PI / 2, 'z'), 'z'); + expect(eulerZ.z).toBeCloseTo(angle, 4); + expect(eulerZ.x).toBeCloseTo(0, 6); + expect(eulerZ.y).toBeCloseTo(0, 6); + + const eulerY = computeEnvironmentRotation(orbitQuaternion(angle, Math.PI / 2, 'y'), 'y'); + expect(eulerY.y).toBeCloseTo(angle, 4); + expect(eulerY.x).toBeCloseTo(0, 6); + expect(eulerY.z).toBeCloseTo(0, 6); + + const eulerX = computeEnvironmentRotation(orbitQuaternion(angle, Math.PI / 2, 'x'), 'x'); + expect(eulerX.x).toBeCloseTo(angle, 4); + expect(eulerX.y).toBeCloseTo(0, 6); + expect(eulerX.z).toBeCloseTo(0, 6); + }); + + it('should only populate the up-axis Euler component (pitch/roll zeroed)', () => { + // Polar π/3 (60°) — well away from both poles, blend ≈ 1 + const quat = orbitQuaternion(Math.PI / 4, Math.PI / 3, 'z'); + const euler = computeEnvironmentRotation(quat, 'z'); + + expect(euler.z).toBeCloseTo(Math.PI / 4, 4); + expect(euler.x).toBeCloseTo(0, 6); + expect(euler.y).toBeCloseTo(0, 6); + }); + }); + + // ── Polar-angle independence (mid-latitude band only) ─────────────────── + + describe('polar-angle independence (outside pole caps)', () => { + it('should return the same rotation for polar angles in the mid-latitude band (z-up)', () => { + const azimuth = Math.PI / 4; + // Stay well outside the pole-fade caps (poleFadeAngleDeg from each pole) + const safeMargin = poleFadeRad + 0.05; + const polarAngles = [ + safeMargin, + Math.PI / 4, + Math.PI / 3, + Math.PI / 2, + (2 * Math.PI) / 3, + (3 * Math.PI) / 4, + Math.PI - safeMargin, + ]; + + const results = polarAngles.map((polar) => { + const quat = orbitQuaternion(azimuth, polar, 'z'); + return computeEnvironmentRotation(quat, 'z'); + }); + + for (const euler of results) { + expect(euler.z).toBeCloseTo(azimuth, 3); + expect(euler.x).toBeCloseTo(0, 6); + expect(euler.y).toBeCloseTo(0, 6); + } + }); + + it('should return the same rotation for polar angles in the mid-latitude band (y-up)', () => { + const azimuth = -Math.PI / 3; + const safeMargin = poleFadeRad + 0.05; + const polarAngles = [safeMargin, Math.PI / 4, Math.PI / 2, Math.PI - safeMargin]; + + const results = polarAngles.map((polar) => { + const quat = orbitQuaternion(azimuth, polar, 'y'); + return computeEnvironmentRotation(quat, 'y'); + }); + + for (const euler of results) { + expect(euler.y).toBeCloseTo(azimuth, 3); + expect(euler.x).toBeCloseTo(0, 6); + expect(euler.z).toBeCloseTo(0, 6); + } + }); + }); + + // ── Continuity ────────────────────────────────────────────────────────── + + describe('continuity across equatorial plane', () => { + it('should be continuous sweeping polar through 90° (z-up)', () => { + const azimuth = Math.PI / 3; + const steps = 100; + const polarStart = Math.PI / 4; + const polarEnd = (3 * Math.PI) / 4; + + let previousZ: number | undefined; + for (let index = 0; index <= steps; index++) { + const polar = polarStart + (index / steps) * (polarEnd - polarStart); + const quat = orbitQuaternion(azimuth, polar, 'z'); + const euler = computeEnvironmentRotation(quat, 'z'); + + if (previousZ !== undefined) { + const delta = Math.abs(euler.z - previousZ); + expect(delta).toBeLessThan(0.1); + } + + previousZ = euler.z; + } + }); + + it('should be continuous sweeping polar through 90° (y-up)', () => { + const azimuth = -Math.PI / 6; + const steps = 100; + const polarStart = Math.PI / 4; + const polarEnd = (3 * Math.PI) / 4; + + let previousY: number | undefined; + for (let index = 0; index <= steps; index++) { + const polar = polarStart + (index / steps) * (polarEnd - polarStart); + const quat = orbitQuaternion(azimuth, polar, 'y'); + const euler = computeEnvironmentRotation(quat, 'y'); + + if (previousY !== undefined) { + const delta = Math.abs(euler.y - previousY); + expect(delta).toBeLessThan(0.1); + } + + previousY = euler.y; + } + }); + }); + + describe('full azimuth sweep is continuous', () => { + it('should have no jumps across the full −π→+π azimuth range', () => { + const polar = Math.PI / 3; // 60° — well away from poles + const steps = 360; + + let previousZ: number | undefined; + for (let index = 0; index <= steps; index++) { + const azimuth = (index / steps) * 2 * Math.PI - Math.PI; + const quat = orbitQuaternion(azimuth, polar, 'z'); + const euler = computeEnvironmentRotation(quat, 'z'); + + if (previousZ !== undefined) { + let delta = Math.abs(euler.z - previousZ); + if (delta > Math.PI) { + delta = 2 * Math.PI - delta; + } + + expect(delta).toBeLessThan(0.1); + } + + previousZ = euler.z; + } + }); + }); + + // ── Pole-proximity fade ───────────────────────────────────────────────── + + describe('pole-proximity fade', () => { + it('should return near-zero yaw at the top pole (polar ≈ 0)', () => { + // Top pole: polar = 0° → camera looking straight down along up axis + const quat = orbitQuaternion(Math.PI / 2, 0, 'z'); + const euler = computeEnvironmentRotation(quat, 'z'); + expect(Math.abs(euler.z)).toBeLessThan(0.01); + }); + + it('should return near-zero yaw at the bottom pole (polar ≈ π)', () => { + // Bottom pole: polar = π → camera looking straight up from below + const quat = orbitQuaternion(Math.PI / 2, Math.PI - 0.001, 'z'); + const euler = computeEnvironmentRotation(quat, 'z'); + expect(Math.abs(euler.z)).toBeLessThan(0.01); + }); + + it('should return full yaw at the equator (polar = π/2)', () => { + const azimuth = Math.PI / 3; + const quat = orbitQuaternion(azimuth, Math.PI / 2, 'z'); + const euler = computeEnvironmentRotation(quat, 'z'); + expect(euler.z).toBeCloseTo(azimuth, 4); + }); + + it('should return full yaw well outside the pole cap', () => { + const azimuth = Math.PI / 4; + // 30° from top pole — outside the 15° cap + const quat = orbitQuaternion(azimuth, Math.PI / 6, 'z'); + const euler = computeEnvironmentRotation(quat, 'z'); + expect(euler.z).toBeCloseTo(azimuth, 2); + }); + + it('should be symmetric between top and bottom poles', () => { + const azimuth = Math.PI / 4; + // 5° from top pole + const topQuat = orbitQuaternion(azimuth, 0.087, 'z'); + const topEuler = computeEnvironmentRotation(topQuat, 'z'); + + // 5° from bottom pole + const bottomQuat = orbitQuaternion(azimuth, Math.PI - 0.087, 'z'); + const bottomEuler = computeEnvironmentRotation(bottomQuat, 'z'); + + // Both should have heavily attenuated yaw (blend close to 0) + expect(Math.abs(topEuler.z)).toBeLessThan(Math.abs(azimuth) * 0.3); + expect(Math.abs(bottomEuler.z)).toBeLessThan(Math.abs(azimuth) * 0.3); + }); + + it('should produce a monotonically increasing blend from pole to equator', () => { + const azimuth = Math.PI / 3; + const steps = 50; + // Sweep from top pole (polar = 0) to equator (polar = π/2) + let previousAbsZ = -1; + for (let index = 1; index <= steps; index++) { + const polar = (index / steps) * (Math.PI / 2); + const quat = orbitQuaternion(azimuth, polar, 'z'); + const euler = computeEnvironmentRotation(quat, 'z'); + const absZ = Math.abs(euler.z); + expect(absZ).toBeGreaterThanOrEqual(previousAbsZ - 1e-6); + previousAbsZ = absZ; + } + }); + }); + + describe('continuity through pole-fade transition (no lighting hop)', () => { + it('should be continuous sweeping from equator through bottom pole cap (z-up)', () => { + const azimuth = Math.PI / 3; + const steps = 500; + // Sweep from π/3 (60°) all the way to near-bottom pole + const polarStart = Math.PI / 3; + const polarEnd = Math.PI - 0.01; + + let previousZ: number | undefined; + for (let index = 0; index <= steps; index++) { + const polar = polarStart + (index / steps) * (polarEnd - polarStart); + const quat = orbitQuaternion(azimuth, polar, 'z'); + const euler = computeEnvironmentRotation(quat, 'z'); + + if (previousZ !== undefined) { + // A genuine hop (the original bug) would produce a delta of ~π. + // The smoothstep fade may produce up to ~0.04 rad per step at + // 500 steps, well within the 0.1 rad threshold. + const delta = Math.abs(euler.z - previousZ); + expect(delta).toBeLessThan(0.1); + } + + previousZ = euler.z; + } + }); + + it('should be continuous sweeping from equator through top pole cap (z-up)', () => { + const azimuth = -Math.PI / 4; + const steps = 500; + const polarStart = (2 * Math.PI) / 3; + const polarEnd = 0.01; + + let previousZ: number | undefined; + for (let index = 0; index <= steps; index++) { + const polar = polarStart + (index / steps) * (polarEnd - polarStart); + const quat = orbitQuaternion(azimuth, polar, 'z'); + const euler = computeEnvironmentRotation(quat, 'z'); + + if (previousZ !== undefined) { + const delta = Math.abs(euler.z - previousZ); + expect(delta).toBeLessThan(0.1); + } + + previousZ = euler.z; + } + }); + }); + + describe('near-pole perturbation stability', () => { + it('should produce small yaw changes for small camera perturbations near bottom pole', () => { + // At 3° from the bottom pole, perturb the azimuth by 90° (worst case). + // Without pole-fade this would be a massive lighting change; with it + // the effective yaw should be heavily attenuated. + const polar = Math.PI - 0.05; // ~3° from bottom pole + const euler1 = computeEnvironmentRotation(orbitQuaternion(0, polar, 'z'), 'z'); + const euler2 = computeEnvironmentRotation(orbitQuaternion(Math.PI / 2, polar, 'z'), 'z'); + + // The effective yaw difference should be much smaller than π/2 (the raw difference) + const effectiveDelta = Math.abs(euler2.z - euler1.z); + expect(effectiveDelta).toBeLessThan(0.15); // < ~9° + }); + + it('should produce small yaw changes for small camera perturbations near top pole', () => { + const polar = 0.05; // ~3° from top pole + const euler1 = computeEnvironmentRotation(orbitQuaternion(0, polar, 'z'), 'z'); + const euler2 = computeEnvironmentRotation(orbitQuaternion(Math.PI / 2, polar, 'z'), 'z'); + + const effectiveDelta = Math.abs(euler2.z - euler1.z); + expect(effectiveDelta).toBeLessThan(0.15); + }); + }); + + // ── Degenerate / edge cases ───────────────────────────────────────────── + + describe('degenerate cases', () => { + it('should return identity for exact bottom-pole quaternion (q.z = q.w = 0)', () => { + const degenerate = new THREE.Quaternion(0, 1, 0, 0); // 180° around Y + const euler = computeEnvironmentRotation(degenerate, 'z'); + + expect(euler.x).toBeCloseTo(0, 6); + expect(euler.y).toBeCloseTo(0, 6); + expect(euler.z).toBeCloseTo(0, 6); + }); + + it('should return identity for exact top-pole quaternion (q.x = q.y = 0)', () => { + // Pure yaw with no pitch → top pole. Pole-fade drives effective yaw to 0. + const topPole = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI / 2); + const euler = computeEnvironmentRotation(topPole, 'z'); + + expect(euler.x).toBeCloseTo(0, 6); + expect(euler.y).toBeCloseTo(0, 6); + // At top pole, blend = 0 so effective yaw = 0 + expect(euler.z).toBeCloseTo(0, 6); + }); + + it('should handle near-zero-length quaternion gracefully', () => { + // Edge case: quaternion is nearly zero (shouldn't happen but protect against it) + const nearZero = new THREE.Quaternion(1e-8, 1e-8, 1e-8, 1e-8); + expect(() => computeEnvironmentRotation(nearZero, 'z')).not.toThrow(); + }); + }); + + // ── Input immutability ────────────────────────────────────────────────── + + describe('does not mutate input', () => { + it('should not modify the input quaternion', () => { + const quat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 3); + const originalW = quat.w; + const originalX = quat.x; + const originalY = quat.y; + const originalZ = quat.z; + + computeEnvironmentRotation(quat, 'z'); + + expect(quat.x).toBe(originalX); + expect(quat.y).toBe(originalY); + expect(quat.z).toBe(originalZ); + expect(quat.w).toBe(originalW); + }); + }); +}); + +// ── computeHeadlampTransform ──────────────────────────────────────────────── + +describe('computeHeadlampTransform', () => { + describe('identity camera matrix', () => { + it('should offset position in camera-up (+Y) and camera-right (+X) directions', () => { + const cameraPosition = new THREE.Vector3(0, 0, 10); + const cameraMatrix = new THREE.Matrix4().identity(); + const radius = 5; + + const { position } = computeHeadlampTransform({ + cameraPosition, + cameraMatrixWorld: cameraMatrix, + sceneRadius: radius, + config: defaultHeadlampConfig, + }); + + // With identity matrix: + // camera-right = column 0 = (1,0,0) + // camera-up = column 1 = (0,1,0) + // Expected position: (0,0,10) + (0,1,0) * 5 * 2.1 + (1,0,0) * 5 * -0.1 + const expectedX = 0 + radius * defaultHeadlampConfig.rightOffset; + const expectedY = 0 + radius * defaultHeadlampConfig.upOffset; + const expectedZ = 10; + + expect(position.x).toBeCloseTo(expectedX, 6); + expect(position.y).toBeCloseTo(expectedY, 6); + expect(position.z).toBeCloseTo(expectedZ, 6); + }); + + it('should place the target forward of camera with skew offsets', () => { + const cameraPosition = new THREE.Vector3(0, 0, 10); + const cameraMatrix = new THREE.Matrix4().identity(); + const radius = 5; + + const { targetPosition } = computeHeadlampTransform({ + cameraPosition, + cameraMatrixWorld: cameraMatrix, + sceneRadius: radius, + config: defaultHeadlampConfig, + }); + + // With identity matrix: + // camera-forward = -column2 = (0,0,-1) negated = (0,0,1)... actually + // column 2 of identity = (0,0,1), negated = (0,0,-1) + // forward direction = -column2 = (0,0,-1) + // target = camera_pos + forward * radius * 2 + right * (-radius * skew) + up * (-radius * skew) + const expectedZ = 10 + -1 * radius * 2; + const expectedX = 0 + -radius * defaultHeadlampConfig.targetRightSkew; + const expectedY = 0 + -radius * defaultHeadlampConfig.targetUpSkew; + + expect(targetPosition.x).toBeCloseTo(expectedX, 6); + expect(targetPosition.y).toBeCloseTo(expectedY, 6); + expect(targetPosition.z).toBeCloseTo(expectedZ, 6); + }); + }); + + describe('scaling with sceneRadius', () => { + it('should produce proportionally larger offsets with larger radius', () => { + const cameraPosition = new THREE.Vector3(0, 0, 10); + const cameraMatrix = new THREE.Matrix4().identity(); + + const small = computeHeadlampTransform({ + cameraPosition, + cameraMatrixWorld: cameraMatrix, + sceneRadius: 1, + config: defaultHeadlampConfig, + }); + const large = computeHeadlampTransform({ + cameraPosition, + cameraMatrixWorld: cameraMatrix, + sceneRadius: 10, + config: defaultHeadlampConfig, + }); + + // The offset from camera position should be 10x larger + const smallOffset = small.position.clone().sub(cameraPosition); + const largeOffset = large.position.clone().sub(cameraPosition); + + expect(largeOffset.length()).toBeCloseTo(smallOffset.length() * 10, 4); + }); + }); + + describe('custom config', () => { + it('should respect custom offset values', () => { + const cameraPosition = new THREE.Vector3(0, 0, 0); + const cameraMatrix = new THREE.Matrix4().identity(); + const radius = 1; + const config: HeadlampConfig = { + rightOffset: 1, + upOffset: 1, + targetRightSkew: 0, + targetUpSkew: 0, + }; + + const { position } = computeHeadlampTransform({ + cameraPosition, + cameraMatrixWorld: cameraMatrix, + sceneRadius: radius, + config, + }); + + // Camera-right = (1,0,0), camera-up = (0,1,0) + // position = (0,0,0) + (0,1,0)*1*1 + (1,0,0)*1*1 = (1, 1, 0) + expect(position.x).toBeCloseTo(1, 6); + expect(position.y).toBeCloseTo(1, 6); + expect(position.z).toBeCloseTo(0, 6); + }); + }); + + describe('does not mutate input', () => { + it('should not modify the input camera position', () => { + const cameraPosition = new THREE.Vector3(1, 2, 3); + const originalX = cameraPosition.x; + const originalY = cameraPosition.y; + const originalZ = cameraPosition.z; + const cameraMatrix = new THREE.Matrix4().identity(); + + computeHeadlampTransform({ + cameraPosition, + cameraMatrixWorld: cameraMatrix, + sceneRadius: 5, + config: defaultHeadlampConfig, + }); + + expect(cameraPosition.x).toBe(originalX); + expect(cameraPosition.y).toBe(originalY); + expect(cameraPosition.z).toBe(originalZ); + }); + }); +}); + +// ── applyLightingForCamera ────────────────────────────────────────────────── + +describe('applyLightingForCamera', () => { + describe('environment rotation', () => { + it('should set scene.environmentRotation based on camera orientation', () => { + const scene = createTestScene(); + const camera = createTestCamera(); + const config = createDefaultLightingConfig(); + + applyLightingForCamera({ scene, camera, headlamp: undefined, ambient: undefined, config }); + + // EnvironmentRotation should have been set (not identity if camera is looking at origin from +Z) + const euler = scene.environmentRotation; + expect(euler).toBeDefined(); + // The Euler order should match z-up + expect(euler.order).toBe('ZXY'); + }); + }); + + describe('environment intensity', () => { + it('should set scene.environmentIntensity using FOV compensation', () => { + const scene = createTestScene(); + const camera = createTestCamera(54); // Reference FOV + const config = createDefaultLightingConfig(); + + applyLightingForCamera({ scene, camera, headlamp: undefined, ambient: undefined, config }); + + // At reference FOV (54), envFactor ≈ 1.0, so intensity ≈ base + expect(scene.environmentIntensity).toBeCloseTo(environmentBaseIntensity, 2); + }); + + it('should reduce environment intensity at low FOV', () => { + const scene = createTestScene(); + const camera = createTestCamera(10); // Low FOV + const config = createDefaultLightingConfig(); + + applyLightingForCamera({ scene, camera, headlamp: undefined, ambient: undefined, config }); + + expect(scene.environmentIntensity).toBeLessThan(environmentBaseIntensity); + }); + }); + + describe('headlamp positioning', () => { + it('should update headlamp position and intensity when provided', () => { + const scene = createTestScene(); + const camera = createTestCamera(); + const headlamp = new THREE.DirectionalLight('white', 1); + scene.add(headlamp); + scene.add(headlamp.target); + const config = createDefaultLightingConfig(); + + const originalPosition = headlamp.position.clone(); + + applyLightingForCamera({ scene, camera, headlamp, ambient: undefined, config }); + + // Position should have changed + expect(headlamp.position.equals(originalPosition)).toBe(false); + // Intensity should be set (at reference FOV, headlampFactor ≈ 1.0) + expect(headlamp.intensity).toBeCloseTo(headlampBaseIntensity, 2); + }); + + it('should not throw when headlamp is undefined', () => { + const scene = createTestScene(); + const camera = createTestCamera(); + const config = createDefaultLightingConfig(); + + expect(() => { + applyLightingForCamera({ scene, camera, headlamp: undefined, ambient: undefined, config }); + }).not.toThrow(); + }); + }); + + describe('ambient light', () => { + it('should update ambient intensity with FOV compensation when provided', () => { + const scene = createTestScene(); + const camera = createTestCamera(54); // Reference FOV + const ambient = new THREE.AmbientLight('white', 1); + scene.add(ambient); + const config = createDefaultLightingConfig(); + + applyLightingForCamera({ scene, camera, headlamp: undefined, ambient, config }); + + // At reference FOV, ambientFactor ≈ 1.0 + expect(ambient.intensity).toBeCloseTo(ambientBaseIntensity, 2); + }); + + it('should boost ambient intensity at low FOV', () => { + const scene = createTestScene(); + const camera = createTestCamera(10); // Low FOV + const ambient = new THREE.AmbientLight('white', 1); + scene.add(ambient); + const config = createDefaultLightingConfig(); + + applyLightingForCamera({ scene, camera, headlamp: undefined, ambient, config }); + + // At low FOV, ambientFactor > 1.0 + expect(ambient.intensity).toBeGreaterThan(ambientBaseIntensity); + }); + + it('should not throw when ambient is undefined', () => { + const scene = createTestScene(); + const camera = createTestCamera(); + const config = createDefaultLightingConfig(); + + expect(() => { + applyLightingForCamera({ scene, camera, headlamp: undefined, ambient: undefined, config }); + }).not.toThrow(); + }); + }); + + describe('consistency across camera angles', () => { + it('should produce different environment rotations for different azimuthal positions', () => { + const scene1 = createTestScene(); + const scene2 = createTestScene(); + const config = createDefaultLightingConfig(); + + // Camera 1: azimuth 0°, polar 45° + const camera1 = createTestCamera(); + camera1.quaternion.copy(orbitQuaternion(0, Math.PI / 4, 'z')); + camera1.updateMatrixWorld(true); + + // Camera 2: azimuth 90°, polar 45° + const camera2 = createTestCamera(); + camera2.quaternion.copy(orbitQuaternion(Math.PI / 2, Math.PI / 4, 'z')); + camera2.updateMatrixWorld(true); + + applyLightingForCamera({ scene: scene1, camera: camera1, headlamp: undefined, ambient: undefined, config }); + applyLightingForCamera({ scene: scene2, camera: camera2, headlamp: undefined, ambient: undefined, config }); + + // Different azimuths should produce different environment rotations + const rot1 = scene1.environmentRotation; + const rot2 = scene2.environmentRotation; + const isIdentical = + Math.abs(rot1.x - rot2.x) < 1e-6 && Math.abs(rot1.y - rot2.y) < 1e-6 && Math.abs(rot1.z - rot2.z) < 1e-6; + expect(isIdentical).toBe(false); + }); + }); +}); + +// ── findTaggedLights ──────────────────────────────────────────────────────── + +describe('findTaggedLights', () => { + it('should find tagged headlamp and ambient light in a scene', () => { + const scene = createTestScene(); + const headlamp = new THREE.DirectionalLight('white', 1); + headlamp.userData[lightingUserDataKeys.headlamp] = true; + const ambient = new THREE.AmbientLight('white', 1); + ambient.userData[lightingUserDataKeys.ambient] = true; + scene.add(headlamp); + scene.add(ambient); + + const result = findTaggedLights(scene); + + expect(result.headlamp).toBe(headlamp); + expect(result.ambient).toBe(ambient); + }); + + it('should return undefined for missing lights', () => { + const scene = createTestScene(); + + const result = findTaggedLights(scene); + + expect(result.headlamp).toBeUndefined(); + expect(result.ambient).toBeUndefined(); + }); + + it('should find lights nested in groups', () => { + const scene = createTestScene(); + const group = new THREE.Group(); + const headlamp = new THREE.DirectionalLight('white', 1); + headlamp.userData[lightingUserDataKeys.headlamp] = true; + group.add(headlamp); + scene.add(group); + + const result = findTaggedLights(scene); + + expect(result.headlamp).toBe(headlamp); + }); + + it('should not return untagged lights', () => { + const scene = createTestScene(); + const untaggedLight = new THREE.DirectionalLight('white', 1); + scene.add(untaggedLight); + + const result = findTaggedLights(scene); + + expect(result.headlamp).toBeUndefined(); + expect(result.ambient).toBeUndefined(); + }); +}); diff --git a/packages/three/src/utils/lights.utils.ts b/packages/three/src/utils/lights.utils.ts new file mode 100644 index 000000000..024b25ec4 --- /dev/null +++ b/packages/three/src/utils/lights.utils.ts @@ -0,0 +1,311 @@ +/** + * Pure lighting utilities extracted from the Lights component. + * + * These functions encapsulate the per-frame camera-relative lighting logic so + * that both the live renderer (via `useFrame`) and the offline screenshot + * renderer can apply identical lighting for any camera orientation. + */ + +import * as THREE from 'three'; +import { calculateFovLightingCompensation } from '#utils/math.utils.js'; + +// ── Lighting constants ───────────────────────────────────────────────────── +// Exported so that both the Lights component and the screenshot capture +// system reference the same tuning values. + +/** Ambient fill -- provides base illumination floor so no surface is fully dark. */ +export const ambientBaseIntensity = 0.05; + +/** + * Camera-relative headlamp base intensity. + * + * Reduced from 0.5 since the camera-relative environment now provides + * directional variation. The headlamp is retained primarily as part of + * the FOV diffuse compensation system (its intensity is boosted at low FOV). + */ +export const headlampBaseIntensity = 0.8; + +/** + * Scene-level environment intensity (base value) -- the primary illumination + * source. This base value is scaled per-frame by the FOV compensation factor. + */ +export const environmentBaseIntensity = 0.9; + +// ── Headlamp configuration ───────────────────────────────────────────────── + +export type HeadlampConfig = { + /** Camera-right offset (multiplier of sceneRadius). */ + rightOffset: number; + /** Camera-up offset (multiplier of sceneRadius). */ + upOffset: number; + /** Target camera-right skew (multiplier of sceneRadius). */ + targetRightSkew: number; + /** Target camera-up skew (multiplier of sceneRadius). */ + targetUpSkew: number; +}; + +/** Default headlamp offset configuration matching the studio lighting rig. */ +export const defaultHeadlampConfig: HeadlampConfig = { + rightOffset: -0.1, + upOffset: 2.1, + targetRightSkew: 0.2, + targetUpSkew: 0.1, +}; + +// ── userData tag constants ───────────────────────────────────────────────── +// Used to identify lights in a cloned scene for screenshot rendering. + +export const lightingUserDataKeys = { + headlamp: 'isScreenshotHeadlamp', + ambient: 'isScreenshotAmbient', + config: 'lightingConfig', +} as const; + +// ── Lighting config stored on scene.userData ─────────────────────────────── + +export type SceneLightingConfig = { + sceneRadius: number; + upDirection: 'x' | 'y' | 'z'; +}; + +// ── Combined config for applyLightingForCamera ───────────────────────────── + +export type LightingConfig = { + sceneRadius: number; + upDirection: 'x' | 'y' | 'z'; + headlampIntensity: number; + ambientIntensity: number; + environmentIntensity: number; + headlampConfig: HeadlampConfig; +}; + +// ── Pure functions ───────────────────────────────────────────────────────── + +/** + * Angle (in radians) from either pole within which the yaw compensation + * fades to zero. At the equator the blend is 1 (full compensation); inside + * this cap the blend smoothsteps to 0 so the environment becomes world- + * fixed and small camera perturbations don't swing the lighting wildly. + * + * 15° keeps standard top/bottom views stable while leaving the vast + * majority of the viewing sphere (150° out of 180°) fully compensated. + */ +export const poleFadeAngleDeg = 15; + +/** `sin²(poleFadeAngle)` — precomputed threshold for the smoothstep ramp. */ +const poleFadeThreshold = Math.sin((poleFadeAngleDeg * Math.PI) / 180) ** 2; + +/** + * Computes the environment rotation Euler that cancels only the azimuthal + * (yaw) component of the camera's world orientation around the up axis. + * + * By not compensating for polar (pitch) or roll changes, the Lightformers + * remain stable during horizontal orbit but shift relative to the camera + * when tilting up/down — producing natural lighting variation at different + * viewing elevations instead of a uniformly locked rig. + * + * **Swing-twist decomposition** extracts yaw in quaternion space, avoiding + * the gimbal-lock discontinuity of Euler decomposition that caused 180° + * lighting hops at the equatorial plane. + * + * **Pole-proximity fade** attenuates the yaw compensation smoothly to zero + * within {@link poleFadeAngleDeg}° of either pole (top/bottom view). Near + * the poles azimuth is geometrically ill-defined and tiny camera movements + * would otherwise cause wild lighting swings. The fade uses a smoothstep + * over `sin²(polar_angle)` which is symmetric around both poles. + * + * Three.js internally negates all Euler components of `environmentRotation` + * (WebGLMaterials.js "accommodate left-handed frame"), so the returned yaw + * angle is provided with the sign that compensates for this negation. + * + * @param cameraWorldQuaternion - The camera's world quaternion. + * @param upDirection - The configured up axis ('x', 'y', or 'z'). + * @returns An Euler suitable for assigning to `scene.environmentRotation`. + */ +export function computeEnvironmentRotation( + cameraWorldQuaternion: THREE.Quaternion, + upDirection: 'x' | 'y' | 'z', +): THREE.Euler { + const order: THREE.EulerOrder = upDirection === 'y' ? 'YXZ' : upDirection === 'z' ? 'ZXY' : 'XZY'; + + // ── Swing-twist decomposition ─────────────────────────────────────────── + // For quaternion Q = (x, y, z, w) the twist around the up axis keeps only + // the scalar (w) and the component aligned with the up axis. The yaw angle + // is 2·atan2(axisComponent, w). + const { x, y, z, w } = cameraWorldQuaternion; + const axisComponent = upDirection === 'z' ? z : upDirection === 'y' ? y : x; + + const twistLengthSq = axisComponent * axisComponent + w * w; + + // Hard safety net: both twist components ≈ 0 → azimuth truly undefined. + if (twistLengthSq < 1e-10) { + return new THREE.Euler(0, 0, 0, order); + } + + const yaw = 2 * Math.atan2(axisComponent, w); + + // ── Pole-proximity fade ───────────────────────────────────────────────── + // sin²(polar_angle) = 4 · twistLen² · swingLen². This equals 0 at both + // poles and 1 at the equator. We smoothstep from 0 → 1 over the + // [0, poleFadeThreshold] range so the yaw compensation fades out + // gracefully within ~15° of either pole. + const swingLengthSq = Math.max(0, 1 - twistLengthSq); // Clamp for float drift + const sinPolarSq = 4 * twistLengthSq * swingLengthSq; + + const t = Math.min(1, sinPolarSq / poleFadeThreshold); + const blend = t * t * (3 - 2 * t); // Smoothstep + + const effectiveYaw = yaw * blend; + + if (upDirection === 'z') { + return new THREE.Euler(0, 0, effectiveYaw, order); + } + + if (upDirection === 'y') { + return new THREE.Euler(0, effectiveYaw, 0, order); + } + + // UpDirection === 'x' + return new THREE.Euler(effectiveYaw, 0, 0, order); +} + +/** + * Computes the world-space position and target for a camera-relative + * directional headlamp. + * + * The headlamp is offset in camera-up and camera-right directions so the + * highlight remains biased toward screen upper-right. The target is placed + * forward of the camera with slight lower-left skew. + * + * @param options - Headlamp transform options. + * @param options.cameraPosition - The camera's world position. + * @param options.cameraMatrixWorld - The camera's world matrix (used for basis vectors). + * @param options.sceneRadius - The bounding sphere radius of the scene. + * @param options.config - Offset multipliers for headlamp placement. + * @returns The world-space position and target position for the headlamp. + */ +export function computeHeadlampTransform(options: { + cameraPosition: THREE.Vector3; + cameraMatrixWorld: THREE.Matrix4; + sceneRadius: number; + config: HeadlampConfig; +}): { position: THREE.Vector3; targetPosition: THREE.Vector3 } { + const { cameraPosition, cameraMatrixWorld, sceneRadius, config } = options; + // Camera basis vectors in world space: + // - column 0: camera-right (+X local) + // - column 1: camera-up (+Y local) + const cameraRight = new THREE.Vector3().setFromMatrixColumn(cameraMatrixWorld, 0).normalize(); + const cameraUp = new THREE.Vector3().setFromMatrixColumn(cameraMatrixWorld, 1).normalize(); + + // Position: camera + up offset + right offset + const position = cameraPosition.clone(); + position.addScaledVector(cameraUp, sceneRadius * config.upOffset); + position.addScaledVector(cameraRight, sceneRadius * config.rightOffset); + + // Camera forward direction + const cameraForward = new THREE.Vector3(); + // Extract forward from the matrix column instead of calling getWorldDirection + // so this function remains standalone (no camera method dependency). + cameraForward.setFromMatrixColumn(cameraMatrixWorld, 2).normalize().negate(); + + // Target: forward of camera with skew offsets + const targetPosition = cameraPosition.clone(); + targetPosition.addScaledVector(cameraForward, sceneRadius * 2); + targetPosition.addScaledVector(cameraRight, -sceneRadius * config.targetRightSkew); + targetPosition.addScaledVector(cameraUp, -sceneRadius * config.targetUpSkew); + + return { position, targetPosition }; +} + +// ── applyLightingForCamera options ────────────────────────────────────────── + +export type ApplyLightingOptions = { + /** The THREE.Scene to update. */ + scene: THREE.Scene; + /** The camera whose orientation drives lighting. */ + camera: THREE.Camera; + /** Optional directional light to position as headlamp. */ + headlamp: THREE.DirectionalLight | undefined; + /** Optional ambient light whose intensity to compensate. */ + ambient: THREE.AmbientLight | undefined; + /** Lighting configuration with base intensities and offsets. */ + config: LightingConfig; +}; + +/** + * Applies camera-relative lighting to a scene for the given camera. + * + * This function is the single source of truth for per-camera lighting setup. + * It is called by: + * - The `Lights` component's `useFrame` callback (live rendering) + * - The `captureScreenshots` function (offline screenshot rendering) + * + * It performs: + * 1. FOV-dependent intensity compensation + * 2. Camera-locked environment rotation + * 3. Headlamp positioning (if headlamp provided) + * 4. Ambient intensity update (if ambient light provided) + */ +export function applyLightingForCamera({ scene, camera, headlamp, ambient, config }: ApplyLightingOptions): void { + // FOV compensation + const currentFov = (camera as THREE.PerspectiveCamera).fov; + const compensation = calculateFovLightingCompensation(currentFov); + + // Environment intensity + scene.environmentIntensity = config.environmentIntensity * compensation.envFactor; + + // Camera-locked environment rotation + const quaternion = new THREE.Quaternion(); + camera.getWorldQuaternion(quaternion); + const rotation = computeEnvironmentRotation(quaternion, config.upDirection); + scene.environmentRotation.copy(rotation); + + // Ambient intensity with FOV compensation + if (ambient) { + ambient.intensity = config.ambientIntensity * compensation.ambientFactor; + } + + // Headlamp positioning with FOV compensation + if (headlamp) { + headlamp.intensity = config.headlampIntensity * compensation.headlampFactor; + + const transform = computeHeadlampTransform({ + cameraPosition: camera.position, + cameraMatrixWorld: camera.matrixWorld, + sceneRadius: config.sceneRadius, + config: config.headlampConfig, + }); + + headlamp.position.copy(transform.position); + headlamp.target.position.copy(transform.targetPosition); + headlamp.target.updateMatrixWorld(); + } +} + +/** + * Discovers tagged lights in a scene graph by traversing and checking + * `userData` markers. Used by the screenshot capture system to find the + * headlamp and ambient light in a cloned scene. + * + * @param scene - The scene to search. + * @returns The tagged headlamp and ambient light, or undefined if not found. + */ +export function findTaggedLights(scene: THREE.Scene): { + headlamp: THREE.DirectionalLight | undefined; + ambient: THREE.AmbientLight | undefined; +} { + let headlamp: THREE.DirectionalLight | undefined; + let ambient: THREE.AmbientLight | undefined; + + scene.traverse((object) => { + if (object.userData[lightingUserDataKeys.headlamp]) { + headlamp = object as THREE.DirectionalLight; + } + + if (object.userData[lightingUserDataKeys.ambient]) { + ambient = object as THREE.AmbientLight; + } + }); + + return { headlamp, ambient }; +} diff --git a/packages/three/src/utils/math.utils.test.ts b/packages/three/src/utils/math.utils.test.ts new file mode 100644 index 000000000..0f6e542e1 --- /dev/null +++ b/packages/three/src/utils/math.utils.test.ts @@ -0,0 +1,383 @@ +import { describe, expect, it } from 'vitest'; +import { + calculateFovFromAngle, + calculateGizmoFovFromAngle, + calculateFovDistanceCompensation, + calculateFovLightingCompensation, + gizmoFovScale, + fovCompensationReferenceFov, + fovCompensationEnvMin, + fovCompensationEnvMax, +} from '#utils/math.utils.js'; + +describe('calculateFovFromAngle', () => { + describe('boundary values', () => { + it('should return 0.1 (near-orthographic minimum) when angle is 0', () => { + expect(calculateFovFromAngle(0)).toBeCloseTo(0.1, 10); + }); + + it('should return 90 (maximum perspective) when angle is 90', () => { + expect(calculateFovFromAngle(90)).toBeCloseTo(90, 10); + }); + }); + + describe('known intermediate values', () => { + it('should return ~60.03 for default angle of 60', () => { + const expected = 0.1 + 89.9 * (60 / 90); + expect(calculateFovFromAngle(60)).toBeCloseTo(expected, 10); + }); + + it('should return ~45.05 for midpoint angle of 45', () => { + const expected = 0.1 + 89.9 * (45 / 90); + expect(calculateFovFromAngle(45)).toBeCloseTo(expected, 10); + }); + }); + + describe('linearity', () => { + it('should follow the formula 0.1 + 89.9 * (angle / 90) for several values', () => { + for (const angle of [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]) { + const expected = 0.1 + 89.9 * (angle / 90); + expect(calculateFovFromAngle(angle)).toBeCloseTo(expected, 10); + } + }); + }); + + describe('monotonicity', () => { + it('should strictly increase over the range [0, 90]', () => { + let previous = calculateFovFromAngle(0); + for (let angle = 1; angle <= 90; angle++) { + const current = calculateFovFromAngle(angle); + expect(current).toBeGreaterThan(previous); + previous = current; + } + }); + }); + + describe('clamping', () => { + it('should clamp negative input to the minimum FOV (0.1)', () => { + expect(calculateFovFromAngle(-10)).toBeCloseTo(0.1, 10); + expect(calculateFovFromAngle(-100)).toBeCloseTo(0.1, 10); + }); + + it('should clamp input above 90 to the maximum FOV (90)', () => { + expect(calculateFovFromAngle(100)).toBeCloseTo(90, 10); + expect(calculateFovFromAngle(180)).toBeCloseTo(90, 10); + }); + }); +}); + +describe('calculateGizmoFovFromAngle', () => { + it('should return the same minimum FOV as calculateFovFromAngle when angle is 0', () => { + expect(calculateGizmoFovFromAngle(0)).toBeCloseTo(calculateFovFromAngle(0), 10); + }); + + it('should return half the maximum FOV when angle is 90', () => { + // With GIZMO_FOV_SCALE = 0.5, slider 90 maps to calculateFovFromAngle(45) ≈ 45.05 + const expected = calculateFovFromAngle(90 * gizmoFovScale); + expect(calculateGizmoFovFromAngle(90)).toBeCloseTo(expected, 10); + }); + + it('should equal calculateFovFromAngle(angle * GIZMO_FOV_SCALE) for several values', () => { + for (const angle of [0, 15, 30, 45, 60, 75, 90]) { + const expected = calculateFovFromAngle(angle * gizmoFovScale); + expect(calculateGizmoFovFromAngle(angle)).toBeCloseTo(expected, 10); + } + }); + + it('should always be less than or equal to the viewport FOV for the same slider value', () => { + for (let angle = 0; angle <= 90; angle += 5) { + const viewportFov = calculateFovFromAngle(angle); + const gizmoFov = calculateGizmoFovFromAngle(angle); + expect(gizmoFov).toBeLessThanOrEqual(viewportFov); + } + }); + + it('should be strictly monotonically increasing over [0, 90]', () => { + let previous = calculateGizmoFovFromAngle(0); + for (let angle = 1; angle <= 90; angle++) { + const current = calculateGizmoFovFromAngle(angle); + expect(current).toBeGreaterThan(previous); + previous = current; + } + }); +}); + +describe('calculateFovDistanceCompensation', () => { + describe('identity', () => { + it('should return the same distance when old and new FOV are equal', () => { + expect(calculateFovDistanceCompensation(60, 60, 100)).toBeCloseTo(100, 6); + expect(calculateFovDistanceCompensation(26, 26, 7)).toBeCloseTo(7, 6); + expect(calculateFovDistanceCompensation(0.1, 0.1, 1000)).toBeCloseTo(1000, 6); + }); + }); + + describe('direction', () => { + it('should return a larger distance when narrowing FOV (camera moves back)', () => { + const result = calculateFovDistanceCompensation(60, 30, 100); + expect(result).toBeGreaterThan(100); + }); + + it('should return a smaller distance when widening FOV (camera moves closer)', () => { + const result = calculateFovDistanceCompensation(30, 60, 100); + expect(result).toBeLessThan(100); + }); + }); + + describe('known values', () => { + it('should produce the correct result for base FOV=26, new FOV=60, distance=7', () => { + const degToRad = Math.PI / 180; + const expected = 7 * (Math.tan((26 / 2) * degToRad) / Math.tan((60 / 2) * degToRad)); + expect(calculateFovDistanceCompensation(26, 60, 7)).toBeCloseTo(expected, 6); + }); + + it('should produce the correct result for FOV=60, new FOV=26, distance=7', () => { + const degToRad = Math.PI / 180; + const expected = 7 * (Math.tan((60 / 2) * degToRad) / Math.tan((26 / 2) * degToRad)); + expect(calculateFovDistanceCompensation(60, 26, 7)).toBeCloseTo(expected, 6); + }); + }); + + describe('round-trip symmetry', () => { + it('should return to the original distance after compensating there and back', () => { + const original = 100; + const intermediate = calculateFovDistanceCompensation(60, 30, original); + const restored = calculateFovDistanceCompensation(30, 60, intermediate); + expect(restored).toBeCloseTo(original, 6); + }); + + it('should be symmetric for arbitrary FOV pairs', () => { + const original = 42; + const intermediate = calculateFovDistanceCompensation(15, 75, original); + const restored = calculateFovDistanceCompensation(75, 15, intermediate); + expect(restored).toBeCloseTo(original, 6); + }); + }); + + describe('zero distance', () => { + it('should return 0 regardless of FOV values', () => { + expect(calculateFovDistanceCompensation(60, 30, 0)).toBe(0); + expect(calculateFovDistanceCompensation(30, 60, 0)).toBe(0); + }); + }); + + describe('epsilon guards', () => { + it('should return a finite value for small but valid FOV (0.001)', () => { + const result = calculateFovDistanceCompensation(60, 0.001, 100); + expect(Number.isFinite(result)).toBe(true); + expect(result).toBeGreaterThan(100); + }); + + it('should return currentDistance unchanged for truly degenerate FOV (1e-8)', () => { + // Tan(1e-8 / 2 * π/180) ≈ 8.7e-11, below the epsilon threshold + expect(calculateFovDistanceCompensation(60, 1e-8, 100)).toBe(100); + expect(calculateFovDistanceCompensation(1e-8, 60, 100)).toBe(100); + }); + + it('should return currentDistance unchanged for zero FOV', () => { + expect(calculateFovDistanceCompensation(60, 0, 100)).toBe(100); + expect(calculateFovDistanceCompensation(0, 60, 100)).toBe(100); + }); + }); + + describe('NaN guards', () => { + it('should return currentDistance unchanged when oldFov is NaN', () => { + expect(calculateFovDistanceCompensation(Number.NaN, 60, 100)).toBe(100); + }); + + it('should return currentDistance unchanged when newFov is NaN', () => { + expect(calculateFovDistanceCompensation(60, Number.NaN, 100)).toBe(100); + }); + + it('should return currentDistance unchanged when distance is NaN', () => { + const result = calculateFovDistanceCompensation(60, 30, Number.NaN); + expect(Number.isNaN(result)).toBe(true); + }); + }); + + describe('focusOffset parameter', () => { + it('should return the same distance when old and new FOV are equal, regardless of offset', () => { + expect(calculateFovDistanceCompensation(26, 26, 7, 0.7)).toBeCloseTo(7, 6); + expect(calculateFovDistanceCompensation(60, 60, 100, 5)).toBeCloseTo(100, 6); + }); + + it('should match the original formula when focusOffset is 0', () => { + const withDefault = calculateFovDistanceCompensation(26, 60, 7); + const withExplicitZero = calculateFovDistanceCompensation(26, 60, 7, 0); + expect(withExplicitZero).toBeCloseTo(withDefault, 10); + }); + + it('should produce the correct result for gizmo constants (FOV=26->90, dist=7, offset=0.7)', () => { + const degToRad = Math.PI / 180; + // NewDist = 0.7 + (7 - 0.7) * tan(13°) / tan(45°) + const expected = 0.7 + 6.3 * (Math.tan(13 * degToRad) / Math.tan(45 * degToRad)); + const result = calculateFovDistanceCompensation(26, 90, 7, 0.7); + expect(result).toBeCloseTo(expected, 6); + }); + + it('should keep the focus plane at constant projected size across FOV changes', () => { + const offset = 0.7; + const baseDistance = 7; + const baseFov = 26; + const degToRad = Math.PI / 180; + + // At any FOV, (newDist - offset) * tan(newFov/2) should equal (baseDist - offset) * tan(baseFov/2) + const baseProduct = (baseDistance - offset) * Math.tan((baseFov / 2) * degToRad); + + for (const newFov of [0.1, 10, 26, 45, 60, 90]) { + const newDist = calculateFovDistanceCompensation(baseFov, newFov, baseDistance, offset); + const newProduct = (newDist - offset) * Math.tan((newFov / 2) * degToRad); + expect(newProduct).toBeCloseTo(baseProduct, 6); + } + }); + + it('should return to the original distance after a round-trip with focusOffset', () => { + const original = 7; + const offset = 0.7; + const intermediate = calculateFovDistanceCompensation(26, 60, original, offset); + const restored = calculateFovDistanceCompensation(60, 26, intermediate, offset); + expect(restored).toBeCloseTo(original, 6); + }); + }); +}); + +describe('calculateFovLightingCompensation', () => { + describe('reference FOV identity', () => { + it('should return { envFactor: 1, headlampFactor: 1, ambientFactor: 1 } at reference FOV', () => { + const result = calculateFovLightingCompensation(fovCompensationReferenceFov); + expect(result.envFactor).toBeCloseTo(1, 6); + expect(result.headlampFactor).toBeCloseTo(1, 6); + expect(result.ambientFactor).toBeCloseTo(1, 6); + }); + }); + + describe('envFactor direction', () => { + it('should have envFactor < 1 for FOV below reference', () => { + const result = calculateFovLightingCompensation(10); + expect(result.envFactor).toBeLessThan(1); + }); + + it('should have envFactor > 1 for FOV above reference', () => { + const result = calculateFovLightingCompensation(90); + expect(result.envFactor).toBeGreaterThan(1); + }); + }); + + describe('diffuse compensation at low FOV', () => { + it('should have headlampFactor > 1 when envFactor < 1', () => { + const result = calculateFovLightingCompensation(10); + expect(result.envFactor).toBeLessThan(1); + expect(result.headlampFactor).toBeGreaterThan(1); + }); + + it('should have ambientFactor > 1 when envFactor < 1', () => { + const result = calculateFovLightingCompensation(10); + expect(result.envFactor).toBeLessThan(1); + expect(result.ambientFactor).toBeGreaterThan(1); + }); + }); + + describe('no compensation at high FOV', () => { + it('should have headlampFactor === 1 when envFactor >= 1', () => { + const result = calculateFovLightingCompensation(90); + expect(result.envFactor).toBeGreaterThanOrEqual(1); + expect(result.headlampFactor).toBeCloseTo(1, 6); + }); + + it('should have ambientFactor === 1 when envFactor >= 1', () => { + const result = calculateFovLightingCompensation(90); + expect(result.envFactor).toBeGreaterThanOrEqual(1); + expect(result.ambientFactor).toBeCloseTo(1, 6); + }); + }); + + describe('clamping', () => { + it('should clamp envFactor at minimum for very low FOV (0.1 deg)', () => { + const result = calculateFovLightingCompensation(0.1); + expect(result.envFactor).toBeCloseTo(fovCompensationEnvMin, 6); + }); + + it('should clamp envFactor at maximum for very high FOV', () => { + // At extremely high FOV, the tan ratio would exceed max clamp + const result = calculateFovLightingCompensation(179); + expect(result.envFactor).toBeCloseTo(fovCompensationEnvMax, 6); + }); + + it('should enforce envFactor >= envMin', () => { + for (const fov of [0.1, 0.5, 1, 2, 5]) { + const result = calculateFovLightingCompensation(fov); + expect(result.envFactor).toBeGreaterThanOrEqual(fovCompensationEnvMin); + } + }); + + it('should enforce envFactor <= envMax', () => { + for (const fov of [90, 120, 150, 170, 179]) { + const result = calculateFovLightingCompensation(fov); + expect(result.envFactor).toBeLessThanOrEqual(fovCompensationEnvMax); + } + }); + }); + + describe('NaN and degenerate input', () => { + it('should return neutral factors for NaN input', () => { + const result = calculateFovLightingCompensation(Number.NaN); + expect(result.envFactor).toBe(1); + expect(result.headlampFactor).toBe(1); + expect(result.ambientFactor).toBe(1); + }); + + it('should return finite factors for valid edge-case inputs', () => { + for (const fov of [0.001, 0.1, 1, 89, 90, 179]) { + const result = calculateFovLightingCompensation(fov); + expect(Number.isFinite(result.envFactor)).toBe(true); + expect(Number.isFinite(result.headlampFactor)).toBe(true); + expect(Number.isFinite(result.ambientFactor)).toBe(true); + } + }); + }); + + describe('monotonicity', () => { + it('should have envFactor monotonically increasing as FOV increases', () => { + let previousEnv = calculateFovLightingCompensation(0.1).envFactor; + for (let fov = 1; fov <= 90; fov++) { + const env = calculateFovLightingCompensation(fov).envFactor; + expect(env).toBeGreaterThanOrEqual(previousEnv); + previousEnv = env; + } + }); + + it('should have headlampFactor monotonically decreasing as FOV increases', () => { + let previousHeadlamp = calculateFovLightingCompensation(0.1).headlampFactor; + for (let fov = 1; fov <= 90; fov++) { + const headlamp = calculateFovLightingCompensation(fov).headlampFactor; + expect(headlamp).toBeLessThanOrEqual(previousHeadlamp); + previousHeadlamp = headlamp; + } + }); + + it('should have ambientFactor monotonically decreasing as FOV increases', () => { + let previousAmbient = calculateFovLightingCompensation(0.1).ambientFactor; + for (let fov = 1; fov <= 90; fov++) { + const ambient = calculateFovLightingCompensation(fov).ambientFactor; + expect(ambient).toBeLessThanOrEqual(previousAmbient); + previousAmbient = ambient; + } + }); + }); + + describe('custom parameters', () => { + it('should accept custom reference FOV', () => { + const result = calculateFovLightingCompensation(30, 30); + expect(result.envFactor).toBeCloseTo(1, 6); + expect(result.headlampFactor).toBeCloseTo(1, 6); + expect(result.ambientFactor).toBeCloseTo(1, 6); + }); + + it('should accept custom exponent', () => { + // Use FOV=50 (close to reference) so values stay above envMin clamp + const mildResult = calculateFovLightingCompensation(50, 54, 0.2); + const strongResult = calculateFovLightingCompensation(50, 54, 0.8); + // Stronger exponent = more aggressive compensation (lower envFactor at low FOV) + expect(strongResult.envFactor).toBeLessThan(mildResult.envFactor); + }); + }); +}); diff --git a/packages/three/src/utils/math.utils.ts b/packages/three/src/utils/math.utils.ts new file mode 100644 index 000000000..7c2a08acb --- /dev/null +++ b/packages/three/src/utils/math.utils.ts @@ -0,0 +1,195 @@ +/** + * Pure math utilities for camera FOV calculations. + */ + +const degToRad = Math.PI / 180; + +// FOV mapping constants +const minFov = 0.1; // Very narrow FOV (nearly orthographic) +const maxFov = 90; // Very wide FOV (extreme perspective) +const fovRange = maxFov - minFov; // 89.9 + +// Epsilon below which a half-FOV tangent is considered degenerate (avoids Infinity/NaN) +export const tanEpsilon = 1e-9; + +// --------------------------------------------------------------------------- +// Gizmo base constants (sourced from the three-viewport-gizmo library internals) +// See: node_modules/three-viewport-gizmo/dist/three-viewport-gizmo.js +// -> `new PerspectiveCamera(26, 1, 5, 10)` with `position.set(0, 0, 7)` +// --------------------------------------------------------------------------- + +/** Default FOV of the three-viewport-gizmo internal PerspectiveCamera (degrees). */ +export const gizmoBaseFov = 26; +/** Default Z-distance of the three-viewport-gizmo internal camera from origin. */ +export const gizmoBaseDistance = 7; +/** Depth margin around the gizmo origin for near/far plane calculation. */ +export const gizmoDepthMargin = 3; + +/** + * Focus offset for gizmo distance compensation. + * + * Derived from `GIZMO_CUBE_LOCAL_HALF_SIZE (1.0) * GIZMO_SCALE (0.7)`. + * The cube face centers sit at ±1.0 in the gizmo's local coordinate space; + * after `gizmo.scale.multiplyScalar(0.7)` the front face is at z = 0.7 in + * world space. Focusing the distance compensation on this plane keeps the + * front-facing cube face at a constant projected size regardless of FOV, + * so only the perspective look/feel changes. + */ +export const gizmoFocusOffset = 0.7; + +/** + * Scale factor applied to the viewport FOV slider value before mapping it to + * the gizmo's camera FOV. A value of 0.5 halves the effective FOV so that at + * the maximum slider position (90) the gizmo shows ~45° instead of 90°, + * significantly reducing perspective warping on the small cube. + */ +export const gizmoFovScale = 0.5; + +/** + * Maps a slider angle (0-90) to an actual camera FOV in degrees (0.1-90). + * Input is clamped to [0, 90]. + * + * The mapping is linear: `0.1 + 89.9 * (angle / 90)`. + * + * @param cameraFovAngle - The slider value in the range [0, 90]. + * @returns The camera FOV in degrees in the range [0.1, 90]. + */ +export function calculateFovFromAngle(cameraFovAngle: number): number { + const clamped = Math.max(0, Math.min(maxFov, cameraFovAngle)); + return minFov + fovRange * (clamped / maxFov); +} + +/** + * Maps a slider angle (0-90) to a **dampened** FOV for the viewport gizmo. + * + * Applies {@link gizmoFovScale} to the slider value before the standard + * linear mapping so that the gizmo never reaches extreme perspective angles. + * At `GIZMO_FOV_SCALE = 0.5`: + * - Slider 0 → 0.1° (same as viewport) + * - Slider 90 → ~45° (half of viewport's 90°) + * + * @param cameraFovAngle - The slider value in the range [0, 90]. + * @returns The dampened gizmo FOV in degrees. + */ +export function calculateGizmoFovFromAngle(cameraFovAngle: number): number { + return calculateFovFromAngle(cameraFovAngle * gizmoFovScale); +} + +// ── FOV lighting compensation ───────────────────────────────────────────────── +// At low FOV, parallel view rays cause specular highlights to wash out across +// entire flat faces. The compensation reduces scene.environmentIntensity +// (dimming specular wash) while boosting headlamp + ambient (compensating +// diffuse loss). All constants are tunable during visual iteration. + +/** FOV at which lighting looks "correct" — no compensation applied. */ +export const fovCompensationReferenceFov = 54; +/** Damping exponent (< 1.0 for partial compensation; 1.0 = full tan ratio). */ +export const fovCompensationExponent = 0.4; +/** Minimum envFactor clamp — prevents total darkness at near-orthographic FOV. */ +export const fovCompensationEnvMin = 0.9; +/** Maximum envFactor clamp — limits brightening at high FOV. */ +export const fovCompensationEnvMax = 1.2; +/** Fraction of diffuse loss redirected to headlamp boost. */ +export const fovCompensationHeadlampBoost = 2.5; +/** Fraction of diffuse loss redirected to ambient boost. */ +export const fovCompensationAmbientBoost = 3; + +export type FovLightingCompensation = { + /** Multiply scene.environmentIntensity by this factor. */ + envFactor: number; + /** Multiply headlamp intensity by this factor. */ + headlampFactor: number; + /** Multiply ambient intensity by this factor. */ + ambientFactor: number; +}; + +/** + * Computes FOV-dependent lighting compensation factors. + * + * At the reference FOV all factors are 1.0. Below reference FOV, `envFactor` + * decreases (dims specular wash) while `headlampFactor` and `ambientFactor` + * increase (compensates diffuse loss). Above reference FOV, `envFactor` + * increases (up to max clamp) and diffuse compensators stay at 1.0. + * + * @param currentFovDeg - The current camera FOV in degrees. + * @param referenceFovDeg - The FOV at which no compensation is applied. + * @param exponent - Damping exponent for the tan ratio. + * @returns Three coordinated compensation factors. + */ +export function calculateFovLightingCompensation( + currentFovDeg: number, + referenceFovDeg: number = fovCompensationReferenceFov, + exponent: number = fovCompensationExponent, +): FovLightingCompensation { + const currentHalfRad = (currentFovDeg / 2) * degToRad; + const refHalfRad = (referenceFovDeg / 2) * degToRad; + const tanCurrent = Math.tan(currentHalfRad); + const tanRef = Math.tan(refHalfRad); + + if (tanRef < tanEpsilon || Number.isNaN(currentFovDeg)) { + return { envFactor: 1, headlampFactor: 1, ambientFactor: 1 }; + } + + const rawEnv = Math.max(tanCurrent / tanRef, 1e-9) ** exponent; + const envFactor = Math.max(fovCompensationEnvMin, Math.min(fovCompensationEnvMax, rawEnv)); + + // Diffuse loss from reduced environment intensity + const diffuseLoss = Math.max(0, 1 - envFactor); + + return { + envFactor, + headlampFactor: 1 + diffuseLoss * fovCompensationHeadlampBoost, + ambientFactor: 1 + diffuseLoss * fovCompensationAmbientBoost, + }; +} + +/** + * Computes the new camera distance required to maintain perceived object size + * when the FOV changes. + * + * **Without `focusOffset` (default 0):** + * `newDistance = currentDistance * tan(oldFov/2) / tan(newFov/2)` + * Keeps objects at the origin at constant projected size. + * + * **With `focusOffset > 0`:** + * `newDistance = focusOffset + (currentDistance - focusOffset) * tan(oldFov/2) / tan(newFov/2)` + * Keeps the plane at `z = focusOffset` (toward the camera) at constant + * projected size. This is used for the viewport gizmo so that the cube's + * front face stays the same apparent size and only the perspective look/feel + * changes with FOV. + * + * Guards against degenerate inputs: + * - If either half-FOV tangent is below epsilon, returns `currentDistance` unchanged. + * - If any input is NaN, returns `currentDistance` unchanged. + * + * @param oldFovDeg - The previous FOV in degrees. + * @param newFovDeg - The new FOV in degrees. + * @param currentDistance - The current camera distance from the target. + * @param focusOffset - Z-offset from the origin toward the camera whose projected + * size should remain constant. Defaults to 0 (origin). + * @returns The compensated distance that keeps the focus plane the same apparent size. + */ +// eslint-disable-next-line max-params -- heavily used utility (30+ call sites); wrapping would add noise for simple numeric args +export function calculateFovDistanceCompensation( + oldFovDeg: number, + newFovDeg: number, + currentDistance: number, + focusOffset = 0, +): number { + if (Number.isNaN(oldFovDeg) || Number.isNaN(newFovDeg) || Number.isNaN(currentDistance)) { + return currentDistance; + } + + const oldHalfRad = (oldFovDeg / 2) * degToRad; + const newHalfRad = (newFovDeg / 2) * degToRad; + + const tanOld = Math.tan(oldHalfRad); + const tanNew = Math.tan(newHalfRad); + + if (Math.abs(tanOld) < tanEpsilon || Math.abs(tanNew) < tanEpsilon) { + return currentDistance; + } + + const effectiveDistance = currentDistance - focusOffset; + return focusOffset + effectiveDistance * (tanOld / tanNew); +} diff --git a/packages/three/src/utils/rotation.utils.ts b/packages/three/src/utils/rotation.utils.ts new file mode 100644 index 000000000..9f22182f1 --- /dev/null +++ b/packages/three/src/utils/rotation.utils.ts @@ -0,0 +1,67 @@ +import * as THREE from 'three'; + +/** + * Computes a quaternion that rotates an object around a given axis to face the camera. + * This creates a billboard effect constrained to rotation around a single axis. + * + * @param options - Axis rotation options. + * @param options.axis - The axis to rotate around (should be normalized) + * @param options.position - The position of the object in world space + * @param options.camera - The camera to face towards + * @param options.referenceUp - Optional reference "up" vector perpendicular to the axis (default: best perpendicular to axis) + * @returns Quaternion representing the rotation around the axis + */ +export function computeAxisRotationForCamera(options: { + axis: THREE.Vector3; + position: THREE.Vector3; + camera: THREE.Camera; + referenceUp?: THREE.Vector3; +}): THREE.Quaternion { + const { axis, position, camera, referenceUp } = options; + + // Calculate direction from object to camera + const eyeDirection = new THREE.Vector3(); + eyeDirection.subVectors(camera.position, position).normalize(); + + // Project the camera direction onto the plane perpendicular to the axis + // This gives us the direction towards the camera, constrained to rotation around the axis + const eyeProjected = new THREE.Vector3() + .copy(eyeDirection) + .addScaledVector(axis, -eyeDirection.dot(axis)) + .normalize(); + + // Get a reference "up" vector perpendicular to the axis + // If not provided, choose the best perpendicular vector + let upVector: THREE.Vector3; + if (referenceUp) { + upVector = referenceUp.clone(); + } else { + // Choose a reference vector that's not parallel to the axis + const absX = Math.abs(axis.x); + const absY = Math.abs(axis.y); + const absZ = Math.abs(axis.z); + + if (absX < absY && absX < absZ) { + upVector = new THREE.Vector3(1, 0, 0); + } else if (absY < absZ) { + upVector = new THREE.Vector3(0, 1, 0); + } else { + upVector = new THREE.Vector3(0, 0, 1); + } + } + + // Project the reference up vector onto the same plane perpendicular to the axis + const upProjected = new THREE.Vector3().copy(upVector).addScaledVector(axis, -upVector.dot(axis)).normalize(); + + // Calculate the signed angle between the projected vectors + const cosAngle = upProjected.dot(eyeProjected); + const crossProduct = new THREE.Vector3().crossVectors(upProjected, eyeProjected); + const sinAngle = crossProduct.dot(axis); + const rotationAngle = Math.atan2(sinAngle, cosAngle); + + // Create quaternion from axis-angle + const quaternion = new THREE.Quaternion(); + quaternion.setFromAxisAngle(axis, rotationAngle); + + return quaternion; +} diff --git a/packages/three/src/utils/snap-detection.utils.ts b/packages/three/src/utils/snap-detection.utils.ts new file mode 100644 index 000000000..5eebcbdd8 --- /dev/null +++ b/packages/three/src/utils/snap-detection.utils.ts @@ -0,0 +1,697 @@ +import * as THREE from 'three'; + +export type SnapPoint = { + position: THREE.Vector3; + type: 'vertex' | 'edge-midpoint'; +}; + +// Epsilon constants for coplanar face detection +const normalEpsilonCos = 0.9995; // Cos(theta) where theta ~ 1.8° +const planeDistanceEpsilon = 1e-4; // World units + +type Triangle = { + a: number; + b: number; + c: number; +}; + +function getTriangleIndexArray(geometry: THREE.BufferGeometry): Triangle[] { + const triangles: Triangle[] = []; + const index = geometry.getIndex(); + if (index) { + const array = index.array as ArrayLike; + for (let i = 0; i < array.length; i += 3) { + triangles.push({ a: array[i]!, b: array[i + 1]!, c: array[i + 2]! }); + } + } else { + const positionCount = geometry.getAttribute('position').count; + for (let i = 0; i < positionCount; i += 3) { + triangles.push({ a: i, b: i + 1, c: i + 2 }); + } + } + + return triangles; +} + +function computeWorldPositions(mesh: THREE.Mesh, geometry: THREE.BufferGeometry): THREE.Vector3[] { + const position = geometry.getAttribute('position'); + const worldPositions: THREE.Vector3[] = Array.from({ length: position.count }); + for (let i = 0; i < position.count; i++) { + const v = new THREE.Vector3().fromBufferAttribute(position, i); + v.applyMatrix4(mesh.matrixWorld); + worldPositions[i] = v; + } + + return worldPositions; +} + +function getTriangleVertices( + tri: Triangle, + worldPositions: THREE.Vector3[], +): [THREE.Vector3, THREE.Vector3, THREE.Vector3] { + return [worldPositions[tri.a]!, worldPositions[tri.b]!, worldPositions[tri.c]!]; +} + +function triangleNormalWorld(a: THREE.Vector3, b: THREE.Vector3, c: THREE.Vector3): THREE.Vector3 { + const ab = new THREE.Vector3().subVectors(b, a); + const ac = new THREE.Vector3().subVectors(c, a); + return new THREE.Vector3().crossVectors(ab, ac).normalize(); +} + +function pointPlaneDistance(normal: THREE.Vector3, constant: number, p: THREE.Vector3): number { + return normal.dot(p) - constant; +} + +function edgeKey(i: number, j: number): string { + return i < j ? `${i}|${j}` : `${j}|${i}`; +} + +type CoplanarFaceParameters = { + hitTriIndex: number; + triangles: Triangle[]; + worldPositions: THREE.Vector3[]; + refNormal: THREE.Vector3; + refConstant: number; + canonicalIndex: number[]; +}; + +function collectCoplanarContiguousFace(parameters: CoplanarFaceParameters): number[] { + const { hitTriIndex, triangles, worldPositions, refNormal, refConstant, canonicalIndex } = parameters; + const candidateFlags = Array.from({ length: triangles.length }).fill(false); + // Pre-filter triangles by plane distance and normal similarity + for (const [i, triangle] of triangles.entries()) { + const t = triangle; + const [a, b, c] = getTriangleVertices(t, worldPositions); + const n = triangleNormalWorld(a, b, c); + if (Math.abs(n.dot(refNormal)) < normalEpsilonCos) { + continue; + } + + const d1 = Math.abs(pointPlaneDistance(refNormal, refConstant, a)); + const d2 = Math.abs(pointPlaneDistance(refNormal, refConstant, b)); + const d3 = Math.abs(pointPlaneDistance(refNormal, refConstant, c)); + if (d1 < planeDistanceEpsilon && d2 < planeDistanceEpsilon && d3 < planeDistanceEpsilon) { + candidateFlags[i] = true; + } + } + + // Build adjacency for candidate triangles via shared edges + const edgeToTriangles = new Map(); + for (const [i, triangle] of triangles.entries()) { + if (!candidateFlags[i]) { + continue; + } + + const t = triangle; + const ca = canonicalIndex[t.a]!; + const cb = canonicalIndex[t.b]!; + const cc = canonicalIndex[t.c]!; + const edges: Array<[string, number, number]> = [ + [edgeKey(ca, cb), ca, cb], + [edgeKey(cb, cc), cb, cc], + [edgeKey(cc, ca), cc, ca], + ]; + for (const [k] of edges) { + const list = edgeToTriangles.get(k) ?? []; + list.push(i); + edgeToTriangles.set(k, list); + } + } + + // BFS from hitTriIndex to collect contiguous region + const visited = new Set(); + const queue: number[] = []; + if (candidateFlags[hitTriIndex]) { + queue.push(hitTriIndex); + visited.add(hitTriIndex); + } + + while (queue.length > 0) { + const idx = queue.shift()!; + const t = triangles[idx]!; + const ca = canonicalIndex[t.a]!; + const cb = canonicalIndex[t.b]!; + const cc = canonicalIndex[t.c]!; + const keys = [edgeKey(ca, cb), edgeKey(cb, cc), edgeKey(cc, ca)]; + for (const k of keys) { + const neighbors = edgeToTriangles.get(k) ?? []; + for (const nIdx of neighbors) { + if (candidateFlags[nIdx] && !visited.has(nIdx)) { + visited.add(nIdx); + queue.push(nIdx); + } + } + } + } + + return [...visited]; +} + +type ReferencePlane = { + normal: THREE.Vector3; + constant: number; + point: THREE.Vector3; +}; + +type BoundaryEdgeResult = { + boundaryEdges: Array<[number, number]>; + interiorEdges: Array<[number, number]>; +}; + +function getRaycastIntersection( + mesh: THREE.Mesh, + raycaster: THREE.Raycaster, +): THREE.Intersection | undefined { + const intersects = raycaster.intersectObject(mesh, true); + if (intersects.length === 0) { + return undefined; + } + + const intersection = intersects[0]; + if (!intersection?.face) { + return undefined; + } + + return intersection as THREE.Intersection; +} + +function buildCanonicalVertexIndices(worldPositions: THREE.Vector3[]): number[] { + const positionKey = (v: THREE.Vector3): string => `${v.x.toFixed(5)},${v.y.toFixed(5)},${v.z.toFixed(5)}`; + const keyToCanonical = new Map(); + const canonicalIndex: number[] = Array.from({ length: worldPositions.length }); + + let idx = 0; + for (const wp of worldPositions) { + const key = positionKey(wp); + if (!keyToCanonical.has(key)) { + keyToCanonical.set(key, idx); + } + + canonicalIndex[idx] = keyToCanonical.get(key)!; + idx++; + } + + return canonicalIndex; +} + +function findHitTriangleIndex(face: THREE.Face, triangles: Triangle[]): number { + const { a, b, c } = face; + for (const [i, triangle] of triangles.entries()) { + const t = triangle; + if ( + (t.a === a && t.b === b && t.c === c) || + (t.a === b && t.b === c && t.c === a) || + (t.a === c && t.b === a && t.c === b) + ) { + return i; + } + } + + return 0; // Fallback +} + +function computeReferencePlane(triangle: Triangle, worldPositions: THREE.Vector3[]): ReferencePlane { + const [pa, pb, pc] = getTriangleVertices(triangle, worldPositions); + const normal = triangleNormalWorld(pa, pb, pc).normalize(); + const constant = normal.dot(pa); + + return { normal, constant, point: pa }; +} + +function gatherBoundaryEdges( + faceTriangleIndices: number[], + triangles: Triangle[], + canonicalIndex: number[], +): BoundaryEdgeResult { + const edgeCount = new Map(); + const edgeCounter = new Map(); + + for (const idx of faceTriangleIndices) { + const t = triangles[idx]!; + const ca = canonicalIndex[t.a]!; + const cb = canonicalIndex[t.b]!; + const cc = canonicalIndex[t.c]!; + const edges: Array<[number, number]> = [ + [ca, cb], + [cb, cc], + [cc, ca], + ]; + + for (const [i, j] of edges) { + const k = edgeKey(i, j); + if (!edgeCount.has(k)) { + edgeCount.set(k, [i, j]); + } + + edgeCounter.set(k, (edgeCounter.get(k) ?? 0) + 1); + } + } + + const boundaryEdges: Array<[number, number]> = []; + const interiorEdges: Array<[number, number]> = []; + + for (const [k, count] of edgeCounter) { + const pair = edgeCount.get(k)!; + if (count === 1) { + boundaryEdges.push(pair); + } else if (count === 2) { + interiorEdges.push(pair); + } + } + + return { boundaryEdges, interiorEdges }; +} + +function tryDetectCircularFace({ + boundaryEdges, + worldPositions, + faceNormal, + planePoint, +}: { + boundaryEdges: Array<[number, number]>; + worldPositions: THREE.Vector3[]; + faceNormal: THREE.Vector3; + planePoint: THREE.Vector3; +}): SnapPoint[] | undefined { + const boundaryVertexIndices = new Set(); + for (const [i, j] of boundaryEdges) { + boundaryVertexIndices.add(i); + boundaryVertexIndices.add(j); + } + + const boundaryVerticesWorld: THREE.Vector3[] = [...boundaryVertexIndices].map((idx) => worldPositions[idx]!); + return detectCircleOnFace(boundaryVerticesWorld, faceNormal, planePoint); +} + +function collectBoundarySnapPoints( + boundaryEdges: Array<[number, number]>, + worldPositions: THREE.Vector3[], +): { snapPoints: SnapPoint[]; addPoint: (v: THREE.Vector3, type: SnapPoint['type']) => void } { + const snapPoints: SnapPoint[] = []; + const seen = new Set(); + + const addPoint = (v: THREE.Vector3, type: SnapPoint['type']): void => { + const key = `${v.x.toFixed(6)},${v.y.toFixed(6)},${v.z.toFixed(6)}`; + if (!seen.has(key)) { + snapPoints.push({ position: v.clone(), type }); + seen.add(key); + } + }; + + for (const [i, j] of boundaryEdges) { + const vi = worldPositions[i]!; + const vj = worldPositions[j]!; + addPoint(vi, 'vertex'); + addPoint(vj, 'vertex'); + addPoint(new THREE.Vector3().addVectors(vi, vj).multiplyScalar(0.5), 'edge-midpoint'); + } + + return { snapPoints, addPoint }; +} + +function orderBoundaryVertices(boundaryEdges: Array<[number, number]>): { + ordered: number[]; + boundaryVertexIndexSet: Set; +} { + const boundaryVertexIndexSet = new Set(); + const boundaryAdj = new Map(); + + for (const [i, j] of boundaryEdges) { + boundaryVertexIndexSet.add(i); + boundaryVertexIndexSet.add(j); + + const ai = boundaryAdj.get(i) ?? []; + ai.push(j); + boundaryAdj.set(i, ai); + + const aj = boundaryAdj.get(j) ?? []; + aj.push(i); + boundaryAdj.set(j, aj); + } + + const boundaryIndexList = [...boundaryVertexIndexSet]; + const ordered: number[] = []; + + if (boundaryIndexList.length >= 3) { + const start = boundaryIndexList[0]!; + let previous = -1; + let curr = start; + const maxSteps = boundaryIndexList.length + 5; + + for (let step = 0; step < maxSteps; step++) { + ordered.push(curr); + // eslint-disable-next-line @typescript-eslint/no-loop-func -- `previous` is always defined in the loop + const neighbors = (boundaryAdj.get(curr) ?? []).filter((n) => n !== previous); + if (neighbors.length === 0) { + break; + } + + const next = neighbors[0]!; + previous = curr; + curr = next; + if (curr === start) { + break; + } + } + } + + return { ordered, boundaryVertexIndexSet }; +} + +type FaceCenterParameters = { + ordered: number[]; + boundaryVertexIndexSet: Set; + worldPositions: THREE.Vector3[]; + refNormal: THREE.Vector3; + refPoint: THREE.Vector3; +}; + +function computeFaceCenter(parameters: FaceCenterParameters): THREE.Vector3 { + const { ordered, boundaryVertexIndexSet, worldPositions, refNormal, refPoint } = parameters; + const { u: planeU, v: planeV } = constructPlaneAxes(refNormal); + let center: THREE.Vector3 | undefined; + + if (ordered.length >= 3) { + // Area-weighted centroid in 2D then map back to 3D + let area = 0; + let cx = 0; + let cy = 0; + + const to2D = (idx: number): { x: number; y: number } => { + const p = worldPositions[idx]!; + const rel = new THREE.Vector3().subVectors(p, refPoint); + return { x: rel.dot(planeU), y: rel.dot(planeV) }; + }; + + for (let i = 0; i < ordered.length; i++) { + const a = to2D(ordered[i]!); + const b = to2D(ordered[(i + 1) % ordered.length]!); + const cross = a.x * b.y - a.y * b.x; + area += cross; + cx += (a.x + b.x) * cross; + cy += (a.y + b.y) * cross; + } + + area *= 0.5; + if (Math.abs(area) > 1e-9) { + cx /= 6 * area; + cy /= 6 * area; + center = new THREE.Vector3() + .copy(refPoint) + .add(new THREE.Vector3().copy(planeU).multiplyScalar(cx)) + .add(new THREE.Vector3().copy(planeV).multiplyScalar(cy)); + } + } + + if (!center) { + // Fallback: average of boundary vertices + center = new THREE.Vector3(); + for (const idx of boundaryVertexIndexSet) { + center.add(worldPositions[idx]!); + } + + const boundaryIndexList = [...boundaryVertexIndexSet]; + center.multiplyScalar(1 / Math.max(1, boundaryIndexList.length)); + } + + return center; +} + +export function detectSnapPoints(mesh: THREE.Mesh, raycaster: THREE.Raycaster): SnapPoint[] { + // 1. Get raycast intersection + const intersection = getRaycastIntersection(mesh, raycaster); + if (!intersection) { + return []; + } + + // 2. Extract geometry data + const { object } = intersection; + const { geometry } = object; + const triangles = getTriangleIndexArray(geometry); + const worldPositions = computeWorldPositions(object, geometry); + + // 3. Build canonical vertex indices to merge coincident vertices + const canonicalIndex = buildCanonicalVertexIndices(worldPositions); + + // 4. Find the hit triangle + const triIndex = findHitTriangleIndex(intersection.face!, triangles); + + // 5. Compute reference plane from hit triangle + const { + normal: refNormal, + constant: refConstant, + point: refPoint, + } = computeReferencePlane(triangles[triIndex]!, worldPositions); + + // 6. Collect contiguous coplanar face region + const faceTriangleIndices = collectCoplanarContiguousFace({ + hitTriIndex: triIndex, + triangles, + worldPositions, + refNormal, + refConstant, + canonicalIndex, + }); + + // 7. Gather boundary edges + const { boundaryEdges } = gatherBoundaryEdges(faceTriangleIndices, triangles, canonicalIndex); + + // 8. Try circular face detection first + const maybeCircle = tryDetectCircularFace({ + boundaryEdges, + worldPositions, + faceNormal: refNormal, + planePoint: refPoint, + }); + if (maybeCircle) { + return maybeCircle; + } + + // 9. Collect boundary snap points + const { snapPoints, addPoint } = collectBoundarySnapPoints(boundaryEdges, worldPositions); + + // 10. Compute and add face center + const { ordered, boundaryVertexIndexSet } = orderBoundaryVertices(boundaryEdges); + const center = computeFaceCenter({ + ordered, + boundaryVertexIndexSet, + worldPositions, + refNormal, + refPoint, + }); + addPoint(center, 'vertex'); + + return snapPoints; +} + +// ---------------------- Circle detection helpers ---------------------- + +function constructPlaneAxes(normal: THREE.Vector3): { u: THREE.Vector3; v: THREE.Vector3 } { + const tryAxis = (axis: THREE.Vector3): THREE.Vector3 => { + const proj = axis.clone().addScaledVector(normal, -axis.dot(normal)); + if (proj.lengthSq() < 1e-10) { + return proj; + } + + return proj.normalize(); + }; + + let u = tryAxis(new THREE.Vector3(1, 0, 0)); + if (u.lengthSq() < 1e-10) { + u = tryAxis(new THREE.Vector3(0, 1, 0)); + } + + if (u.lengthSq() < 1e-10) { + const temporary = Math.abs(normal.x) < 0.9 ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0); + u = tryAxis(temporary); + } + + const v = new THREE.Vector3().crossVectors(normal, u).normalize(); + return { u, v }; +} + +function fitCircle2D(points: Array<{ x: number; y: number }>): { cx: number; cy: number; r: number } | undefined { + let sxx = 0; + let syy = 0; + let sxy = 0; + let sx = 0; + let sy = 0; + let szz = 0; + let sxz = 0; + let syz = 0; + + for (const p of points) { + const z = p.x * p.x + p.y * p.y; + sxx += p.x * p.x; + syy += p.y * p.y; + sxy += p.x * p.y; + sx += p.x; + sy += p.y; + szz += z; + sxz += p.x * z; + syz += p.y * z; + } + + const n = points.length; + + const aMatrix = [ + [sxx, sxy, sx], + [sxy, syy, sy], + [sx, sy, n], + ]; + + const bVector = [sxz * 0.5, syz * 0.5, szz * 0.5]; + const sol = solveSymmetric3(aMatrix, bVector); + if (!sol) { + return undefined; + } + + const [cx, cy, c] = sol; + const r = Math.sqrt(Math.max(0, cx * cx + cy * cy + c)); + if (!Number.isFinite(r)) { + return undefined; + } + + return { cx, cy, r }; +} + +function solveSymmetric3(aMatrix: number[][], bVector: number[]): [number, number, number] | undefined { + const m: number[][] = [[...aMatrix[0]!], [...aMatrix[1]!], [...aMatrix[2]!]]; + const b = [...bVector]; + + for (let i = 0; i < 3; i++) { + let pivot = i; + for (let r = i + 1; r < 3; r++) { + if (Math.abs(m[r]![i]!) > Math.abs(m[pivot]![i]!)) { + pivot = r; + } + } + + if (Math.abs(m[pivot]![i]!) < 1e-12) { + return undefined; + } + + if (pivot !== i) { + [m[i], m[pivot]] = [m[pivot]!, m[i]!]; + [b[i], b[pivot]] = [b[pivot]!, b[i]!]; + } + + for (let r = i + 1; r < 3; r++) { + const factor = m[r]![i]! / m[i]![i]!; + for (let c = i; c < 3; c++) { + m[r]![c]! -= factor * m[i]![c]!; + } + + b[r]! -= factor * b[i]!; + } + } + + const x: [number, number, number] = [0, 0, 0]; + for (let i = 2; i >= 0; i--) { + let sum = b[i]!; + for (let c = i + 1; c < 3; c++) { + sum -= m[i]![c]! * x[c]!; + } + + x[i] = sum / m[i]![i]!; + } + + return [x[0], x[1], x[2]]; +} + +function detectCircleOnFace( + boundaryVerticesWorld: THREE.Vector3[], + faceNormal: THREE.Vector3, + planePoint: THREE.Vector3, +): SnapPoint[] | undefined { + const minSamples = 12; + if (boundaryVerticesWorld.length < minSamples) { + return undefined; + } + + const { u, v } = constructPlaneAxes(faceNormal.clone().normalize()); + + const pts2D = boundaryVerticesWorld.map((p) => { + const rel = new THREE.Vector3().subVectors(p, planePoint); + return { x: rel.dot(u), y: rel.dot(v) }; + }); + + const fit = fitCircle2D(pts2D); + if (!fit) { + return undefined; + } + + const { cx, cy, r } = fit; + if (!Number.isFinite(r) || r <= 0) { + return undefined; + } + + let r2sum = 0; + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (const p of pts2D) { + const dx = p.x - cx; + const dy = p.y - cy; + const di = Math.hypot(dx, dy); + r2sum += (di - r) * (di - r); + minX = Math.min(minX, p.x); + maxX = Math.max(maxX, p.x); + minY = Math.min(minY, p.y); + maxY = Math.max(maxY, p.y); + } + + const rms = Math.sqrt(r2sum / pts2D.length); + const relRms = rms / r; + const width = maxX - minX; + const height = maxY - minY; + const aspect = Math.max(width, height) / Math.max(1e-9, Math.min(width, height)); + if (!(relRms <= 0.03 && aspect <= 1.05)) { + return undefined; + } + + const centerWorld = planePoint.clone().addScaledVector(u, cx).addScaledVector(v, cy); + + const result: SnapPoint[] = [ + { position: centerWorld.clone().addScaledVector(u, r), type: 'edge-midpoint' }, + { position: centerWorld.clone().addScaledVector(u, -r), type: 'edge-midpoint' }, + { position: centerWorld.clone().addScaledVector(v, r), type: 'edge-midpoint' }, + { position: centerWorld.clone().addScaledVector(v, -r), type: 'edge-midpoint' }, + { position: centerWorld, type: 'vertex' }, + ]; + return result; +} + +export function findClosestSnapPoint( + snapPoints: SnapPoint[], + options: { + mousePos: THREE.Vector2; + camera: THREE.Camera; + canvas: HTMLCanvasElement; + snapDistancePx: number; + snapPointBufferPx?: number; + }, +): SnapPoint | undefined { + const { mousePos, camera, canvas, snapDistancePx, snapPointBufferPx = 15 } = options; + let closest: SnapPoint | undefined; + let minDistance = snapDistancePx + snapPointBufferPx; + + for (const snapPoint of snapPoints) { + const screenPos = snapPoint.position.clone().project(camera); + const canvasX = (screenPos.x + 1) * 0.5 * canvas.width; + const canvasY = (-screenPos.y + 1) * 0.5 * canvas.height; + + const mouseCanvasX = (mousePos.x + 1) * 0.5 * canvas.width; + const mouseCanvasY = (-mousePos.y + 1) * 0.5 * canvas.height; + + const distance = Math.hypot(canvasX - mouseCanvasX, canvasY - mouseCanvasY); + + if (distance < minDistance) { + minDistance = distance; + closest = snapPoint; + } + } + + return closest; +} diff --git a/packages/three/src/utils/spatial.utils.ts b/packages/three/src/utils/spatial.utils.ts new file mode 100644 index 000000000..215a2df8b --- /dev/null +++ b/packages/three/src/utils/spatial.utils.ts @@ -0,0 +1,18 @@ +import type * as THREE from 'three'; + +export type PixelsToWorldUnitsInput = { + // Accepts a loosely-typed viewport to avoid version-specific type coupling. + viewport: unknown; + camera: THREE.Camera; + size: { width: number; height: number }; + at: THREE.Vector3; + pixels: number; +}; + +// Convert a pixel measure to world units at a given world-space point. +export function pixelsToWorldUnits({ viewport, camera, size, at, pixels }: PixelsToWorldUnitsInput): number { + const { getCurrentViewport } = viewport as { getCurrentViewport: (...args: unknown[]) => unknown }; + const vp = getCurrentViewport(camera, at) as { width: number; height: number }; + const worldPerPixel = vp.height / size.height; + return pixels * worldPerPixel; +} From d0d9db98bdd5130ffda1ca254d722993386c0f97 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 25 Feb 2026 10:21:39 +0000 Subject: [PATCH 03/14] Copy React components from apps/ui to packages/three - gltf-mesh, axes-helper, infinite-grid, lights - section-view, section-view-controls, transform-controls-drei - scene-overlay Updated imports: #components/geometry/graphics/three/ -> # section-view-controls: #utils/color.utils.js (package local) Updated react/index.ts with exports Co-authored-by: richard --- packages/three/src/react/axes-helper.tsx | 109 +++ packages/three/src/react/gltf-mesh.tsx | 335 +++++++++ packages/three/src/react/index.ts | 16 + packages/three/src/react/infinite-grid.tsx | 76 ++ packages/three/src/react/lights.tsx | 231 +++++++ packages/three/src/react/scene-overlay.tsx | 77 +++ .../three/src/react/section-view-controls.tsx | 648 ++++++++++++++++++ packages/three/src/react/section-view.tsx | 279 ++++++++ .../src/react/transform-controls-drei.tsx | 201 ++++++ 9 files changed, 1972 insertions(+) create mode 100644 packages/three/src/react/axes-helper.tsx create mode 100644 packages/three/src/react/gltf-mesh.tsx create mode 100644 packages/three/src/react/index.ts create mode 100644 packages/three/src/react/infinite-grid.tsx create mode 100644 packages/three/src/react/lights.tsx create mode 100644 packages/three/src/react/scene-overlay.tsx create mode 100644 packages/three/src/react/section-view-controls.tsx create mode 100644 packages/three/src/react/section-view.tsx create mode 100644 packages/three/src/react/transform-controls-drei.tsx diff --git a/packages/three/src/react/axes-helper.tsx b/packages/three/src/react/axes-helper.tsx new file mode 100644 index 000000000..d1486a2e7 --- /dev/null +++ b/packages/three/src/react/axes-helper.tsx @@ -0,0 +1,109 @@ +import * as THREE from 'three'; +import { Line } from '@react-three/drei'; +import React, { Fragment } from 'react'; + +type CustomAxesHelperProps = { + /** + * The size of the axes + * @default 5000 + */ + readonly size?: number; + /** + * The color of the X axis + * @default 'red' + */ + readonly xAxisColor?: string; + /** + * The color of the Y axis + * @default 'green' + */ + readonly yAxisColor?: string; + /** + * The color of the Z axis + * @default 'blue' + */ + readonly zAxisColor?: string; + /** + * The thickness of the axes + * @default 5 + */ + readonly thickness?: number; + /** + * The thickness of the axes when hovered + * @default 2 + */ + readonly hoverThickness?: number; +}; + +export function AxesHelper({ + size = 50_000, + xAxisColor = 'rgb(125, 56, 50)', + yAxisColor = 'rgb(64, 115, 63)', + zAxisColor = 'rgb(37, 78, 136)', + thickness = 1.25, + hoverThickness = 2, +}: CustomAxesHelperProps): React.JSX.Element { + const [hoveredAxis, setHoveredAxis] = React.useState<'x' | 'y' | 'z' | undefined>(undefined); + + // Static axis definitions - only recreated when size or colors change, NOT on hover + const axes = React.useMemo( + () => [ + { + id: 'x' as const, + origin: new THREE.Vector3(0, 0, 0), + negativeEnd: new THREE.Vector3(-size, 0, 0), + positiveEnd: new THREE.Vector3(size, 0, 0), + color: xAxisColor, + }, + { + id: 'y' as const, + origin: new THREE.Vector3(0, 0, 0), + negativeEnd: new THREE.Vector3(0, -size, 0), + positiveEnd: new THREE.Vector3(0, size, 0), + color: yAxisColor, + }, + { + id: 'z' as const, + origin: new THREE.Vector3(0, 0, 0), + negativeEnd: new THREE.Vector3(0, 0, -size), + positiveEnd: new THREE.Vector3(0, 0, size), + color: zAxisColor, + }, + ], + [size, xAxisColor, yAxisColor, zAxisColor], + ); + + return ( + + {axes.map((axis) => { + const isHovered = hoveredAxis === axis.id; + const points = [isHovered ? axis.negativeEnd : axis.origin, axis.positiveEnd]; + return ( + + + { + setHoveredAxis(axis.id); + }} + onPointerOut={() => { + setHoveredAxis(undefined); + }} + /> + + ); + })} + + ); +} diff --git a/packages/three/src/react/gltf-mesh.tsx b/packages/three/src/react/gltf-mesh.tsx new file mode 100644 index 000000000..a1a7256ed --- /dev/null +++ b/packages/three/src/react/gltf-mesh.tsx @@ -0,0 +1,335 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { GLTFLoader, LineSegments2 } from 'three/addons'; +import type { GLTF } from 'three/addons/loaders/GLTFLoader.js'; +import type { Group, Object3D, Material, BufferGeometry, Mesh, Texture } from 'three'; +import { Vector2 } from 'three'; +import { useThree } from '@react-three/fiber'; +import { applyMatcap } from '#materials/gltf-matcap.js'; +import { + applyFatLineSegments, + updateLineMaterialResolution, +} from '#materials/gltf-edges.js'; + +// Module-scoped GLTFLoader instance. GLTFLoader is stateless and fully reusable, +// so creating a fresh instance per parse wastes initialization overhead and GC pressure. +const gltfLoader = new GLTFLoader(); + +/** + * Dispose a material and all its texture properties. + */ +function disposeMaterialWithTextures(mat: Material): void { + for (const value of Object.values(mat)) { + if (value && typeof value === 'object' && 'isTexture' in value) { + (value as Texture).dispose(); + } + } + + mat.dispose(); +} + +/** + * Recursively dispose all GPU resources (geometries, materials, textures) in a scene graph. + * This prevents GPU memory leaks when replacing or unmounting GLTF scenes. + */ +function disposeSceneResources(object: Object3D): void { + object.traverse((child) => { + // Dispose geometry + if ('geometry' in child) { + const { geometry } = child as { geometry?: BufferGeometry }; + geometry?.dispose(); + } + + // Dispose material(s) and their textures + if ('material' in child) { + const { material } = child as { material?: Material | Material[] }; + if (material) { + const materials = Array.isArray(material) ? material : [material]; + for (const mat of materials) { + disposeMaterialWithTextures(mat); + } + } + } + }); +} + +/** + * Clone and save all mesh materials from a scene so they can be restored + * after destructive operations like matcap application. + */ +function saveOriginalMaterials(scene: Group): Map { + const saved = new Map(); + scene.traverse((child) => { + if ('isMesh' in child && child.isMesh && !(child instanceof LineSegments2)) { + const mesh = child as Mesh; + if (Array.isArray(mesh.material)) { + saved.set( + mesh.id, + mesh.material.map((m) => m.clone()), + ); + } else { + saved.set(mesh.id, mesh.material.clone()); + } + } + }); + return saved; +} + +/** + * Restore saved original materials onto a scene. + * Disposes any current materials that differ from the originals (e.g. matcap materials). + */ +function restoreOriginalMaterials(scene: Group, saved: Map): void { + scene.traverse((child) => { + if ('isMesh' in child && child.isMesh && !(child instanceof LineSegments2)) { + const mesh = child as Mesh; + const original = saved.get(mesh.id); + if (!original) { + return; + } + + // Dispose current material if it was replaced (e.g. matcap) + if (mesh.material !== original) { + const currentMats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + for (const mat of currentMats) { + disposeMaterialWithTextures(mat); + } + } + + // Assign saved clones directly (they are pristine copies never used as active materials). + // Re-clone the saved copies so the stored originals remain untouched for future restores. + if (Array.isArray(original)) { + mesh.material = original; + saved.set( + mesh.id, + original.map((m) => m.clone()), + ); + } else { + mesh.material = original; + saved.set(mesh.id, original.clone()); + } + } + }); +} + +/** + * Dispose saved material clones stored in the originals map. + */ +function disposeSavedMaterials(saved: Map): void { + for (const mat of saved.values()) { + if (Array.isArray(mat)) { + for (const m of mat) { + disposeMaterialWithTextures(m); + } + } else { + disposeMaterialWithTextures(mat); + } + } + + saved.clear(); +} + +type GltfMeshDisplayProperties = { + /** + * The GLTF file to load. + */ + readonly gltfFile: Uint8Array; + /** + * Whether to enable matcap material. + */ + readonly enableMatcap: boolean; + /** + * Whether to enable surfaces. + */ + readonly enableSurfaces?: boolean; + /** + * Whether to enable lines. + */ + readonly enableLines?: boolean; +}; + +/** + * Update visibility of surfaces and lines based on object type. + * + * Uses Three.js object type for identification: + * - Mesh objects (including subclasses like SkinnedMesh, InstancedMesh) are surfaces + * - LineSegments and LineSegments2 objects are edges + * + * @param scene - The GLTF scene + * @param enableSurfaces - Whether to show surfaces + * @param enableLines - Whether to show lines + */ +function updateVisibility(scene: Group, enableSurfaces: boolean, enableLines: boolean): void { + scene.traverse((object) => { + // Check line types first (LineSegments2 has custom type) + if (object.type === 'LineSegments' || object instanceof LineSegments2) { + object.visible = enableLines; + } else if ('isMesh' in object && object.isMesh) { + // `isMesh` is true for Mesh, SkinnedMesh, InstancedMesh, etc. + object.visible = enableSurfaces; + } + }); +} + +/** + * This component renders a GLTF mesh. + * + * Rather than using Drei's `Gltf` component, this component is optimized for performance + * and caters to the needs of a CAD application. + * + * It does the following: + * - Supports toggling visibility of surfaces and lines via object type + * - Supports matcap material (applied to all Mesh objects) + * - Converts LineSegments to LineSegments2 for fat line rendering with constant screen-space width + * - Edges are rendered as LineSegments from the GLTF (processed by edge detection middleware) + * - Detects and prioritizes vertex colors over material colors + * - When vertex colors (COLOR_0 attribute) are present: uses vertex colors exclusively + * - When no vertex colors are present: falls back to material colors and opacity + * + * @param gltfFile - The GLTF file to load + * @param enableMatcap - Whether to enable matcap material + * @param enableSurfaces - Whether to enable surfaces + * @param enableLines - Whether to enable lines + * @returns A React component with Three.js primitives that renders the GLTF mesh + */ +export function GltfMesh({ + gltfFile, + enableMatcap = false, + enableSurfaces = true, + enableLines = true, +}: GltfMeshDisplayProperties): React.JSX.Element | undefined { + // The "base scene" is the parsed GLTF with line segments converted but no material overrides. + // It serves as the template from which material modes (matcap/original) are derived. + const [baseScene, setBaseScene] = useState(undefined); + // The rendered scene has material mode applied and is what displays. + const [scene, setScene] = useState(undefined); + const { size, invalidate } = useThree(); + + // Memoize resolution vector to avoid creating new objects on each render + const resolutionRef = useRef(new Vector2(size.width, size.height)); + + // Saved clones of the original materials so we can restore them after matcap is toggled off. + const originalMaterialsRef = useRef>(new Map()); + + // Update resolution when size changes. Deferred via requestAnimationFrame + // so that rapid resize events (e.g. dragging a Dockview divider) batch into + // a single scene traversal + invalidation per animation frame. + useEffect(() => { + resolutionRef.current.set(size.width, size.height); + + if (!scene) { + return; + } + + const frameId = requestAnimationFrame(() => { + updateLineMaterialResolution(scene, resolutionRef.current); + invalidate(); + }); + + return () => { + cancelAnimationFrame(frameId); + }; + }, [size, scene, invalidate]); + + // ── Effect 1: Parse GLTF binary (expensive, only on gltfFile change) ────── + // Parses the GLTF, converts line segments, and saves original materials. + // Does not apply matcap or any material overrides -- that is handled by Effect 2. + useEffect(() => { + let cancelled = false; + + const loadGltf = async (): Promise => { + try { + if (typeof SharedArrayBuffer === 'function' && gltfFile.buffer instanceof SharedArrayBuffer) { + throw new TypeError('SharedArrayBuffer is not supported in '); + } + + const gltf = await gltfLoader.parseAsync( + gltfFile.buffer, + '', // Path (not needed for ArrayBuffer) + ); + + if (cancelled) { + disposeSceneResources(gltf.scene); + return; + } + + // Convert LineSegments to LineSegments2 for fat line rendering + applyFatLineSegments(gltf, resolutionRef.current); + + // Save clones of the original materials before any overrides + disposeSavedMaterials(originalMaterialsRef.current); + originalMaterialsRef.current = saveOriginalMaterials(gltf.scene); + + setBaseScene(gltf.scene); + invalidate(); + } catch (error) { + if (!cancelled) { + console.error('Failed to load GLTF:', error); + } + } + }; + + // Dispose previous base scene and saved materials before loading new one + setBaseScene((previous) => { + if (previous) { + disposeSceneResources(previous); + } + + return undefined; + }); + setScene(undefined); + + void loadGltf(); + + return () => { + cancelled = true; + }; + }, [gltfFile, invalidate]); + + // Cleanup on unmount: dispose base scene and saved materials + useEffect( + () => () => { + disposeSavedMaterials(originalMaterialsRef.current); + }, + [], + ); + + // Effect 2: Apply materials (lightweight, runs on matcap toggle or new base scene). + // Applies matcap or restores original materials on the base scene. + // When enableMatcap changes, only this effect runs (no GLTF re-parse). + // Visibility is NOT handled here -- it is handled by the dedicated visibility effect + // below to avoid expensive material re-application on visibility toggles. + const applyMaterials = useCallback( + (targetScene: Group): void => { + if (enableMatcap) { + void applyMatcap({ scene: targetScene } as GLTF); + } else { + restoreOriginalMaterials(targetScene, originalMaterialsRef.current); + } + }, + [enableMatcap], + ); + + useEffect(() => { + if (!baseScene) { + return; + } + + applyMaterials(baseScene); + setScene(baseScene); + invalidate(); + }, [baseScene, applyMaterials, invalidate]); + + // Toggle visibility when enableSurfaces or enableLines change + useEffect(() => { + if (scene) { + updateVisibility(scene, enableSurfaces, enableLines); + invalidate(); + } + }, [scene, enableSurfaces, enableLines, invalidate]); + + if (!scene) { + return undefined; + } + + return ; +} diff --git a/packages/three/src/react/index.ts b/packages/three/src/react/index.ts new file mode 100644 index 000000000..dbf778b42 --- /dev/null +++ b/packages/three/src/react/index.ts @@ -0,0 +1,16 @@ +// @taucad/three/react - React components, hooks, and stores +export { AxesHelper } from './axes-helper.js'; +export { GltfMesh } from './gltf-mesh.js'; +export { InfiniteGrid } from './infinite-grid.js'; +export { Lights } from './lights.js'; +export { SceneOverlay } from './scene-overlay.js'; +export { SectionView } from './section-view.js'; +export type { CutterProperties } from './section-view.js'; +export { + SectionViewControls, + type AvailablePlane, + type PlaneId, + type PlaneSelectorId, + type UpDirection, +} from './section-view-controls.js'; +export { TransformControls, type TransformControlsProps } from './transform-controls-drei.js'; diff --git a/packages/three/src/react/infinite-grid.tsx b/packages/three/src/react/infinite-grid.tsx new file mode 100644 index 000000000..383856971 --- /dev/null +++ b/packages/three/src/react/infinite-grid.tsx @@ -0,0 +1,76 @@ +import { Plane } from '@react-three/drei'; +import React from 'react'; +import { infiniteGridMaterial } from '#materials/infinite-grid-material.js'; +import type { InfiniteGridMaterialProperties } from '#materials/infinite-grid-material.js'; + +type InfiniteGridProperties = { + /** + * The properties for the infinite grid material. + */ + readonly materialProperties?: InfiniteGridMaterialProperties; + /** + * The properties for the infinite grid plane. + */ + readonly planeProperties?: React.ComponentProps; + /** + * The axes to use for the grid orientation. + * - 'xyz': Grid on XY plane (Z-up coordinate system, CAD/engineering) + * - 'xzy': Grid on XZ plane (Y-up coordinate system, standard Three.js) + * - 'zyx': Grid on ZY plane (X-up coordinate system) + */ + readonly axes: 'xyz' | 'xzy' | 'zyx'; +}; + +/** + * An infinite grid component that renders a ground plane grid. + * The grid extends infinitely in all directions and scales dynamically + * based on camera distance for optimal visibility. + * + * ### Features: + * - **Infinite extent**: Grid extends as far as needed based on camera position + * - **Dynamic scaling**: Grid size adjusts to camera distance for consistent visibility + * - **Dual grid system**: Small and large grid lines with independent sizing and thickness + * - **Distance-based fading**: Grid fades out at edges to prevent visual artifacts + * - **Customizable appearance**: Configurable colors, opacity, and thickness + * - **Performance optimized**: Uses efficient shader-based rendering + * + * ### Grid Orientation: + * The grid orientation is controlled by the `axes` prop: + * - 'xyz': Grid on XY plane (Z-up coordinate system, CAD/engineering) + * - 'xzy': Grid on XZ plane (Y-up coordinate system, standard Three.js) + * - 'zyx': Grid on ZY plane (X-up coordinate system) + * + * ### Usage: + * ```tsx + * + * ``` + * + * @param properties - The properties for the infinite grid. + */ +export function InfiniteGrid(properties: InfiniteGridProperties): React.JSX.Element { + const { materialProperties = {}, planeProperties = {}, axes } = properties; + + // Create material with provided axes + const material = React.useMemo( + () => infiniteGridMaterial({ ...materialProperties, axes }), + [axes, materialProperties], + ); + + return ( + + ); +} diff --git a/packages/three/src/react/lights.tsx b/packages/three/src/react/lights.tsx new file mode 100644 index 000000000..4e1029375 --- /dev/null +++ b/packages/three/src/react/lights.tsx @@ -0,0 +1,231 @@ +import { useRef } from 'react'; +import type * as THREE from 'three'; +import { useThree, useFrame } from '@react-three/fiber'; +import { Environment, Lightformer } from '@react-three/drei'; +import { + applyLightingForCamera, + ambientBaseIntensity, + headlampBaseIntensity, + environmentBaseIntensity, + defaultHeadlampConfig, + lightingUserDataKeys, +} from '#utils/lights.utils.js'; +import type { SceneLightingConfig } from '#utils/lights.utils.js'; + +/** Environment cubemap resolution (px). Higher = sharper specular reflections. */ +const envResolution = 512; + +// Studio preset Lightformer intensities ────────────────────────────────────── +// Asymmetric camera-space rig matching Onshape's observed pattern. +// Key upper-left, fill right, top overhead, ground below, back-fill behind. + +/** Key panel (right-upper in camera space) -- brightest light, creates NE-bright gradient. */ +const studioKeyIntensity = 4; +/** Left-upper fill (left-upper in camera space) -- illuminates left-facing L sections (WNW/NW-left). */ +const studioLeftFillIntensity = 1.2; +/** Top panel (overhead in camera space) -- subtle overhead accent on sloped surfaces. */ +const studioTopIntensity = 0.25; +/** Ground panel (below in camera space) -- bright for bottom-view luminosity. */ +const studioGroundIntensity = 1.5; +/** Specular highlight panel (upper-right for bottom face) -- creates focused off-center specular on flat faces. */ +const studioBackFillIntensity = 8; + +// Neutral preset Lightformer intensities ───────────────────────────────────── +const neutralKeyIntensity = 0.6; +const neutralGroundIntensity = 0.2; + +type UpDirection = 'x' | 'y' | 'z'; + +type LightsProperties = { + readonly enableMatcap?: boolean; + readonly sceneRadius?: number; + readonly environmentPreset?: 'studio' | 'neutral' | 'soft' | 'performance'; + readonly upDirection?: UpDirection; +}; + +/** + * Professional CAD lighting setup matching Onshape's rendering style. + * + * Design principles: + * 1. **Azimuth-locked environment** — `scene.environmentRotation` is driven from + * only the azimuthal (yaw) component of the inverse camera quaternion each + * frame, so Lightformers stay stable during horizontal orbit but shift + * naturally when the camera tilts up/down, producing lighting variation. + * + * 2. **Asymmetric camera-space lightformers** — Key panel upper-left, fill right, + * top overhead, ground below, and back-fill behind camera. This matches Onshape's + * observed lighting pattern (upper-left brightest, lower-right darkest). + * + * 3. **FOV compensation** — As FOV decreases toward orthographic, specular highlights + * wash out (parallel view rays → uniform reflection). A multi-lever system scales + * down `scene.environmentIntensity` at low FOV while boosting headlamp and ambient + * to compensate diffuse loss. No material changes. + * + * 4. **Camera-space headlamp** — A subtle directional light offset in camera-up + * and camera-right directions so the highlight remains biased toward screen + * upper-right. + * + * 5. **Scale-adaptive** — All Lightformer positions and scales are expressed as + * multiples of `sceneRadius` so lighting adapts to model size. + */ +export function Lights({ + enableMatcap = false, + sceneRadius = 0, + environmentPreset = 'studio', + upDirection = 'z', +}: LightsProperties): React.JSX.Element { + const { camera, scene } = useThree(); + const cameraLightReference = useRef(null); + const ambientReference = useRef(null); + + // Clamp sceneRadius to avoid zero/tiny values before geometry loads + const clampedSceneRadius = Math.max(sceneRadius, 1); + + // Keep clamped radius accessible in useFrame without re-subscribing + const radiusRef = useRef(clampedSceneRadius); + radiusRef.current = clampedSceneRadius; + + // Per-frame updates delegated to the shared applyLightingForCamera utility. + // This ensures the live renderer and the offline screenshot renderer apply + // identical lighting for any camera orientation. + useFrame(() => { + // Persist lighting config on scene.userData so the screenshot capture + // system can read it from a cloned scene without prop-drilling. + scene.userData[lightingUserDataKeys.config] = { + sceneRadius: radiusRef.current, + upDirection, + } satisfies SceneLightingConfig; + + applyLightingForCamera({ + scene, + camera, + headlamp: cameraLightReference.current ?? undefined, + ambient: ambientReference.current ?? undefined, + config: { + sceneRadius: radiusRef.current, + upDirection, + headlampIntensity: headlampBaseIntensity, + ambientIntensity: ambientBaseIntensity, + environmentIntensity: environmentBaseIntensity, + headlampConfig: defaultHeadlampConfig, + }, + }); + }); + + const showEnvironment = !enableMatcap && (environmentPreset === 'studio' || environmentPreset === 'neutral'); + + return ( + <> + {/* Base ambient fill -- always present for minimum illumination */} + + + {/* Headlamp -- positioned above camera in world space for top-down gradients */} + + + {showEnvironment ? ( + + {environmentPreset === 'studio' ? ( + <> + {/* ── Key panel (right-upper in camera space) ── */} + {/* Brightest side light. Positioned primarily to the right of the + camera with moderate upward offset. Creates the NE-bright + gradient (NNE, ENE lit) while keeping NNW dark. */} + + {/* ── Left-upper fill (left-upper in camera space) ── */} + {/* Illuminates left-facing L sections (WNW = NW-left) that the + rightward key cannot reach. Env_x dominant negative with moderate + +env_y so WNW (env_y=0.38) gets more than WSW (env_y=-0.38). */} + + {/* ── Top panel (overhead in camera space) ── */} + {/* Reduced overhead accent — kept low to avoid over-brightening + NNW (D section) which has high env_y normal component. */} + + {/* ── Ground panel (below-right in camera space) ── */} + {/* Bright ground for bottom-view luminosity. Offset in +X so that + the bottom-face specular shifts toward the right (matching the + asymmetric rig's "brighter on right" pattern). */} + + {/* ── Specular highlight panel (upper-right in camera space) ── */} + {/* Positioned in the (+X, -Y, +Z) octant to create a focused specular + highlight in the upper-right area of bottom-facing surfaces when + viewed from below. In Z-up screen coords for the bottom face: + +X → screen right, -Y → screen top, +Z → close to the reflection + pole. Equal X and -Y offsets place the specular at 45° toward the + top-right corner. Negligible contribution to front/side face + speculars (~61° from front reflection direction). */} + + + ) : ( + <> + {/* Neutral preset: reduced intensity, minimal reflections */} + + + + )} + + ) : null} + + {/* Soft preset: hemisphere + ambient only, no environment map */} + {!enableMatcap && environmentPreset === 'soft' ? : null} + + {/* Performance preset: minimal lights, no environment (equivalent to legacy setup) */} + {!enableMatcap && environmentPreset === 'performance' ? ( + <> + + + + + ) : null} + + ); +} diff --git a/packages/three/src/react/scene-overlay.tsx b/packages/three/src/react/scene-overlay.tsx new file mode 100644 index 000000000..ca6bd44c0 --- /dev/null +++ b/packages/three/src/react/scene-overlay.tsx @@ -0,0 +1,77 @@ +import type { ReactNode } from 'react'; +import { useMemo } from 'react'; +import * as THREE from 'three'; +import { createPortal, useFrame } from '@react-three/fiber'; + +type SceneOverlayProperties = { + readonly children: ReactNode; +}; + +/** + * Renders children in a separate THREE.Scene that composites on top of the + * post-processed output. This keeps overlay elements (Grid, AxesHelper) + * outside the EffectComposer pipeline so they are not affected by N8AO + * ambient-occlusion darkening. + * + * Runs at `renderPriority = 2` (after EffectComposer at priority 1). + * + * ### Automatic adaptation + * + * The component auto-detects whether an EffectComposer (or any other + * positive-priority render owner) is active by reading R3F's internal + * subscriber count (`state.internal.priority`). + * + * - **Post-processing active** (`priority > 1`): the EffectComposer already + * wrote colour to the screen but clobbered the depth buffer with its + * fullscreen-quad output. We restore scene depth via a depth-only + * re-render (`colorMask(false …)`) before compositing the overlay. + * + * - **No post-processing** (`priority === 1`, i.e. we are the sole render + * owner): we render the full scene ourselves (colour + depth), then + * composite the overlay on top. This means the EffectComposer can be + * freely added or removed without any prop changes to SceneOverlay. + */ +export function SceneOverlay({ children }: SceneOverlayProperties): React.JSX.Element { + const overlayScene = useMemo(() => new THREE.Scene(), []); + + // Lightweight material for the depth-only pass. MeshBasicMaterial has built-in + // logarithmic depth support and skips all PBR/matcap/environment shader work. + const depthOnlyMaterial = useMemo(() => { + const mat = new THREE.MeshBasicMaterial(); + mat.colorWrite = false; + return mat; + }, []); + + useFrame((state) => { + // Skip entirely when the overlay scene has no children to render + if (overlayScene.children.length === 0) { + return; + } + + const { gl, scene, camera } = state; + const previousAutoClear = gl.autoClear; + gl.autoClear = false; + + if (state.internal.priority > 1) { + // Another render-owner (EffectComposer) already wrote colour. + // Restore scene depth only with a lightweight override material, + // preserving the post-processed image while avoiding full material shaders. + const previousOverrideMaterial = scene.overrideMaterial; + scene.overrideMaterial = depthOnlyMaterial; + gl.render(scene, camera); + scene.overrideMaterial = previousOverrideMaterial; + } else { + // We are the sole render-owner. Render the full scene ourselves. + gl.autoClear = true; + gl.render(scene, camera); + gl.autoClear = false; + } + + // Overlay pass: grid / axes depth-test correctly against the model. + gl.render(overlayScene, camera); + + gl.autoClear = previousAutoClear; + }, 2); // Priority 2: runs after EffectComposer (priority 1) + + return <>{createPortal(children, overlayScene)}; +} diff --git a/packages/three/src/react/section-view-controls.tsx b/packages/three/src/react/section-view-controls.tsx new file mode 100644 index 000000000..d7d83b588 --- /dev/null +++ b/packages/three/src/react/section-view-controls.tsx @@ -0,0 +1,648 @@ +import React, { useRef, useMemo } from 'react'; +import type { ThreeEvent } from '@react-three/fiber'; +import * as THREE from 'three'; +import { useFrame, useThree } from '@react-three/fiber'; +import { TransformControls } from './transform-controls-drei.js'; +import { pixelsToWorldUnits } from '#utils/spatial.utils.js'; +import { matcapMaterial } from '#materials/matcap-material.js'; +import { FontGeometry } from '#geometries/font-geometry.js'; +import { RoundedRectangleGeometry } from '#geometries/rounded-rectangle-geometry.js'; +import { adjustHexColorBrightness } from '#utils/color.utils.js'; + +// Module-scope scratch vectors for PlaneSelector useFrame (avoids per-frame allocations) +const _normalizedDir = new THREE.Vector3(); +const _baseOffset = new THREE.Vector3(); +const _depthOffset = new THREE.Vector3(); + +export type PlaneId = 'xy' | 'xz' | 'yz'; +export type PlaneSelectorId = 'xy' | 'xz' | 'yz' | 'yx' | 'zx' | 'zy'; +export type UpDirection = 'x' | 'y' | 'z'; + +/** + * Get the plane position for a given plane ID and up direction. + * The position determines where the plane selector is placed in 3D space. + * + * In each coordinate system, we want the "horizontal" plane (Top/Bottom) to be + * positioned along the up axis, and the vertical planes to be positioned along + * the other two axes. + */ +function getPlanePositionForUpDirection(planeId: PlaneId, upDirection: UpDirection): [number, number, number] { + // Z-up (CAD/engineering default): XY is horizontal, XZ/YZ are vertical + if (upDirection === 'z') { + if (planeId === 'xy') { + return [0, 0, -1]; + } + + if (planeId === 'xz') { + return [0, 1, 0]; + } + + // Yz + return [-1, 0, 0]; + } + + // Y-up (standard Three.js): XZ is horizontal, XY/YZ are vertical + if (upDirection === 'y') { + if (planeId === 'xy') { + return [0, 0, -1]; + } + + if (planeId === 'xz') { + return [0, -1, 0]; + } + + // Yz + return [-1, 0, 0]; + } + + // X-up: YZ is horizontal, XY/XZ are vertical + if (planeId === 'xy') { + return [0, 0, -1]; + } + + if (planeId === 'xz') { + return [0, -1, 0]; + } + + // YZ - positioned at bottom (-X direction, like Y-up has XZ at -Y) + return [-1, 0, 0]; +} + +/** + * Get the plane rotation for a given plane ID and up direction. + * The rotation orients the plane selector to face the correct direction. + */ +function getPlaneRotationForUpDirection(planeId: PlaneId, upDirection: UpDirection): [number, number, number] { + // Z-up (CAD/engineering default) + if (upDirection === 'z') { + if (planeId === 'xy') { + return [0, 0, 0]; + } + + if (planeId === 'xz') { + return [-Math.PI / 2, 0, Math.PI]; + } + + // Yz + return [Math.PI / 2, Math.PI / 2, 0]; + } + + // Y-up (standard Three.js) + if (upDirection === 'y') { + if (planeId === 'xy') { + // XY plane at [0,0,-1] faces +Z, needs to face the camera with upright text + return [0, 0, 0]; + } + + if (planeId === 'xz') { + // XZ plane at [0,-1,0] - horizontal floor plane + // Rotate 90° around X to make it horizontal, facing +Y (up) + // Add 180° around Z to flip text orientation without mirroring + return [Math.PI / 2, 0, Math.PI]; + } + + // YZ plane at [-1,0,0] - vertical side plane + // Rotate to face +X direction + return [0, Math.PI / 2, 0]; + } + + // X-up + if (planeId === 'xy') { + // XY plane at [0,0,-1] faces +Z + return [0, 0, -Math.PI / 2]; + } + + if (planeId === 'xz') { + // XZ plane at [0,-1,0] - vertical side plane + // Rotate -90° around X to face +Y, add 180° Z for upright text + return [-Math.PI / 2, Math.PI, -Math.PI / 2]; + } + + // YZ plane at [-1,0,0] - horizontal floor plane in X-up + // Rotate 90° around Y to make it horizontal, facing +X (up) + // Add 90° around Z for upright text when viewed from above + return [0, Math.PI / 2, Math.PI]; +} + +/** + * Get the labels for a plane selector based on the up direction. + * The semantic meaning of "Top/Bottom/Front/Back/Left/Right" changes depending + * on which axis is "up" and the position of the selector in 3D space. + * + * The label mapping must match the physical position of the selector: + * - The selector at the "up" position should show "Top" + * - The selector at the "down" position should show "Bottom" + * - etc. + */ +function getLabelsForUpDirection( + id: PlaneSelectorId, + naming: 'cartesian' | 'face', + upDirection: UpDirection, +): [string, string] { + if (naming === 'cartesian') { + const label = id.toUpperCase(); + return [label, label]; + } + + const base = getBaseFromSelector(id); + // IsInverse is true for 'yx', 'zx', 'zy' (the reversed versions) + const isInverse = id !== base; + + if (upDirection === 'z') { + // Z-up: XY → Top/Bottom, XZ → Front/Back, YZ → Left/Right + // XY at [0,0,-1]: faces +Z (up), so 'xy' shows "Top" + // XZ at [0,1,0]: faces -Y, so 'xz' shows "Back" + // YZ at [-1,0,0]: faces +X, so 'yz' shows "Right" + if (base === 'xy') { + return isInverse ? ['Bottom', 'Top'] : ['Top', 'Bottom']; + } + + if (base === 'xz') { + return isInverse ? ['Front', 'Back'] : ['Back', 'Front']; + } + + // Yz + return isInverse ? ['Left', 'Right'] : ['Right', 'Left']; + } + + if (upDirection === 'y') { + // Y-up: XZ → Top/Bottom, XY → Front/Back, YZ → Left/Right + // XZ at [0,-1,0]: The selector is below the model, the visible face (facing +Y) should show "Top" + // But the 'xz' ID with isInverse=true render prop uses base rotation, making it face +Y + // So 'zx' (which faces -Y, away from viewer looking down) should show "Top" + // and 'xz' (facing +Y toward viewer) should show "Bottom" + if (base === 'xz') { + return isInverse ? ['Top', 'Bottom'] : ['Bottom', 'Top']; + } + + // XY at [0,0,-1]: Front/Back need to be on opposite faces + if (base === 'xy') { + return isInverse ? ['Back', 'Front'] : ['Front', 'Back']; + } + + // Yz - Right/Left remains the same + return isInverse ? ['Left', 'Right'] : ['Right', 'Left']; + } + + // X-up: YZ → Top/Bottom, XY → Front/Back, XZ → Left/Right + // YZ at [-1,0,0]: swap so 'yz' shows "Top", 'zy' shows "Bottom" + if (base === 'yz') { + return isInverse ? ['Bottom', 'Top'] : ['Top', 'Bottom']; + } + + // XY at [0,0,-1]: 'xy' faces +Z → shows "Front", 'yx' faces -Z → shows "Back" + if (base === 'xy') { + return isInverse ? ['Back', 'Front'] : ['Front', 'Back']; + } + + // XZ at [0,-1,0]: swap so 'xz' shows "Left", 'zx' shows "Right" + return isInverse ? ['Right', 'Left'] : ['Left', 'Right']; +} + +function getBaseFromSelector(id: PlaneSelectorId): PlaneId { + if (id === 'xy' || id === 'yx') { + return 'xy'; + } + + if (id === 'xz' || id === 'zx') { + return 'xz'; + } + + return 'yz'; +} + +type PlaneSelectorProperties = { + readonly planeId: PlaneSelectorId; + readonly position: [number, number, number]; + readonly color: string; + readonly onClick: (planeId: PlaneSelectorId) => void; + readonly onHover: (planeId: PlaneSelectorId | undefined) => void; + readonly matcapTexture: THREE.Texture; + readonly size: number; + readonly offset: number; + readonly naming: 'cartesian' | 'face'; + readonly isExternallyHovered?: boolean; + readonly textDepth: number; + readonly labelDepth: number; + readonly isInverse?: boolean; + readonly upDirection: UpDirection; +}; + +function PlaneSelector({ + planeId, + position, + color, + onClick, + onHover, + matcapTexture, + size, + offset, + naming, + isExternallyHovered, + textDepth, + labelDepth, + isInverse = false, + upDirection, +}: PlaneSelectorProperties): React.JSX.Element { + const { gl, camera, size: threeSize, viewport } = useThree(); + const [isHovered, setIsHovered] = React.useState(false); + const groupRef = useRef(null); + const baseDirection = useMemo( + () => new THREE.Vector3(position[0], position[1], position[2]), + [position], + ); + const origin = useMemo(() => new THREE.Vector3(0, 0, 0), []); + + // Keep the selector a constant screen size and screen offset by updating each frame + useFrame(() => { + const currentGroup = groupRef.current; + if (!currentGroup) { + return; + } + + const desiredWorldSize = pixelsToWorldUnits({ + viewport, + camera, + size: threeSize, + at: origin, + pixels: size, + }); + const desiredWorldOffset = pixelsToWorldUnits({ + viewport, + camera, + size: threeSize, + at: origin, + pixels: offset, + }); + + // Base geometry is 1x1, so scale directly to desired world size + const scale = desiredWorldSize; + currentGroup.scale.set(scale, scale, scale); + + if (baseDirection.lengthSq() > 0) { + _normalizedDir.copy(baseDirection).normalize(); + _baseOffset.copy(_normalizedDir).multiplyScalar(desiredWorldOffset); + + // For inverse faces, add an additional offset to account for label depth + // so they're truly back-to-back without overlapping + if (isInverse) { + _depthOffset.copy(_normalizedDir).multiplyScalar(-labelDepth * scale); + } else { + _depthOffset.set(0, 0, 0); + } + + if (planeId === 'xz' || planeId === 'zx') { + currentGroup.position.copy(_baseOffset.sub(_depthOffset)); + } else { + currentGroup.position.copy(_baseOffset.add(_depthOffset)); + } + } + }); + + const handleClick = (event: ThreeEvent): void => { + event.stopPropagation(); + onClick(planeId); + }; + + const handlePointerOver = (event: ThreeEvent): void => { + event.stopPropagation(); + setIsHovered(true); + gl.domElement.style.cursor = 'pointer'; + onHover(planeId); + }; + + const handlePointerOut = (event: ThreeEvent): void => { + event.stopPropagation(); + setIsHovered(false); + gl.domElement.style.cursor = 'auto'; + onHover(undefined); + }; + + const [forwardPlaneName] = getLabelsForUpDirection(planeId, naming, upDirection); + + const frontFontGeometry = useMemo( + // eslint-disable-next-line new-cap -- Three.js naming convention + () => FontGeometry({ text: forwardPlaneName, depth: textDepth, size: 0.2 }), + [forwardPlaneName, textDepth], + ); + const roundedRectangleGeometry = useMemo( + // eslint-disable-next-line new-cap -- Three.js naming convention + () => RoundedRectangleGeometry({ width: 1, height: 1, radius: 0.1, smoothness: 16, depth: labelDepth }), + [labelDepth], + ); + const darkenedColor = useMemo(() => adjustHexColorBrightness(color, -0.5), [color]); + const slightlyDarkenedColor = useMemo(() => adjustHexColorBrightness(color, -0.3), [color]); + + const baseRotation = getPlaneRotationForUpDirection(getBaseFromSelector(planeId), upDirection); + // For inverse faces, rotate 180 degrees to face the opposite direction + // The rotation axis depends on the up direction to maintain upright text + const rotation = isInverse + ? baseRotation + : ((): [number, number, number] => { + const base = getBaseFromSelector(planeId); + + // X-up needs different inverse rotations due to different base rotations + if (upDirection === 'x') { + if (base === 'xy') { + // Front→Back: flip around X to keep text upright + return [baseRotation[0] + Math.PI, baseRotation[1], baseRotation[2]]; + } + + if (base === 'xz') { + // Right→Left: flip around X + return [baseRotation[0] + Math.PI, baseRotation[1], baseRotation[2]]; + } + + // YZ (Top→Bottom): flip around Y + return [baseRotation[0], baseRotation[1] + Math.PI, baseRotation[2]]; + } + + // Z-up and Y-up use 180° Y rotation + if (base === 'xy') { + return [baseRotation[0], baseRotation[1] + Math.PI, baseRotation[2]]; + } + + if (base === 'xz') { + return [baseRotation[0], baseRotation[1] + Math.PI, baseRotation[2]]; + } + + // Base === 'yz' + return [baseRotation[0], baseRotation[1] + Math.PI, baseRotation[2]]; + })(); + const displayedHover = isHovered || Boolean(isExternallyHovered); + const actualColor = displayedHover ? darkenedColor : slightlyDarkenedColor; + + return ( + + + + + + + + + + + ); +} + +export type AvailablePlane = { id: PlaneId; normal: [number, number, number]; constant: number }; + +type SectionViewControlsProperties = { + readonly isActive: boolean; + readonly selectedPlaneId: PlaneId | undefined; + readonly availablePlanes: AvailablePlane[]; + readonly pivot?: [number, number, number]; + readonly rotation: [number, number, number]; + readonly planeName: 'cartesian' | 'face'; + readonly hoveredSectionViewId: PlaneSelectorId | undefined; + readonly upDirection: UpDirection; + readonly onSelectPlane: (planeId: PlaneSelectorId) => void; + readonly onHover: (planeId: PlaneSelectorId | undefined) => void; + readonly onSetRotation: (rotation: THREE.Euler) => void; + readonly onSetPivot?: (value: [number, number, number]) => void; +}; + +export function SectionViewControls({ + isActive, + selectedPlaneId, + availablePlanes, + pivot, + rotation, + planeName, + hoveredSectionViewId, + upDirection, + onSelectPlane, + onHover, + onSetRotation, + onSetPivot, +}: SectionViewControlsProperties): React.JSX.Element | undefined { + const transformControlsRef = useRef(undefined); + // Track the latest rotation locally to project translation along the rotated plane normal + const rotationRef = useRef(new THREE.Euler(0, 0, 0)); + // Keep an optional world-space anchor so the gizmo doesn't "jump" after rotations + const anchorPositionRef = useRef(undefined); + const matcapTexture = useMemo(() => matcapMaterial(), []); + // Track whether the user is actively dragging translate/rotate so we don't override the position mid-drag + const isTranslatingRef = useRef(false); + const isRotatingRef = useRef(false); + // World-space pivot point to keep the plane anchored during rotation + const pivotPointRef = useRef(new THREE.Vector3()); + + const planes = React.useMemo(() => { + const planes: Array<{ idPos: PlaneSelectorId; idNeg: PlaneSelectorId; normal: THREE.Vector3; color: string }> = [ + { idPos: 'xy', idNeg: 'yx', normal: new THREE.Vector3(0, 0, -1), color: '#3b82f6' }, + { idPos: 'xz', idNeg: 'zx', normal: new THREE.Vector3(0, -1, 0), color: '#22c55e' }, + { idPos: 'yz', idNeg: 'zy', normal: new THREE.Vector3(-1, 0, 0), color: '#ef4444' }, + ]; + + return planes; + }, []); + + // Find the selected plane configuration + const selectedPlane = availablePlanes.find((plane) => plane.id === selectedPlaneId); + + // Calculate plane properties before any conditional returns + const [nx, ny, nz] = selectedPlane?.normal ?? [0, 0, 1]; + const normal = new THREE.Vector3(nx, ny, nz); + + // Single frame loop to keep rotation and position in sync. + // - When not dragging rotate: sync object rotation from props + // - When not dragging translate/rotate: position object at pivot + useFrame(() => { + const { current } = transformControlsRef; + if (!current || !selectedPlane) { + return; + } + + // Sync external rotation when not rotating + if (!isRotatingRef.current) { + rotationRef.current.set(rotation[0], rotation[1], rotation[2]); + current.rotation.set(rotation[0], rotation[1], rotation[2]); + } + + // While dragging, do not override transform-controls position + if (isRotatingRef.current || isTranslatingRef.current) { + return; + } + + // Keep anchor if set (post-drag/rotate) + if (anchorPositionRef.current) { + current.position.copy(anchorPositionRef.current); + return; + } + + // Controlled position from pivot + if (pivot) { + current.position.set(pivot[0], pivot[1], pivot[2]); + } + }); + + // Keep transform controls controlled by external state changes. + // When plane selection, translation, or rotation are changed via the UI/state + // (not by dragging), clear any anchor so the gizmo snaps to the computed + // position in the next frame. + React.useEffect(() => { + if (isTranslatingRef.current || isRotatingRef.current) { + return; + } + + anchorPositionRef.current = undefined; + }, [selectedPlaneId, rotation, pivot]); + + if (!isActive) { + return undefined; + } + + // If no plane is selected, show the 6 plane selectors (3 base + 3 inverse faces) + // Constants for depth calculations - extracted to allow precise back-to-back positioning + const textDepth = 0.01; + const labelDepth = 0.02; + const offsetPx = 40; + if (!selectedPlane) { + return ( + + {planes.map(({ idPos, idNeg, color }) => { + // Use coordinate-aware position based on up direction + const baseId = getBaseFromSelector(idPos); + const position = getPlanePositionForUpDirection(baseId, upDirection); + + return ( + + + + + ); + })} + + ); + } + + return ( + + {/* Hidden transform controls for dragging logic */} + + + + + + } + mode="translate" + space="local" + size={1} + visible={false} + showX={Math.abs(normal.x) > 0.5} + showY={Math.abs(normal.y) > 0.5} + showZ={Math.abs(normal.z) > 0.5} + onChange={() => { + if (!isTranslatingRef.current) { + return; + } + + const currentObject = transformControlsRef.current; + if (currentObject) { + const { position } = currentObject; + if (onSetPivot) { + onSetPivot([position.x, position.y, position.z]); + } + } + }} + onPointerDown={() => { + // Keep current anchor so the gizmo does not snap to the plane projection + isTranslatingRef.current = true; + }} + onPointerUp={() => { + isTranslatingRef.current = false; + // Persist the final world position as the new anchor to avoid any post-drag snapping + if (transformControlsRef.current) { + anchorPositionRef.current = transformControlsRef.current.position.clone(); + } + }} + /> + } + mode="rotate" + space="local" + size={1} + visible={false} + showX={Math.abs(normal.y) > 0.5 || Math.abs(normal.z) > 0.5} + showY={Math.abs(normal.x) > 0.5 || Math.abs(normal.z) > 0.5} + showZ={Math.abs(normal.x) > 0.5 || Math.abs(normal.y) > 0.5} + onChange={() => { + if (!isRotatingRef.current) { + return; + } + + const currentObject = transformControlsRef.current; + if (currentObject) { + // Extract the rotation from the object + const rotation = currentObject.rotation.clone(); + rotationRef.current.copy(rotation); + onSetRotation(rotation); + // Do not change translation here; machine derives display value from pivot + } + }} + onPointerDown={() => { + isRotatingRef.current = true; + if (transformControlsRef.current) { + // Capture current gizmo world position as the rotation pivot + pivotPointRef.current.copy(transformControlsRef.current.position); + // Set anchor so when rotation ends the gizmo stays where it was left + anchorPositionRef.current = pivotPointRef.current.clone(); + } + }} + onPointerUp={() => { + isRotatingRef.current = false; + // Keep anchor until the next manipulation (or translation drag) + }} + /> + + ); +} diff --git a/packages/three/src/react/section-view.tsx b/packages/three/src/react/section-view.tsx new file mode 100644 index 000000000..7cec7b437 --- /dev/null +++ b/packages/three/src/react/section-view.tsx @@ -0,0 +1,279 @@ +/** + * Credit to https://github.com/r3f-cutter/r3f-cutter for the original implementation. + * + * This has been modified to support conditional cutting of meshes and lines. + */ + +import * as React from 'react'; +import * as THREE from 'three'; +import { useFrame, useThree } from '@react-three/fiber'; +import { Plane } from '@react-three/drei'; + +// Reusable temporaries for per-frame plane positioning (avoids GC pressure) +const _defaultNormal = new THREE.Vector3(0, 0, 1); +const _quaternion = new THREE.Quaternion(); +const _worldPosition = new THREE.Vector3(); + +export type CutterProperties = { + readonly children: React.ReactNode; + readonly plane: THREE.Plane; + readonly enableSection?: boolean; + readonly enableLines?: boolean; + readonly enableMesh?: boolean; + readonly cappingMaterial: THREE.Material; +}; + +type PlaneStencilGroupProperties = { + readonly meshObj: THREE.Mesh; + readonly plane: THREE.Plane; + readonly renderOrder: number; +}; + +export const SectionView = React.forwardRef<{ update: () => void }, CutterProperties>( + ( + { children, plane, enableSection = true, enableLines = true, enableMesh = true, cappingMaterial }, + ref, + ): React.JSX.Element => { + const { gl } = useThree(); + const rootGroupRef = React.useRef(null); + + const [meshList, setMeshList] = React.useState([]); + const [planeSize, setPlaneSize] = React.useState(10); + // Track the previous set of mesh IDs to avoid unnecessary setMeshList calls + // when only clipping plane values changed (the mesh list itself is stable). + const previousMeshIdsRef = React.useRef(''); + + const update: () => void = React.useCallback(() => { + // Early return if cutting is disabled + if (!enableSection) { + setMeshList([]); + + // Remove clipping planes from all objects when cutting is disabled + const rootGroup = rootGroupRef.current; + + if (rootGroup) { + rootGroup.traverse((child: THREE.Object3D) => { + const isMeshOrLine = child instanceof THREE.Mesh || child instanceof THREE.LineSegments; + + if (isMeshOrLine && child.material) { + if (Array.isArray(child.material)) { + for (const mat of child.material) { + mat.clippingPlanes = []; + } + } else { + child.material.clippingPlanes = []; + } + } + }); + } + + return; + } + + const meshChildren: THREE.Mesh[] = []; + const rootGroup = rootGroupRef.current; + + if (rootGroup) { + rootGroup.traverse((child: THREE.Object3D) => { + // Handle LineSegments - apply/clear clipping but no caps + if (child instanceof THREE.LineSegments) { + if (child.material) { + if (Array.isArray(child.material)) { + for (const mat of child.material) { + mat.clippingPlanes = enableLines ? [plane] : []; + } + } else { + child.material.clippingPlanes = enableLines ? [plane] : []; + } + } + + return; // Lines don't get caps, so return early + } + + // Clear clipping planes from meshes if not enabled + if (child instanceof THREE.Mesh && !enableMesh) { + if (child.material) { + if (Array.isArray(child.material)) { + for (const mat of child.material) { + mat.clippingPlanes = []; + } + } else { + child.material.clippingPlanes = []; + } + } + + return; + } + + if (child instanceof THREE.Mesh && child.material && child.geometry) { + child.matrixAutoUpdate = false; + + // Add clipping planes to each mesh and make sure that the material is + // double sided. This is needed to create PlaneStencilGroup for the mesh. + if (Array.isArray(child.material)) { + for (const mat of child.material) { + mat.clippingPlanes = [plane]; + mat.side = THREE.DoubleSide; + } + } else { + child.material.clippingPlanes = [plane]; + child.material.side = THREE.DoubleSide; + } + + // Three.js mesh types are complex and involve generics + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- Mesh type generics are complex + meshChildren.push(child); + } + }); + + // Only update the mesh list and recompute bounds when the set of meshes + // actually changed (not on every plane drag). The mesh list is stable during + // plane manipulation -- only clipping plane values on materials change. + const meshIdsKey = meshChildren.map((m) => m.id).join(','); + if (meshIdsKey !== previousMeshIdsRef.current) { + previousMeshIdsRef.current = meshIdsKey; + + const bbox = new THREE.Box3(); + bbox.setFromObject(rootGroup); + + const boxSize = new THREE.Vector3(); + bbox.getSize(boxSize); + + const calculatedPlaneSize = 2 * boxSize.length(); + setPlaneSize(calculatedPlaneSize); + setMeshList(meshChildren); + } + } + // Depend on primitive values instead of plane object to avoid infinite loop + // eslint-disable-next-line react-hooks/exhaustive-deps -- plane.normal and plane.constant are extracted below + }, [ + plane.normal.x, + plane.normal.y, + plane.normal.z, + plane.constant, + enableSection, + enableLines, + enableMesh, + cappingMaterial, + ]); + + const planeListRef = React.useRef> | undefined>(undefined); + + // See + // https://react.dev/learn/manipulating-the-dom-with-refs#how-to-manage-a-list-of-refs-using-a-ref-callback + function getPlaneListMap(): Map> { + planeListRef.current ??= new Map>(); + return planeListRef.current; + } + + useFrame(() => { + if (enableSection && planeListRef.current && rootGroupRef.current) { + // Reuse module-scoped temporaries to avoid per-frame allocations + _defaultNormal.set(0, 0, 1); + _quaternion.setFromUnitVectors(_defaultNormal, plane.normal); + + for (const [, planeObject] of planeListRef.current) { + // Get a point on the clipping plane in world space + plane.coplanarPoint(_worldPosition); + + // Offset slightly opposite to the plane normal to prevent z-fighting with the mesh surface + const zFightingOffset = 0.1; + _worldPosition.addScaledVector(plane.normal, -zFightingOffset); + + // Transform the world position to the local space of the root group + // This accounts for any centering or translation applied to the parent group + rootGroupRef.current.worldToLocal(planeObject.position.copy(_worldPosition)); + + // Orient the plane to match the clipping plane's normal + planeObject.quaternion.copy(_quaternion); + } + } + }); + + React.useEffect(() => { + update(); + }, [update, children]); + + // Enable/disable local clipping and stencil based on cutting state + React.useEffect(() => { + const shouldEnable = enableSection && (meshList.length > 0 || enableLines); + gl.localClippingEnabled = shouldEnable; + + return () => { + gl.localClippingEnabled = false; + }; + }, [gl, enableSection, enableLines, meshList.length]); + + React.useImperativeHandle(ref, () => ({ update }), [update]); + + return ( + + {children} + {enableSection && meshList.length > 0 ? ( + <> + + {meshList.map((meshObject, index) => ( + + ))} + + {meshList.map((meshObject, index) => ( + + { + const map = getPlaneListMap(); + if (node) { + map.set(index, node); + } else { + map.delete(index); + } + }} + args={[planeSize, planeSize]} + renderOrder={index + 1} + material={cappingMaterial} + onAfterRender={(renderer) => { + renderer.clearStencil(); + }} + /> + + ))} + + ) : null} + + ); + }, +); + +function PlaneStencilGroup({ meshObj, plane, renderOrder }: PlaneStencilGroupProperties): React.JSX.Element { + return ( + + + + + + + + + ); +} diff --git a/packages/three/src/react/transform-controls-drei.tsx b/packages/three/src/react/transform-controls-drei.tsx new file mode 100644 index 000000000..b7a19b9ab --- /dev/null +++ b/packages/three/src/react/transform-controls-drei.tsx @@ -0,0 +1,201 @@ +import type { ThreeElement, ThreeElements } from '@react-three/fiber'; +import { useThree } from '@react-three/fiber'; +import * as React from 'react'; +import * as THREE from 'three'; +import type { ForwardRefComponent } from '@react-three/drei/helpers/ts-utils.js'; +import { TransformControls as TransformControlsImpl } from '#controls/transform-controls.js'; + +type ControlsProto = { + enabled: boolean; +}; + +export type TransformControlsProps = Omit, 'ref' | 'args'> & + Omit & { + readonly object?: THREE.Object3D | React.RefObject; + // eslint-disable-next-line react/boolean-prop-naming -- copied verbatim, keeping the same API intentionally here. + readonly enabled?: boolean; + readonly axis?: string | undefined; + readonly domElement?: HTMLElement; + readonly mode?: 'translate' | 'rotate' | 'scale'; + readonly translationSnap?: number | undefined; + readonly rotationSnap?: number | undefined; + readonly scaleSnap?: number | undefined; + readonly space?: 'world' | 'local'; + readonly size?: number; + // eslint-disable-next-line react/boolean-prop-naming -- copied verbatim, keeping the same API intentionally here. + readonly showX?: boolean; + // eslint-disable-next-line react/boolean-prop-naming -- copied verbatim, keeping the same API intentionally here. + readonly showY?: boolean; + // eslint-disable-next-line react/boolean-prop-naming -- copied verbatim, keeping the same API intentionally here. + readonly showZ?: boolean; + readonly children?: React.ReactElement; + readonly camera?: THREE.Camera; + readonly onChange?: (event?: THREE.Event) => void; + readonly onPointerDown?: (event?: THREE.Event) => void; + readonly onPointerUp?: (event?: THREE.Event) => void; + readonly onObjectChange?: (event?: THREE.Event) => void; + readonly makeDefault?: boolean; + }; + +export const TransformControls: ForwardRefComponent = + /* @__PURE__ */ React.forwardRef( + ( + { + children, + domElement, + onChange, + onPointerDown, + onPointerUp, + onObjectChange, + object, + makeDefault, + camera, + // Transform + enabled, + axis, + mode, + translationSnap, + rotationSnap, + scaleSnap, + space, + size, + showX, + showY, + showZ, + ...props + }, + ref, + ) => { + const defaultControls = useThree((state) => state.controls) as unknown as ControlsProto | undefined; + const gl = useThree((state) => state.gl); + const events = useThree((state) => state.events); + const defaultCamera = useThree((state) => state.camera); + const invalidate = useThree((state) => state.invalidate); + const get = useThree((state) => state.get); + const set = useThree((state) => state.set); + const explCamera = camera ?? defaultCamera; + const explDomElement = (domElement ?? events.connected ?? gl.domElement) as HTMLElement; + const controls = React.useMemo( + () => new TransformControlsImpl(explCamera, explDomElement), + [explCamera, explDomElement], + ); + const group = React.useRef(null!); + + React.useLayoutEffect(() => { + if (object) { + controls.attach(object instanceof THREE.Object3D ? object : object.current); + } else if (group.current instanceof THREE.Object3D) { + controls.attach(group.current); + } + + return () => { + void controls.detach(); + }; + }, [object, children, controls]); + + React.useEffect(() => { + if (defaultControls) { + const callback = (event: { value: boolean }) => { + defaultControls.enabled = !event.value; + }; + + // @ts-expect-error -- adding a new event. + controls.addEventListener('dragging-changed', callback); + return () => { + // @ts-expect-error -- adding a new event. + controls.removeEventListener('dragging-changed', callback); + }; + } + + return () => { + // No-op when makeDefault=false. + }; + }, [controls, defaultControls]); + + const onChangeRef = React.useRef<((event?: THREE.Event) => void) | undefined>(undefined); + const onPointerDownRef = React.useRef<((event?: THREE.Event) => void) | undefined>(undefined); + const onPointerUpRef = React.useRef<((event?: THREE.Event) => void) | undefined>(undefined); + const onObjectChangeRef = React.useRef<((event?: THREE.Event) => void) | undefined>(undefined); + + React.useLayoutEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + React.useLayoutEffect(() => { + onPointerDownRef.current = onPointerDown; + }, [onPointerDown]); + React.useLayoutEffect(() => { + onPointerUpRef.current = onPointerUp; + }, [onPointerUp]); + React.useLayoutEffect(() => { + onObjectChangeRef.current = onObjectChange; + }, [onObjectChange]); + + React.useEffect(() => { + const onChange = (event: THREE.Event) => { + invalidate(); + onChangeRef.current?.(event); + }; + + const onPointerDown = (event: THREE.Event) => onPointerDownRef.current?.(event); + const onPointerUp = (event: THREE.Event) => onPointerUpRef.current?.(event); + const onObjectChange = (event: THREE.Event) => onObjectChangeRef.current?.(event); + + // @ts-expect-error -- newly added events + controls.addEventListener('change', onChange); + // @ts-expect-error -- newly added events + controls.addEventListener('pointerDown', onPointerDown); + // @ts-expect-error -- newly added events + controls.addEventListener('pointerUp', onPointerUp); + // @ts-expect-error -- newly added events + controls.addEventListener('objectChange', onObjectChange); + + return () => { + // @ts-expect-error -- newly added events + controls.removeEventListener('change', onChange); + // @ts-expect-error -- newly added events + controls.removeEventListener('pointerDown', onPointerDown); + // @ts-expect-error -- newly added events + controls.removeEventListener('pointerUp', onPointerUp); + // @ts-expect-error -- newly added events + controls.removeEventListener('objectChange', onObjectChange); + }; + }, [invalidate, controls]); + + React.useEffect(() => { + if (makeDefault) { + const old = get().controls; + set({ controls }); + return () => { + set({ controls: old }); + }; + } + + return () => { + // No-op when makeDefault=false. + }; + }, [makeDefault, controls, get, set]); + + return ( + <> + + + {children} + + + ); + }, + ); From 7bed8348995f02a54a5ce6a111f0dea9301d270d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 25 Feb 2026 10:23:00 +0000 Subject: [PATCH 04/14] feat(three): copy use-camera-framing test and add supporting modules - Copy use-camera-framing.test.ts from apps/ui to packages/three - Update imports: #components/geometry/graphics/three/ -> #react/ - Add stage.ts with StageOptions and defaultStageOptions - Add use-camera-framing.ts and use-camera-reset.ts hooks - Add use-graphics.ts stub for standalone package usage - Add @vitest-environment jsdom for React hook tests Co-authored-by: richard --- packages/three/src/hooks/use-graphics.ts | 32 ++ .../react/hooks/use-camera-framing.test.ts | 274 ++++++++++++++++++ .../src/react/hooks/use-camera-framing.ts | 125 ++++++++ packages/three/src/react/stage.ts | 47 +++ packages/three/src/react/use-camera-reset.ts | 111 +++++++ 5 files changed, 589 insertions(+) create mode 100644 packages/three/src/hooks/use-graphics.ts create mode 100644 packages/three/src/react/hooks/use-camera-framing.test.ts create mode 100644 packages/three/src/react/hooks/use-camera-framing.ts create mode 100644 packages/three/src/react/stage.ts create mode 100644 packages/three/src/react/use-camera-reset.ts diff --git a/packages/three/src/hooks/use-graphics.ts b/packages/three/src/hooks/use-graphics.ts new file mode 100644 index 000000000..0511494be --- /dev/null +++ b/packages/three/src/hooks/use-graphics.ts @@ -0,0 +1,32 @@ +/** + * Default graphics context for standalone package usage. + * Apps can provide their own implementation via module resolution or context. + */ +const defaultGraphicsState = { + context: { + cameraFovAngle: 50, + }, +}; + +/** + * Select a value from the graphics context state. + * In standalone mode returns from default state; apps override via GraphicsProvider. + */ +export function useGraphicsSelector(selector: (state: { context: { cameraFovAngle: number } }) => T): T { + return selector(defaultGraphicsState); +} + +/** No-op actor for standalone package usage. Apps provide their own via GraphicsProvider. */ +const noopCameraCapability = { + send(_event: { type: string; reset?: () => void }) { + // No-op + }, +}; + +/** + * Get the camera capability actor for registering reset handlers. + * In standalone mode returns a no-op; apps provide their own via GraphicsProvider. + */ +export function useCameraCapability(): typeof noopCameraCapability { + return noopCameraCapability; +} diff --git a/packages/three/src/react/hooks/use-camera-framing.test.ts b/packages/three/src/react/hooks/use-camera-framing.test.ts new file mode 100644 index 000000000..e0d74b91c --- /dev/null +++ b/packages/three/src/react/hooks/use-camera-framing.test.ts @@ -0,0 +1,274 @@ +/** + * @vitest-environment jsdom + */ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { Vector3 } from 'three'; +import type { StageOptions } from '#react/stage.js'; +import { defaultStageOptions } from '#react/stage.js'; +import { useCameraFraming } from '#react/hooks/use-camera-framing.js'; + +// ── Controllable mocks ─────────────────────────────────────────────────────── + +/** Viewport size reported by `useThree()`. Mutate before render/rerender. */ +const mockSize = { width: 800, height: 600 }; + +vi.mock('@react-three/fiber', () => ({ + useThree: () => ({ size: mockSize }), +})); + +/** + * Spy returned by the mocked `useCameraReset`. When invoked, simulates the + * real behaviour by committing the current geometry radius via `setSceneRadius`. + */ +const mockResetCamera = vi.fn(); + +/** Shape of the params that `useCameraFraming` forwards to `useCameraReset`. */ +type CapturedResetParameters = { + geometryRadius: number; + geometryCenter: Vector3; + setSceneRadius: (radius: number) => void; + rotation: { side: number; vertical: number }; + perspective: { + offsetRatio: number; + zoomLevel: number; + nearPlane: number; + minimumFarPlane: number; + farPlaneRadiusMultiplier: number; + }; + cameraFovAngle: number; +}; + +/** Latest params forwarded to `useCameraReset` by the hook under test. */ +let latestResetParameters: CapturedResetParameters; + +vi.mock('#react/use-camera-reset.js', () => ({ + useCameraReset: vi.fn((parameters: CapturedResetParameters) => { + latestResetParameters = parameters; + mockResetCamera.mockImplementation(() => { + latestResetParameters.setSceneRadius(latestResetParameters.geometryRadius); + }); + return mockResetCamera; + }), +})); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const origin = new Vector3(0, 0, 0); + +// ── Setup ──────────────────────────────────────────────────────────────────── + +beforeEach(() => { + mockResetCamera.mockClear(); + mockSize.width = 800; + mockSize.height = 600; +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('useCameraFraming', () => { + // ── Initial reset behavior ────────────────────────────────────────────── + + describe('initial reset behavior', () => { + it('calls resetCamera on first render with geometryRadius > 0', () => { + renderHook(() => useCameraFraming(10, origin)); + + expect(mockResetCamera).toHaveBeenCalledTimes(1); + // Initial reset uses configured angles (called with no arguments) + expect(mockResetCamera).toHaveBeenCalledWith(); + }); + + it('calls resetCamera when geometryRadius is 0 but does not mark initial reset done', () => { + renderHook(() => useCameraFraming(0, origin)); + + // Still called because sceneRadius starts as undefined (always significant) + expect(mockResetCamera).toHaveBeenCalledTimes(1); + expect(mockResetCamera).toHaveBeenCalledWith(); + }); + + it('uses initial reset (configured angles) when transitioning from zero to positive radius', () => { + const { rerender } = renderHook(({ radius }) => useCameraFraming(radius, origin), { + initialProps: { radius: 0 }, + }); + + mockResetCamera.mockClear(); + + rerender({ radius: 10 }); + + expect(mockResetCamera).toHaveBeenCalled(); + // All calls should use the initial-reset pattern (no { enableConfiguredAngles: false }) + const hasSubsequentPattern = mockResetCamera.mock.calls.some( + (call: unknown[]) => + (call[0] as { enableConfiguredAngles?: boolean } | undefined)?.enableConfiguredAngles === false, + ); + expect(hasSubsequentPattern).toBe(false); + }); + }); + + // ── Significant geometry change detection ─────────────────────────────── + + describe('significant geometry change detection', () => { + it('triggers direction-preserving reset when radius changes by more than 10%', () => { + const { rerender } = renderHook(({ radius }) => useCameraFraming(radius, origin), { + initialProps: { radius: 10 }, + }); + + mockResetCamera.mockClear(); + + rerender({ radius: 12 }); // 20% change + + expect(mockResetCamera).toHaveBeenCalledWith({ enableConfiguredAngles: false }); + }); + + it('does not reset when radius changes by less than 10%', () => { + const { rerender } = renderHook(({ radius }) => useCameraFraming(radius, origin), { + initialProps: { radius: 10 }, + }); + + mockResetCamera.mockClear(); + + rerender({ radius: 10.5 }); // 5% change + + expect(mockResetCamera).not.toHaveBeenCalled(); + }); + + it('does not reset at the exact 10% boundary (strict > comparison)', () => { + const { rerender } = renderHook(({ radius }) => useCameraFraming(radius, origin), { + initialProps: { radius: 10 }, + }); + + mockResetCamera.mockClear(); + + rerender({ radius: 11 }); // Exactly 10% + + expect(mockResetCamera).not.toHaveBeenCalled(); + }); + + it('resets just above the 10% threshold', () => { + const { rerender } = renderHook(({ radius }) => useCameraFraming(radius, origin), { + initialProps: { radius: 10 }, + }); + + mockResetCamera.mockClear(); + + rerender({ radius: 11.01 }); // 10.1% change + + expect(mockResetCamera).toHaveBeenCalledWith({ enableConfiguredAngles: false }); + }); + + it('handles multiple successive significant changes', () => { + const { rerender } = renderHook(({ radius }) => useCameraFraming(radius, origin), { + initialProps: { radius: 10 }, + }); + + mockResetCamera.mockClear(); + + rerender({ radius: 15 }); // 50% change from 10 + expect(mockResetCamera).toHaveBeenCalledTimes(1); + + mockResetCamera.mockClear(); + + rerender({ radius: 20 }); // 33% change from 15 + expect(mockResetCamera).toHaveBeenCalledTimes(1); + expect(mockResetCamera).toHaveBeenCalledWith({ enableConfiguredAngles: false }); + }); + }); + + // ── Aspect ratio change detection ─────────────────────────────────────── + + describe('aspect ratio change detection', () => { + it('triggers reset when viewport aspect changes by more than 10%', () => { + const { rerender } = renderHook(({ radius }) => useCameraFraming(radius, origin), { + initialProps: { radius: 10 }, + }); + + mockResetCamera.mockClear(); + + mockSize.width = 400; // 400/600 = 0.667 vs 800/600 = 1.333 → 50% change + rerender({ radius: 10 }); + + expect(mockResetCamera).toHaveBeenCalledWith({ enableConfiguredAngles: false }); + }); + + it('does not reset for small aspect changes', () => { + const { rerender } = renderHook(({ radius }) => useCameraFraming(radius, origin), { + initialProps: { radius: 10 }, + }); + + mockResetCamera.mockClear(); + + mockSize.width = 790; // 790/600 ≈ 1.317 vs 1.333 → ~1.2% change + rerender({ radius: 10 }); + + expect(mockResetCamera).not.toHaveBeenCalled(); + }); + + it('ignores aspect changes before initial geometry reset is complete', () => { + const { rerender } = renderHook(({ radius }) => useCameraFraming(radius, origin), { + initialProps: { radius: 0 }, + }); + + mockResetCamera.mockClear(); + + mockSize.width = 400; // Significant aspect change + rerender({ radius: 0 }); + + // Aspect effect returns early when isInitialResetDoneRef is false or radius <= 0 + expect(mockResetCamera).not.toHaveBeenCalled(); + }); + }); + + // ── Edge cases ────────────────────────────────────────────────────────── + + describe('edge cases', () => { + it('returns the resetCamera function for manual use', () => { + const { result } = renderHook(() => useCameraFraming(10, origin)); + + expect(result.current).toBe(mockResetCamera); + }); + + it('uses defaultStageOptions when none provided', () => { + renderHook(() => useCameraFraming(10, origin)); + + expect(latestResetParameters.perspective.offsetRatio).toBe(defaultStageOptions.offsetRatio); + expect(latestResetParameters.perspective.nearPlane).toBe(defaultStageOptions.nearPlane); + expect(latestResetParameters.perspective.minimumFarPlane).toBe(defaultStageOptions.minimumFarPlane); + expect(latestResetParameters.perspective.farPlaneRadiusMultiplier).toBe( + defaultStageOptions.farPlaneRadiusMultiplier, + ); + expect(latestResetParameters.perspective.zoomLevel).toBe(defaultStageOptions.zoomLevel); + expect(latestResetParameters.rotation.side).toBe(defaultStageOptions.rotation.side); + expect(latestResetParameters.rotation.vertical).toBe(defaultStageOptions.rotation.vertical); + }); + + it('merges custom StageOptions with defaults', () => { + const customOptions: StageOptions = { + zoomLevel: 2, + rotation: { side: 0 }, + }; + + renderHook(() => useCameraFraming(10, origin, customOptions)); + + // Custom values applied + expect(latestResetParameters.perspective.zoomLevel).toBe(2); + expect(latestResetParameters.rotation.side).toBe(0); + // Defaults preserved for unset fields + expect(latestResetParameters.perspective.offsetRatio).toBe(defaultStageOptions.offsetRatio); + expect(latestResetParameters.rotation.vertical).toBe(defaultStageOptions.rotation.vertical); + }); + + it('forwards cameraFovAngle from graphics context', () => { + renderHook(() => useCameraFraming(10, origin)); + + expect(latestResetParameters.cameraFovAngle).toBe(50); + }); + + it('forwards geometryCenter to useCameraReset', () => { + const center = new Vector3(1, 2, 3); + + renderHook(() => useCameraFraming(10, center)); + + expect(latestResetParameters.geometryCenter).toBe(center); + }); + }); +}); diff --git a/packages/three/src/react/hooks/use-camera-framing.ts b/packages/three/src/react/hooks/use-camera-framing.ts new file mode 100644 index 000000000..fc8e64356 --- /dev/null +++ b/packages/three/src/react/hooks/use-camera-framing.ts @@ -0,0 +1,125 @@ +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useThree } from '@react-three/fiber'; +import type * as THREE from 'three'; +import { useCameraReset } from '#react/use-camera-reset.js'; +import { useGraphicsSelector } from '#hooks/use-graphics.js'; +import type { StageOptions } from '#react/stage.js'; +import { defaultStageOptions } from '#react/stage.js'; + +const significantRadiusChangeRatio = 0.1; +const significantAspectChangeRatio = 0.1; + +/** + * Camera framing policy hook. + * + * Resolves `StageOptions` into concrete camera parameters, wires them into + * `useCameraReset`, and runs the auto-reset `useLayoutEffect` that decides + * whether an initial (with configured angles) or subsequent (preserving the + * current viewing direction) camera reset is needed when the geometry bounds + * change significantly. + * + * Returns `resetCamera` for manual (e.g. toolbar button) resets. + */ +export function useCameraFraming( + geometryRadius: number, + geometryCenter: THREE.Vector3, + stageOptions: StageOptions = defaultStageOptions, +): (options?: { enableConfiguredAngles?: boolean }) => void { + const cameraFovAngle = useGraphicsSelector((state) => state.context.cameraFovAngle); + + // Merge caller options with defaults + const { offsetRatio, nearPlane, minimumFarPlane, farPlaneRadiusMultiplier, zoomLevel, rotation } = useMemo( + () => ({ + ...defaultStageOptions, + ...stageOptions, + rotation: { ...defaultStageOptions.rotation, ...stageOptions.rotation }, + }), + [stageOptions], + ); + + // Internal state: the "committed" scene radius that the camera was last + // framed to. Compared against the live geometryRadius to decide whether a + // camera reset is needed. + const [sceneRadius, setSceneRadius] = useState(undefined); + + const setSceneRadiusCallback = useCallback((radius: number) => { + setSceneRadius(radius); + }, []); + + // Ref tracking the original camera distance for zoom-relative positioning + const originalDistanceReference = useRef(undefined); + + // Whether the very first camera reset (with configured angles) has fired + const isInitialResetDoneRef = useRef(false); + + // Wire everything into the lower-level camera reset hook + const resetCamera = useCameraReset({ + geometryRadius, + geometryCenter, + rotation: { + side: rotation.side, + vertical: rotation.vertical, + }, + perspective: { + offsetRatio, + zoomLevel, + nearPlane, + minimumFarPlane, + farPlaneRadiusMultiplier, + }, + setSceneRadius: setSceneRadiusCallback, + originalDistanceReference, + cameraFovAngle, + }); + + /** + * Auto-reset the camera when the geometry's bounding sphere changes + * significantly relative to the last committed scene radius. + */ + useLayoutEffect(() => { + const changeRatio = sceneRadius === undefined ? 0 : Math.abs((geometryRadius - sceneRadius) / sceneRadius); + const isSignificantChange = sceneRadius === undefined ? true : changeRatio > significantRadiusChangeRatio; + + if (isSignificantChange) { + // Only mark the initial reset as complete once we have real geometry + // (geometryRadius > 0). Before that, the camera may be replaced by + // PerspectiveCamera makeDefault, leaving it at (0,0,0). If we marked + // the flag earlier, subsequent resets would skip configured angles and + // compute the viewing direction from (0,0,0) toward geometryCenter, + // which can point the camera below the scene. + if (isInitialResetDoneRef.current && geometryRadius > 0) { + resetCamera({ enableConfiguredAngles: false }); + } else { + resetCamera(); + if (geometryRadius > 0) { + isInitialResetDoneRef.current = true; + } + } + } + }, [resetCamera, sceneRadius, geometryRadius]); + + // Track viewport aspect ratio and re-frame when it changes significantly. + // This ensures the model remains fully visible when Dockview panels are + // resized (e.g. split into narrow portrait viewports). + const { size } = useThree(); + const viewportAspect = size.width > 0 && size.height > 0 ? size.width / size.height : 1; + const lastAspectRef = useRef(viewportAspect); + + useLayoutEffect(() => { + // Skip if the initial geometry reset hasn't happened yet + if (!isInitialResetDoneRef.current || geometryRadius <= 0) { + lastAspectRef.current = viewportAspect; + return; + } + + const lastAspect = lastAspectRef.current; + const aspectChange = Math.abs(viewportAspect - lastAspect) / Math.max(lastAspect, 1e-9); + + if (aspectChange > significantAspectChangeRatio) { + lastAspectRef.current = viewportAspect; + resetCamera({ enableConfiguredAngles: false }); + } + }, [viewportAspect, resetCamera, geometryRadius]); + + return resetCamera; +} diff --git a/packages/three/src/react/stage.ts b/packages/three/src/react/stage.ts new file mode 100644 index 000000000..99d4e11fd --- /dev/null +++ b/packages/three/src/react/stage.ts @@ -0,0 +1,47 @@ +/** Configuration options for the 3D stage camera and view. */ +export type StageOptions = { + /** + * The ratio of the scene's radius to offset the camera from the center. Adjusting this value will change the applied perspective of the scene. + */ + offsetRatio?: number; + /** + * The near plane of the camera. + */ + nearPlane?: number; + /** + * The minimum far plane of the camera. + */ + minimumFarPlane?: number; + /** + * The multiplier for the camera's far plane. + */ + farPlaneRadiusMultiplier?: number; + /** + * The zoom level of the camera. + */ + zoomLevel?: number; + rotation?: { + /** + * The initial z-axis rotation of the camera in radians. + */ + side?: number; + + /** + * The initial xy-plane rotation of the camera in radians. + */ + vertical?: number; + }; +}; + +// Default configuration constants +export const defaultStageOptions = { + offsetRatio: 2, + nearPlane: 1e-3, + minimumFarPlane: 10_000_000_000, + farPlaneRadiusMultiplier: 5, + zoomLevel: 1, + rotation: { + side: -Math.PI / 4, // Default rotation is 45 degrees counter-clockwise + vertical: Math.PI / 6, // Default rotation is 30 degrees upwards + }, +} as const satisfies StageOptions; diff --git a/packages/three/src/react/use-camera-reset.ts b/packages/three/src/react/use-camera-reset.ts new file mode 100644 index 000000000..9dd228d32 --- /dev/null +++ b/packages/three/src/react/use-camera-reset.ts @@ -0,0 +1,111 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useThree } from '@react-three/fiber'; +import type { RefObject } from 'react'; +import type * as THREE from 'three'; +import { resetCamera as resetCameraFn } from '#utils/camera.utils.js'; +import { useCameraCapability } from '#hooks/use-graphics.js'; + +// Define the specific types needed for camera reset +type ResetRotation = { + side: number; + vertical: number; +}; + +type ResetPerspective = { + offsetRatio: number; + zoomLevel: number; + nearPlane: number; + minimumFarPlane: number; + farPlaneRadiusMultiplier: number; +}; + +type ResetCameraParameters = { + geometryRadius: number; + geometryCenter: THREE.Vector3; + rotation: ResetRotation; + perspective: ResetPerspective; + setSceneRadius: (radius: number) => void; + originalDistanceReference?: RefObject; + cameraFovAngle: number; +}; + +/** + * Hook that provides camera reset functionality and registers it with the graphics context + * + * @param parameters - The parameters for the camera reset. + * @returns The reset function. + */ +export function useCameraReset(parameters: ResetCameraParameters): (options?: { + /** + * Whether to enable configured angles. + * @default true + */ + enableConfiguredAngles?: boolean; +}) => void { + const { camera, controls, invalidate, size } = useThree(); + const viewportAspect = size.width > 0 && size.height > 0 ? size.width / size.height : 1; + const cameraCapabilityActor = useCameraCapability(); + const isRegistered = useRef(false); + + // Store viewportAspect in a ref so the resetCamera callback remains stable + // during resize. The aspect is read lazily when reset is actually called, + // preventing callback recreation on every resize pixel. + const viewportAspectRef = useRef(viewportAspect); + viewportAspectRef.current = viewportAspect; + + const { + geometryRadius, + geometryCenter, + rotation, + perspective, + setSceneRadius, + originalDistanceReference, + cameraFovAngle, + } = parameters; + + const resetCamera = useCallback( + (options?: { enableConfiguredAngles?: boolean }) => { + // Reset original distance reference if available + if (originalDistanceReference?.current !== undefined) { + originalDistanceReference.current = undefined; + } + + resetCameraFn({ + camera, + geometryRadius, + geometryCenter, + rotation, + perspective, + setSceneRadius, + invalidate, + enableConfiguredAngles: options?.enableConfiguredAngles, + cameraFovAngle, + controls: (controls ?? undefined) as { target: THREE.Vector3; update: () => void } | undefined, + viewportAspect: viewportAspectRef.current, + }); + }, + [ + originalDistanceReference, + camera, + controls, + geometryRadius, + geometryCenter, + rotation, + perspective, + setSceneRadius, + invalidate, + cameraFovAngle, + ], + ); + + // Register the reset function with the camera capability actor only once + useEffect(() => { + if (!isRegistered.current) { + cameraCapabilityActor.send({ type: 'registerReset', reset: resetCamera }); + isRegistered.current = true; + } + }, [resetCamera, cameraCapabilityActor]); + + // Return the reset function for direct use if needed + return resetCamera; +} From 946d0579c36b74fdd2fe20c89c38c0c6c9dff25c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 25 Feb 2026 10:28:55 +0000 Subject: [PATCH 05/14] feat(three): extract screenshot capture functions into @taucad/three Extract pure screenshot capture functions from the xstate machine file into standalone pure functions in the @taucad/three package: - captureScreenshot: Core Three.js screenshot capture with temporary renderer, matcap override, FOV compensation, and bounding box framing - createCompositeImage: Creates a composite grid image from multiple screenshots with labels, dividers, and configurable layout - calculateOptimalGrid: Grid layout calculator for composite images Local type definitions (CameraAngle, ScreenshotOptions, etc.) are defined in the package to avoid depending on @taucad/types. No xstate or SVG-specific code was included. Co-authored-by: richard --- packages/three/src/index.ts | 2 + .../src/screenshot/capture-screenshot.ts | 219 ++++++++++++++++++ .../src/screenshot/create-composite-image.ts | 194 ++++++++++++++++ packages/three/src/screenshot/index.ts | 8 + packages/three/src/screenshot/types.ts | 52 +++++ 5 files changed, 475 insertions(+) create mode 100644 packages/three/src/index.ts create mode 100644 packages/three/src/screenshot/capture-screenshot.ts create mode 100644 packages/three/src/screenshot/create-composite-image.ts create mode 100644 packages/three/src/screenshot/index.ts create mode 100644 packages/three/src/screenshot/types.ts diff --git a/packages/three/src/index.ts b/packages/three/src/index.ts new file mode 100644 index 000000000..a9e2e75fe --- /dev/null +++ b/packages/three/src/index.ts @@ -0,0 +1,2 @@ +// @taucad/three - Core Three.js utilities (no React dependency) +export * from '#screenshot/index.js'; diff --git a/packages/three/src/screenshot/capture-screenshot.ts b/packages/three/src/screenshot/capture-screenshot.ts new file mode 100644 index 000000000..9ad61888c --- /dev/null +++ b/packages/three/src/screenshot/capture-screenshot.ts @@ -0,0 +1,219 @@ +import * as THREE from 'three'; +import type { CameraAngle, ScreenshotOptions } from '#screenshot/types.js'; +import { applyMatcapToClonedScene, disposeClonedSceneMaterials } from '#materials/gltf-matcap.js'; +import { ensureMatcapTextureLoaded } from '#materials/matcap-material.js'; +import { defaultStageOptions } from '#react/stage.js'; +import { computeViewFittingZoom } from '#utils/camera.utils.js'; +import { calculateFovDistanceCompensation } from '#utils/math.utils.js'; + +const defaultOptions = { + aspectRatio: 16 / 9, + zoomLevel: 1.25, + cameraAngles: [{ phi: undefined, theta: undefined }] as CameraAngle[], + output: { + format: 'image/png' as const, + quality: 0.92, + isPreview: true, + }, +} satisfies ScreenshotOptions; + +/** + * Capture one or more screenshots of a Three.js scene from the given camera + * angles. The function creates a temporary off-screen renderer, applies matcap + * materials for lighting-independent rendering, and returns an array of data + * URLs — one per camera angle. + */ +export async function captureScreenshot({ + gl, + scene, + camera, + options, +}: { + gl: THREE.WebGLRenderer; + scene: THREE.Scene; + camera: THREE.Camera; + options?: ScreenshotOptions; +}): Promise { + if (!gl.domElement.isConnected) { + throw new Error('Screenshot attempted on disconnected canvas - canvas may have been recreated'); + } + + const config = { + ...defaultOptions, + ...options, + output: { + ...defaultOptions.output, + ...options?.output, + }, + }; + + if (config.cameraAngles.length === 0) { + config.cameraAngles = defaultOptions.cameraAngles; + } + + const originalHeight = gl.domElement.height; + + const targetAspect = config.aspectRatio; + let width = Math.round(originalHeight * targetAspect); + let height = originalHeight; + + if (config.maxResolution) { + const maxDimension = Math.max(width, height); + if (maxDimension > config.maxResolution) { + const scale = config.maxResolution / maxDimension; + width = Math.round(width * scale); + height = Math.round(height * scale); + } + } + + const screenshotCanvas = document.createElement('canvas'); + screenshotCanvas.width = width; + screenshotCanvas.height = height; + + const screenshotRenderer = new THREE.WebGLRenderer({ + canvas: screenshotCanvas, + alpha: true, + antialias: true, + logarithmicDepthBuffer: true, + }); + + try { + screenshotRenderer.setSize(width, height, false); + + const useHighDpi = config.cameraAngles.length === 1; + const pixelRatio = useHighDpi ? gl.getPixelRatio() : 1; + screenshotRenderer.setPixelRatio(pixelRatio); + + screenshotRenderer.outputColorSpace = gl.outputColorSpace; + + // Matcap materials produce values in displayable range — no filmic + // tone-mapping curve needed. + screenshotRenderer.toneMapping = THREE.NoToneMapping; + screenshotRenderer.toneMappingExposure = 1; + + const dataUrls: string[] = []; + + const screenshotScene = scene.clone(); + + if (config.output.isPreview) { + screenshotScene.traverse((object) => { + if (object.userData['isPreviewOnly']) { + object.visible = false; + } + }); + } + + // Replace all mesh materials with matcap for lighting-independent rendering. + const matcapTexture = await ensureMatcapTextureLoaded(); + applyMatcapToClonedScene(screenshotScene, matcapTexture); + + screenshotScene.environment = null; + screenshotScene.environmentIntensity = 0; + + // Compute bounding-box center and sphere radius so the camera orbits and + // frames the actual model rather than the world origin. + const boundingBox = new THREE.Box3().setFromObject(screenshotScene); + const geometryCenter = new THREE.Vector3(); + const boundingSphere = new THREE.Sphere(); + boundingBox.getCenter(geometryCenter); + boundingBox.getBoundingSphere(boundingSphere); + const geometryRadius = boundingSphere.radius > 0 ? boundingSphere.radius : 1000; + + for (const cameraAngle of config.cameraAngles) { + const screenshotCamera = (camera as THREE.PerspectiveCamera).clone(); + + // Fix FOV to 45° for consistent perspective across all screenshots. + // Compensate zoom so the same visible area is preserved. + const screenshotFov = 45; + const zoomCompensation = calculateFovDistanceCompensation(screenshotFov, screenshotCamera.fov, 1); + screenshotCamera.fov = screenshotFov; + screenshotCamera.zoom = config.zoomLevel * zoomCompensation; + screenshotCamera.aspect = config.aspectRatio; + + if (cameraAngle.phi !== undefined && cameraAngle.theta !== undefined) { + const standardFov = 60; + const adjustedOffsetRatio = + defaultStageOptions.offsetRatio * calculateFovDistanceCompensation(standardFov, screenshotFov, 1); + const distance = geometryRadius * adjustedOffsetRatio; + + const phiRad = (cameraAngle.phi * Math.PI) / 180; + const thetaRad = (cameraAngle.theta * Math.PI) / 180; + + const upVector = THREE.Object3D.DEFAULT_UP.clone(); + + let ox: number; + let oy: number; + let oz: number; + + if (upVector.z === 1) { + ox = distance * Math.sin(phiRad) * Math.cos(thetaRad); + oy = distance * Math.sin(phiRad) * Math.sin(thetaRad); + oz = distance * Math.cos(phiRad); + } else if (upVector.y === 1) { + ox = distance * Math.sin(phiRad) * Math.cos(thetaRad); + oz = distance * Math.sin(phiRad) * Math.sin(thetaRad); + oy = distance * Math.cos(phiRad); + } else { + oy = distance * Math.sin(phiRad) * Math.cos(thetaRad); + oz = distance * Math.sin(phiRad) * Math.sin(thetaRad); + ox = distance * Math.cos(phiRad); + } + + screenshotCamera.position.set(geometryCenter.x + ox, geometryCenter.y + oy, geometryCenter.z + oz); + screenshotCamera.lookAt(geometryCenter); + + screenshotCamera.zoom = computeViewFittingZoom({ + cameraPosition: screenshotCamera.position, + target: geometryCenter, + boundingBox, + fovDeg: screenshotFov, + aspectRatio: config.aspectRatio, + }); + } + + screenshotCamera.updateProjectionMatrix(); + screenshotCamera.updateMatrixWorld(true); + + screenshotRenderer.render(screenshotScene, screenshotCamera); + + // eslint-disable-next-line no-await-in-loop -- sequential processing required for shared canvas + const blob = await new Promise((resolve) => { + const mimeType = config.output.format; + const quality = mimeType === 'image/jpeg' || mimeType === 'image/webp' ? config.output.quality : undefined; + + screenshotCanvas.toBlob( + (result) => { + resolve(result ?? undefined); + }, + mimeType, + quality, + ); + }); + + if (!blob) { + throw new Error('Failed to create blob from canvas'); + } + + // eslint-disable-next-line no-await-in-loop -- sequential processing required for shared canvas + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener('load', () => { + resolve(reader.result as string); + }); + reader.addEventListener('error', reject); + reader.readAsDataURL(blob); + }); + + dataUrls.push(dataUrl); + } + + disposeClonedSceneMaterials(screenshotScene); + + return dataUrls; + } finally { + screenshotRenderer.dispose(); + screenshotRenderer.forceContextLoss(); + screenshotCanvas.width = 0; + screenshotCanvas.height = 0; + } +} diff --git a/packages/three/src/screenshot/create-composite-image.ts b/packages/three/src/screenshot/create-composite-image.ts new file mode 100644 index 000000000..70fc9bdeb --- /dev/null +++ b/packages/three/src/screenshot/create-composite-image.ts @@ -0,0 +1,194 @@ +import type { CompositeScreenshotOptions } from '#screenshot/types.js'; + +const defaultCompositeOptions = { + enabled: true, + preferredRatio: { columns: 3, rows: 2 }, + showLabels: true, + padding: 12, + labelHeight: 24, + backgroundColor: 'transparent', + dividerColor: '#666666', + dividerWidth: 1, +} satisfies CompositeScreenshotOptions; + +/** + * Calculate optimal grid layout for given number of items. + * + * @param itemCount - Number of items to arrange in a grid. + * @param preferredRatio - Target column-to-row ratio. + * @param preferredRatio.columns - Preferred number of columns. + * @param preferredRatio.rows - Preferred number of rows. + */ +export function calculateOptimalGrid( + itemCount: number, + preferredRatio: { columns: number; rows: number } = defaultCompositeOptions.preferredRatio, +): { columns: number; rows: number } { + if (itemCount <= 0) { + return { columns: 1, rows: 1 }; + } + + if (itemCount === 1) { + return { columns: 1, rows: 1 }; + } + + const targetRatio = preferredRatio.columns / preferredRatio.rows; + + let bestColumns = 1; + let bestRows = itemCount; + let bestRatioDiff = Math.abs(bestColumns / bestRows - targetRatio); + + for (let columns = 1; columns <= itemCount; columns++) { + const rows = Math.ceil(itemCount / columns); + const ratio = columns / rows; + const ratioDiff = Math.abs(ratio - targetRatio); + + if (ratioDiff < bestRatioDiff) { + bestColumns = columns; + bestRows = rows; + bestRatioDiff = ratioDiff; + } + } + + return { columns: bestColumns, rows: bestRows }; +} + +/** + * Create a composite grid image from multiple screenshots. + * + * @param screenshots - Array of labelled screenshot data URLs. + * @param options - Composite layout options. + * @returns A data URL of the composited image. + */ +export async function createCompositeImage( + screenshots: Array<{ label: string; dataUrl: string }>, + options: CompositeScreenshotOptions = defaultCompositeOptions, +): Promise { + const mergedOptions = { + ...defaultCompositeOptions, + ...options, + }; + + const { padding, labelHeight, showLabels, backgroundColor, dividerColor, dividerWidth, preferredRatio } = + mergedOptions; + + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('Could not get canvas context'); + } + + const images = await Promise.all( + screenshots.map(async (screenshot) => { + return new Promise<{ label: string; image: HTMLImageElement }>((resolve, reject) => { + const img = new globalThis.Image(); + img.addEventListener('load', () => { + resolve({ label: screenshot.label, image: img }); + }); + img.addEventListener('error', reject); + img.src = screenshot.dataUrl; + }); + }), + ); + + if (images.length === 0) { + throw new Error('No images to create composite image from'); + } + + const originalWidth = images[0]!.image.width; + const originalHeight = images[0]!.image.height; + + const maxImageSize = 600; + const scale = Math.min(1, maxImageSize / Math.max(originalWidth, originalHeight)); + const imageWidth = Math.round(originalWidth * scale); + const imageHeight = Math.round(originalHeight * scale); + + const { columns, rows } = calculateOptimalGrid(images.length, preferredRatio); + + const effectiveLabelHeight = showLabels ? labelHeight : 0; + const effectivePadding = Math.max(padding, Math.round(imageWidth * 0.02)); + + canvas.width = columns * imageWidth + (columns + 1) * effectivePadding; + canvas.height = rows * (imageHeight + effectiveLabelHeight) + (rows + 1) * effectivePadding; + + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = 'low'; + + const isTransparent = backgroundColor === 'transparent'; + if (!isTransparent) { + context.fillStyle = backgroundColor; + context.fillRect(0, 0, canvas.width, canvas.height); + } + + if (showLabels) { + const fontSize = Math.max(12, Math.round(imageHeight * 0.06)); + context.fillStyle = '#000000'; + context.font = `bold ${fontSize}px Arial`; + context.textAlign = 'center'; + } + + for (const [index, item] of images.entries()) { + const col = index % columns; + const row = Math.floor(index / columns); + + const x = effectivePadding + col * (imageWidth + effectivePadding); + const y = effectivePadding + row * (imageHeight + effectiveLabelHeight + effectivePadding); + + context.drawImage(item.image, x, y, imageWidth, imageHeight); + + if (showLabels) { + const labelX = x + imageWidth / 2; + const labelY = y + imageHeight + effectiveLabelHeight - 5; + context.fillText(item.label.toUpperCase(), labelX, labelY); + } + } + + if (dividerColor !== 'transparent') { + context.strokeStyle = dividerColor; + context.lineWidth = dividerWidth; + + context.beginPath(); + + for (let col = 1; col < columns; col++) { + const dividerX = effectivePadding + col * (imageWidth + effectivePadding) - effectivePadding / 2; + context.moveTo(dividerX, effectivePadding); + context.lineTo(dividerX, canvas.height - effectivePadding); + } + + for (let row = 1; row < rows; row++) { + const dividerY = + effectivePadding + row * (imageHeight + effectiveLabelHeight + effectivePadding) - effectivePadding / 2; + context.moveTo(effectivePadding, dividerY); + context.lineTo(canvas.width - effectivePadding, dividerY); + } + + context.stroke(); + } + + const outputFormat = 'image/webp'; + const outputQuality = 0.75; + + const blob = await new Promise((resolve) => { + canvas.toBlob( + (result) => { + resolve(result ?? undefined); + }, + outputFormat, + outputQuality, + ); + }); + + if (!blob) { + throw new Error('Failed to create blob from composite canvas'); + } + + const compositeDataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener('load', () => { + resolve(reader.result as string); + }); + reader.addEventListener('error', reject); + reader.readAsDataURL(blob); + }); + + return compositeDataUrl; +} diff --git a/packages/three/src/screenshot/index.ts b/packages/three/src/screenshot/index.ts new file mode 100644 index 000000000..1f3f8e0f3 --- /dev/null +++ b/packages/three/src/screenshot/index.ts @@ -0,0 +1,8 @@ +export { captureScreenshot } from '#screenshot/capture-screenshot.js'; +export { createCompositeImage, calculateOptimalGrid } from '#screenshot/create-composite-image.js'; +export type { + CameraAngle, + CompositeScreenshotOptions, + ScreenshotOptions, + ScreenshotOutputOptions, +} from '#screenshot/types.js'; diff --git a/packages/three/src/screenshot/types.ts b/packages/three/src/screenshot/types.ts new file mode 100644 index 000000000..15bcda2a7 --- /dev/null +++ b/packages/three/src/screenshot/types.ts @@ -0,0 +1,52 @@ +/** Camera angle configuration for screenshots. */ +export type CameraAngle = { + /** Theta angle of the camera (angle from the XZ plane). */ + theta?: number; + /** Phi angle of the camera (angle from the XY plane). */ + phi?: number; + /** Human-readable label for this camera angle. */ + label?: string; +}; + +/** Output format settings for screenshots. */ +export type ScreenshotOutputOptions = { + /** File format for the output image. */ + format?: 'image/png' | 'image/jpeg' | 'image/webp'; + /** Quality level for lossy formats (0.0 to 1.0). Only applies to jpeg and webp. */ + quality?: number; + /** + * Whether to screenshot the scene as a preview. + * When true, objects marked with `isPreviewOnly` userData will be hidden. + */ + isPreview?: boolean; +}; + +/** Options for configuring composite (grid) image creation. */ +export type CompositeScreenshotOptions = { + enabled: boolean; + maxColumns?: number; + maxRows?: number; + preferredRatio?: { columns: number; rows: number }; + padding?: number; + labelHeight?: number; + showLabels?: boolean; + backgroundColor?: string | 'transparent'; + dividerColor?: string | 'transparent'; + dividerWidth?: number; +}; + +/** Options for configuring screenshot capture. */ +export type ScreenshotOptions = { + /** Aspect ratio of the screenshot (width/height). */ + aspectRatio?: number; + /** Maximum resolution (largest dimension) for the screenshot in pixels. */ + maxResolution?: number; + /** Zoom level multiplier (1.0 = no change). */ + zoomLevel?: number; + /** Array of camera angles to capture. Each angle produces a separate image. */ + cameraAngles?: CameraAngle[]; + /** Output format settings. */ + output?: ScreenshotOutputOptions; + /** Composite image settings for multi-angle captures. */ + composite?: CompositeScreenshotOptions; +}; From fee1cf304143fe8b6e713ab65893db98fe4a761d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 25 Feb 2026 10:32:08 +0000 Subject: [PATCH 06/14] feat(three): add zustand stores for viewer, measure, and section view Create standalone zustand stores to replace the xstate graphics machine for the @taucad/three package: - viewer-store: viewer configuration (grid, axes, matcap, FOV, theme, etc.) - measure-store: measurement state with start/complete/pin workflow - section-view-store: section plane selection, rotation, and direction - store-context: React context provider with typed selector hooks - index barrel export with all stores and types All stores use zustand/vanilla for framework-agnostic creation and zustand's useStore hook for React integration. Co-authored-by: richard --- packages/three/src/react/index.ts | 27 +++++ packages/three/src/react/stores/index.ts | 22 ++++ .../three/src/react/stores/measure-store.ts | 114 ++++++++++++++++++ .../src/react/stores/section-view-store.ts | 72 +++++++++++ .../three/src/react/stores/store-context.tsx | 79 ++++++++++++ .../three/src/react/stores/viewer-store.ts | 85 +++++++++++++ 6 files changed, 399 insertions(+) create mode 100644 packages/three/src/react/stores/index.ts create mode 100644 packages/three/src/react/stores/measure-store.ts create mode 100644 packages/three/src/react/stores/section-view-store.ts create mode 100644 packages/three/src/react/stores/store-context.tsx create mode 100644 packages/three/src/react/stores/viewer-store.ts diff --git a/packages/three/src/react/index.ts b/packages/three/src/react/index.ts index dbf778b42..453acbd99 100644 --- a/packages/three/src/react/index.ts +++ b/packages/three/src/react/index.ts @@ -14,3 +14,30 @@ export { type UpDirection, } from './section-view-controls.js'; export { TransformControls, type TransformControlsProps } from './transform-controls-drei.js'; + +// Zustand stores +export { + createViewerStore, + createMeasureStore, + createSectionViewStore, + CadStoreProvider, + CadStoreContext, + useViewerStore, + useMeasureStore, + useSectionViewStore, +} from './stores/index.js'; +export type { + ViewerStore, + ViewerStoreOptions, + ViewerState, + Measurement, + MeasureUnits, + MeasureStore, + MeasureStoreOptions, + MeasureState, + AvailableSectionView, + SectionViewStore, + SectionViewStoreOptions, + SectionViewState, + CadStores, +} from './stores/index.js'; diff --git a/packages/three/src/react/stores/index.ts b/packages/three/src/react/stores/index.ts new file mode 100644 index 000000000..7513ce964 --- /dev/null +++ b/packages/three/src/react/stores/index.ts @@ -0,0 +1,22 @@ +export { createViewerStore } from './viewer-store.js'; +export type { ViewerStore, ViewerStoreOptions, ViewerState } from './viewer-store.js'; + +export { createMeasureStore } from './measure-store.js'; +export type { Measurement, MeasureUnits, MeasureStore, MeasureStoreOptions, MeasureState } from './measure-store.js'; + +export { createSectionViewStore } from './section-view-store.js'; +export type { + AvailableSectionView, + SectionViewStore, + SectionViewStoreOptions, + SectionViewState, +} from './section-view-store.js'; + +export { + CadStoreProvider, + CadStoreContext, + useViewerStore, + useMeasureStore, + useSectionViewStore, +} from './store-context.js'; +export type { CadStores } from './store-context.js'; diff --git a/packages/three/src/react/stores/measure-store.ts b/packages/three/src/react/stores/measure-store.ts new file mode 100644 index 000000000..7289fec6a --- /dev/null +++ b/packages/three/src/react/stores/measure-store.ts @@ -0,0 +1,114 @@ +import { createStore } from 'zustand/vanilla'; + +type Measurement = { + id: string; + startPoint: [number, number, number]; + endPoint: [number, number, number]; + distance: number; + name: string; + isPinned: boolean; +}; + +type MeasureUnits = { + factor: number; + symbol: string; +}; + +type MeasureStoreOptions = { + snapDistance?: number; + units?: MeasureUnits; +}; + +type MeasureState = { + isActive: boolean; + measurements: Measurement[]; + currentStart: [number, number, number] | undefined; + snapDistance: number; + hoveredMeasurementId: string | undefined; + units: MeasureUnits; + setActive: (active: boolean) => void; + startMeasurement: (point: [number, number, number]) => void; + completeMeasurement: (endPoint: [number, number, number]) => void; + cancelMeasurement: () => void; + clearMeasurement: (id: string) => void; + clearAll: () => void; + clearUnpinned: () => void; + togglePinned: (id: string) => void; + setHoveredMeasurement: (id: string | undefined) => void; + setMeasurementName: (id: string, name: string) => void; + setUnits: (units: MeasureUnits) => void; + setSnapDistance: (distance: number) => void; +}; + +function computeDistance(start: [number, number, number], end: [number, number, number]): number { + const dx = end[0] - start[0]; + const dy = end[1] - start[1]; + const dz = end[2] - start[2]; + return Math.sqrt(dx * dx + dy * dy + dz * dz); +} + +export function createMeasureStore(options?: MeasureStoreOptions) { + return createStore((set) => ({ + isActive: false, + measurements: [], + currentStart: undefined, + snapDistance: options?.snapDistance ?? 0.1, + hoveredMeasurementId: undefined, + units: options?.units ?? { factor: 1, symbol: 'mm' }, + setActive: (active) => { set({ isActive: active }); }, + startMeasurement: (point) => { set({ currentStart: point }); }, + completeMeasurement: (endPoint) => { + set((state) => { + if (!state.currentStart) { + return state; + } + + const measurement: Measurement = { + id: crypto.randomUUID(), + startPoint: state.currentStart, + endPoint, + distance: computeDistance(state.currentStart, endPoint), + name: `Measurement ${String(state.measurements.length + 1)}`, + isPinned: false, + }; + + return { + measurements: [...state.measurements, measurement], + currentStart: undefined, + }; + }); + }, + cancelMeasurement: () => { set({ currentStart: undefined }); }, + clearMeasurement: (id) => { + set((state) => ({ + measurements: state.measurements.filter((m) => m.id !== id), + })); + }, + clearAll: () => { set({ measurements: [], currentStart: undefined }); }, + clearUnpinned: () => { + set((state) => ({ + measurements: state.measurements.filter((m) => m.isPinned), + })); + }, + togglePinned: (id) => { + set((state) => ({ + measurements: state.measurements.map((m) => + m.id === id ? { ...m, isPinned: !m.isPinned } : m, + ), + })); + }, + setHoveredMeasurement: (id) => { set({ hoveredMeasurementId: id }); }, + setMeasurementName: (id, name) => { + set((state) => ({ + measurements: state.measurements.map((m) => + m.id === id ? { ...m, name } : m, + ), + })); + }, + setUnits: (units) => { set({ units }); }, + setSnapDistance: (distance) => { set({ snapDistance: distance }); }, + })); +} + +export type { Measurement, MeasureUnits, MeasureStoreOptions, MeasureState }; +export type MeasureStore = ReturnType; diff --git a/packages/three/src/react/stores/section-view-store.ts b/packages/three/src/react/stores/section-view-store.ts new file mode 100644 index 000000000..9435ad98d --- /dev/null +++ b/packages/three/src/react/stores/section-view-store.ts @@ -0,0 +1,72 @@ +import { createStore } from 'zustand/vanilla'; + +type AvailableSectionView = { + id: 'xy' | 'xz' | 'yz'; + normal: [number, number, number]; + constant: number; +}; + +type SectionViewStoreOptions = { + enableLines?: boolean; + enableMesh?: boolean; + availableSectionViews?: AvailableSectionView[]; +}; + +type SectionViewState = { + isActive: boolean; + selectedPlaneId: 'xy' | 'xz' | 'yz' | undefined; + rotation: [number, number, number]; + direction: 1 | -1; + pivot: [number, number, number]; + enableLines: boolean; + enableMesh: boolean; + hoveredSectionViewId: string | undefined; + planeName: 'cartesian' | 'face'; + availableSectionViews: AvailableSectionView[]; + setActive: (active: boolean) => void; + selectPlane: (planeId: 'xy' | 'xz' | 'yz' | undefined) => void; + setRotation: (rotation: [number, number, number]) => void; + setPivot: (pivot: [number, number, number]) => void; + toggleDirection: () => void; + setDirection: (direction: 1 | -1) => void; + setHoveredSectionView: (id: string | undefined) => void; + setPlaneName: (name: 'cartesian' | 'face') => void; + setEnableLines: (enabled: boolean) => void; + setEnableMesh: (enabled: boolean) => void; +}; + +const DEFAULT_SECTION_VIEWS: AvailableSectionView[] = [ + { id: 'xy', normal: [0, 0, 1], constant: 0 }, + { id: 'xz', normal: [0, 1, 0], constant: 0 }, + { id: 'yz', normal: [1, 0, 0], constant: 0 }, +]; + +export function createSectionViewStore(options?: SectionViewStoreOptions) { + return createStore((set) => ({ + isActive: false, + selectedPlaneId: undefined, + rotation: [0, 0, 0], + direction: 1, + pivot: [0, 0, 0], + enableLines: options?.enableLines ?? true, + enableMesh: options?.enableMesh ?? true, + hoveredSectionViewId: undefined, + planeName: 'cartesian', + availableSectionViews: options?.availableSectionViews ?? DEFAULT_SECTION_VIEWS, + setActive: (active) => { set({ isActive: active }); }, + selectPlane: (planeId) => { set({ selectedPlaneId: planeId }); }, + setRotation: (rotation) => { set({ rotation }); }, + setPivot: (pivot) => { set({ pivot }); }, + toggleDirection: () => { + set((state) => ({ direction: state.direction === 1 ? -1 : 1 })); + }, + setDirection: (direction) => { set({ direction }); }, + setHoveredSectionView: (id) => { set({ hoveredSectionViewId: id }); }, + setPlaneName: (name) => { set({ planeName: name }); }, + setEnableLines: (enabled) => { set({ enableLines: enabled }); }, + setEnableMesh: (enabled) => { set({ enableMesh: enabled }); }, + })); +} + +export type { AvailableSectionView, SectionViewStoreOptions, SectionViewState }; +export type SectionViewStore = ReturnType; diff --git a/packages/three/src/react/stores/store-context.tsx b/packages/three/src/react/stores/store-context.tsx new file mode 100644 index 000000000..81c6b2850 --- /dev/null +++ b/packages/three/src/react/stores/store-context.tsx @@ -0,0 +1,79 @@ +import React, { createContext, useContext, useRef } from 'react'; +import { useStore } from 'zustand'; +import { createViewerStore } from './viewer-store.js'; +import { createMeasureStore } from './measure-store.js'; +import { createSectionViewStore } from './section-view-store.js'; +import type { ViewerStore, ViewerStoreOptions, ViewerState } from './viewer-store.js'; +import type { MeasureStore, MeasureStoreOptions, MeasureState } from './measure-store.js'; +import type { SectionViewStore, SectionViewStoreOptions, SectionViewState } from './section-view-store.js'; + +type CadStores = { + viewerStore: ViewerStore; + measureStore: MeasureStore; + sectionViewStore: SectionViewStore; +}; + +const CadStoreContext = createContext(undefined); + +type CadStoreProviderProperties = { + readonly children: React.ReactNode; + readonly viewerStore?: ViewerStore; + readonly measureStore?: MeasureStore; + readonly sectionViewStore?: SectionViewStore; + readonly viewerOptions?: ViewerStoreOptions; + readonly measureOptions?: MeasureStoreOptions; + readonly sectionViewOptions?: SectionViewStoreOptions; +}; + +export function CadStoreProvider({ + children, + viewerStore, + measureStore, + sectionViewStore, + viewerOptions, + measureOptions, + sectionViewOptions, +}: CadStoreProviderProperties): React.JSX.Element { + const storesRef = useRef(undefined); + + if (!storesRef.current) { + storesRef.current = { + viewerStore: viewerStore ?? createViewerStore(viewerOptions), + measureStore: measureStore ?? createMeasureStore(measureOptions), + sectionViewStore: sectionViewStore ?? createSectionViewStore(sectionViewOptions), + }; + } + + return ( + + {children} + + ); +} + +function useCadStores(): CadStores { + const stores = useContext(CadStoreContext); + if (!stores) { + throw new Error('useCadStores must be used within a CadStoreProvider'); + } + + return stores; +} + +export function useViewerStore(selector: (state: ViewerState) => T): T { + const { viewerStore } = useCadStores(); + return useStore(viewerStore, selector); +} + +export function useMeasureStore(selector: (state: MeasureState) => T): T { + const { measureStore } = useCadStores(); + return useStore(measureStore, selector); +} + +export function useSectionViewStore(selector: (state: SectionViewState) => T): T { + const { sectionViewStore } = useCadStores(); + return useStore(sectionViewStore, selector); +} + +export { CadStoreContext }; +export type { CadStores }; diff --git a/packages/three/src/react/stores/viewer-store.ts b/packages/three/src/react/stores/viewer-store.ts new file mode 100644 index 000000000..e31b9d9d1 --- /dev/null +++ b/packages/three/src/react/stores/viewer-store.ts @@ -0,0 +1,85 @@ +import { createStore } from 'zustand/vanilla'; + +type ViewerStoreOptions = { + enableGrid?: boolean; + enableAxes?: boolean; + enableMatcap?: boolean; + enablePostProcessing?: boolean; + enableSurfaces?: boolean; + enableLines?: boolean; + enableGizmo?: boolean; + fieldOfView?: number; + upDirection?: 'x' | 'y' | 'z'; + environmentPreset?: 'studio' | 'neutral' | 'soft' | 'performance'; + gridSizes?: { smallSize: number; largeSize: number }; + theme?: 'light' | 'dark'; + accentColor?: string; + sceneRadius?: number; +}; + +type ViewerState = { + enableGrid: boolean; + enableAxes: boolean; + enableMatcap: boolean; + enablePostProcessing: boolean; + enableSurfaces: boolean; + enableLines: boolean; + enableGizmo: boolean; + fieldOfView: number; + upDirection: 'x' | 'y' | 'z'; + environmentPreset: 'studio' | 'neutral' | 'soft' | 'performance'; + gridSizes: { smallSize: number; largeSize: number }; + theme: 'light' | 'dark'; + accentColor: string; + sceneRadius: number; + setFieldOfView: (angle: number) => void; + setUpDirection: (direction: 'x' | 'y' | 'z') => void; + setGridSizes: (sizes: { smallSize: number; largeSize: number }) => void; + setTheme: (theme: 'light' | 'dark') => void; + setAccentColor: (color: string) => void; + setSceneRadius: (radius: number) => void; + setEnableGrid: (enabled: boolean) => void; + setEnableAxes: (enabled: boolean) => void; + setEnableMatcap: (enabled: boolean) => void; + setEnablePostProcessing: (enabled: boolean) => void; + setEnableSurfaces: (enabled: boolean) => void; + setEnableLines: (enabled: boolean) => void; + setEnableGizmo: (enabled: boolean) => void; + setEnvironmentPreset: (preset: 'studio' | 'neutral' | 'soft' | 'performance') => void; +}; + +export function createViewerStore(options?: ViewerStoreOptions) { + return createStore((set) => ({ + enableGrid: options?.enableGrid ?? false, + enableAxes: options?.enableAxes ?? false, + enableMatcap: options?.enableMatcap ?? false, + enablePostProcessing: options?.enablePostProcessing ?? false, + enableSurfaces: options?.enableSurfaces ?? true, + enableLines: options?.enableLines ?? true, + enableGizmo: options?.enableGizmo ?? false, + fieldOfView: options?.fieldOfView ?? 50, + upDirection: options?.upDirection ?? 'z', + environmentPreset: options?.environmentPreset ?? 'studio', + gridSizes: options?.gridSizes ?? { smallSize: 10, largeSize: 100 }, + theme: options?.theme ?? 'light', + accentColor: options?.accentColor ?? '#3b82f6', + sceneRadius: options?.sceneRadius ?? 0, + setFieldOfView: (angle) => { set({ fieldOfView: angle }); }, + setUpDirection: (direction) => { set({ upDirection: direction }); }, + setGridSizes: (sizes) => { set({ gridSizes: sizes }); }, + setTheme: (theme) => { set({ theme }); }, + setAccentColor: (color) => { set({ accentColor: color }); }, + setSceneRadius: (radius) => { set({ sceneRadius: radius }); }, + setEnableGrid: (enabled) => { set({ enableGrid: enabled }); }, + setEnableAxes: (enabled) => { set({ enableAxes: enabled }); }, + setEnableMatcap: (enabled) => { set({ enableMatcap: enabled }); }, + setEnablePostProcessing: (enabled) => { set({ enablePostProcessing: enabled }); }, + setEnableSurfaces: (enabled) => { set({ enableSurfaces: enabled }); }, + setEnableLines: (enabled) => { set({ enableLines: enabled }); }, + setEnableGizmo: (enabled) => { set({ enableGizmo: enabled }); }, + setEnvironmentPreset: (preset) => { set({ environmentPreset: preset }); }, + })); +} + +export type { ViewerStoreOptions, ViewerState }; +export type ViewerStore = ReturnType; From 8f43f6afe6143eaa7643f9609dc57c0e83faf951 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 25 Feb 2026 10:34:42 +0000 Subject: [PATCH 07/14] feat(three): add decoupled PostProcessing, Grid, and ViewportGizmoCube components Replace app-specific useGraphicsSelector, useTheme, and useColor hooks with zustand-based useViewerStore from the package's own store context. - post-processing.tsx: reads enablePostProcessing from viewer store - grid.tsx: reads gridSizes, upDirection, theme from viewer store - viewport-gizmo.tsx: reads fieldOfView, accentColor, theme from viewer store - All Three.js imports updated to use package-local # paths - Export all three components from react/index.ts Co-authored-by: richard --- packages/three/src/react/grid.tsx | 31 ++++ packages/three/src/react/index.ts | 3 + packages/three/src/react/post-processing.tsx | 39 ++++ packages/three/src/react/viewport-gizmo.tsx | 182 +++++++++++++++++++ 4 files changed, 255 insertions(+) create mode 100644 packages/three/src/react/grid.tsx create mode 100644 packages/three/src/react/post-processing.tsx create mode 100644 packages/three/src/react/viewport-gizmo.tsx diff --git a/packages/three/src/react/grid.tsx b/packages/three/src/react/grid.tsx new file mode 100644 index 000000000..b63016df0 --- /dev/null +++ b/packages/three/src/react/grid.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import * as THREE from 'three'; +import { InfiniteGrid } from '#react/infinite-grid.js'; +import { useViewerStore } from '#react/stores/store-context.js'; + +/** + * Grid component that renders the infinite grid using sizes from the viewer store + * and handles theme-aware color selection and coordinate system orientation. + */ +export const Grid = React.memo((): React.JSX.Element => { + const gridSizes = useViewerStore((state) => state.gridSizes); + const upDirection = useViewerStore((state) => state.upDirection); + const theme = useViewerStore((state) => state.theme); + + const gridColor = React.useMemo( + () => (theme === 'light' ? new THREE.Color('lightgrey') : new THREE.Color('grey')), + [theme], + ); + + // x: X-up (1,0,0) -> grid on YZ plane -> 'zyx' + // y: Y-up (0,1,0) -> grid on XZ plane -> 'xzy' + // z: Z-up (0,0,1) -> grid on XY plane -> 'xyz' + const axes = upDirection === 'x' ? ('zyx' as const) : upDirection === 'y' ? ('xzy' as const) : ('xyz' as const); + + const materialProperties = React.useMemo( + () => ({ smallSize: gridSizes.smallSize, largeSize: gridSizes.largeSize, color: gridColor }), + [gridSizes.smallSize, gridSizes.largeSize, gridColor], + ); + + return ; +}); diff --git a/packages/three/src/react/index.ts b/packages/three/src/react/index.ts index 453acbd99..d1622faad 100644 --- a/packages/three/src/react/index.ts +++ b/packages/three/src/react/index.ts @@ -14,6 +14,9 @@ export { type UpDirection, } from './section-view-controls.js'; export { TransformControls, type TransformControlsProps } from './transform-controls-drei.js'; +export { PostProcessing } from './post-processing.js'; +export { Grid } from './grid.js'; +export { ViewportGizmoCube } from './viewport-gizmo.js'; // Zustand stores export { diff --git a/packages/three/src/react/post-processing.tsx b/packages/three/src/react/post-processing.tsx new file mode 100644 index 000000000..819d14dbe --- /dev/null +++ b/packages/three/src/react/post-processing.tsx @@ -0,0 +1,39 @@ +import { EffectComposer, N8AO } from '@react-three/postprocessing'; +import { useViewerStore } from '#react/stores/store-context.js'; + +/** + * Conditionally renders the EffectComposer with N8AO ambient occlusion. + * When disabled, the EffectComposer unmounts and SceneOverlay auto-adapts + * to render the full scene itself via `state.internal.priority` detection. + * + * N8AO is configured with `screenSpaceRadius={true}`, which means `aoRadius` + * is measured in **pixels** (not world units). This makes the ambient occlusion + * effect scale-independent -- models of any size receive visually consistent AO + * without needing access to `sceneRadius`. If `screenSpaceRadius` were `false`, + * `aoRadius` would need to be proportional to the scene bounding sphere radius + * (typically 1-2 orders of magnitude smaller than the scene scale). + * + * `distanceFalloff` is set to `0` to disable distance-based AO attenuation. + * N8AO reconstructs screen-space normals from the depth buffer, but its gradient + * selection logic (`dl < dr` comparison in `computeNormal`) operates in raw depth + * space without compensating for the logarithmic depth buffer encoding. This causes + * the left/right normal gradient to flip at certain depth contours on smooth curved + * surfaces, producing visible contour-line artifacts. The `distanceFalloff` parameter + * amplifies these errors because its distance weighting relies on the same imprecise + * depth reconstruction. Setting it to `0` bypasses that code path entirely. For + * single-body CAD geometry this has negligible visual impact -- AO still darkens + * corners and crevices correctly via angular occlusion alone. + */ +export function PostProcessing(): React.JSX.Element | undefined { + const enablePostProcessing = useViewerStore((state) => state.enablePostProcessing); + + if (!enablePostProcessing) { + return undefined; + } + + return ( + + + + ); +} diff --git a/packages/three/src/react/viewport-gizmo.tsx b/packages/three/src/react/viewport-gizmo.tsx new file mode 100644 index 000000000..8ce22ff8b --- /dev/null +++ b/packages/three/src/react/viewport-gizmo.tsx @@ -0,0 +1,182 @@ +/* eslint-disable @typescript-eslint/no-unnecessary-condition -- TODO: review these types, some are actually required */ +import { useThree, useFrame } from '@react-three/fiber'; +import type { GizmoAxisOptions, GizmoOptions } from 'three-viewport-gizmo'; +import { ViewportGizmo } from 'three-viewport-gizmo'; +import { useEffect, useCallback, useRef } from 'react'; +import * as THREE from 'three'; +import type { OrbitControls } from 'three/addons'; +import type { ReactNode } from 'react'; +import { createViewportGizmoCubeAxes } from '#controls/viewport-gizmo-cube-axes.js'; +import { useViewerStore } from '#react/stores/store-context.js'; +import { + syncGizmoFov, + resolveGizmoContainer, + createGizmoCanvas, + createGizmoRenderer, + disposeGizmoResources, +} from '#utils/gizmo.utils.js'; + +type ViewportGizmoProperties = { + readonly size?: number; + /** + * A container element or selector to append the gizmo to. + * + * When provided, the gizmo will be appended to this container instead of the renderer's parent. + */ + readonly container?: HTMLElement | string; + /** + * Optional dependencies array that will be appended to the effect dependencies. + * When any of these values change, the gizmo will be disposed and recreated. + * Useful for triggering recreation when coordinate systems or other external state changes. + * + * @example + * ```tsx + * + * ``` + */ + readonly dependencies?: readonly unknown[]; +}; + +const className = 'viewport-gizmo-cube'; +const emptyDependencies: readonly unknown[] = []; + +export function ViewportGizmoCube({ + size = 96, + container, + dependencies = emptyDependencies, +}: ViewportGizmoProperties): ReactNode { + const camera = useThree((state) => state.camera) as THREE.PerspectiveCamera; + const gl = useThree((state) => state.gl); + const controls = useThree((state) => state.controls) as OrbitControls; + const scene = useThree((state) => state.scene); + const invalidate = useThree((state) => state.invalidate); + + const accentColor = useViewerStore((state) => state.accentColor); + const theme = useViewerStore((state) => state.theme); + const fieldOfView = useViewerStore((state) => state.fieldOfView); + + const fieldOfViewRef = useRef(fieldOfView); + fieldOfViewRef.current = fieldOfView; + + // eslint-disable-next-line @typescript-eslint/no-restricted-types -- React ref + const gizmoRef = useRef(null); + // eslint-disable-next-line @typescript-eslint/no-restricted-types -- React ref + const rendererRef = useRef(null); + + const handleChange = useCallback((): void => { + invalidate(); + }, [invalidate]); + + useEffect(() => { + if (!camera || !gl || !controls) { + return; + } + + const canvas = createGizmoCanvas(className); + + const containerToUse = resolveGizmoContainer(container, gl.domElement); + if (!containerToUse) { + return; + } + + containerToUse.append(canvas); + + const renderer = createGizmoRenderer(canvas, size); + + const faceConfig = { + color: theme === 'dark' ? 0x33_33_33 : 0xdd_dd_dd, + labelColor: theme === 'dark' ? 0xff_ff_ff : 0x00_00_00, + hover: { + color: accentColor, + }, + } as const satisfies GizmoAxisOptions; + const edgeConfig = { + color: theme === 'dark' ? 0x55_55_55 : 0xee_ee_ee, + opacity: 1, + hover: { + color: accentColor, + }, + } as const satisfies GizmoAxisOptions; + const cornerConfig = { + ...faceConfig, + color: theme === 'dark' ? 0x33_33_33 : 0xdd_dd_dd, + hover: { + color: accentColor, + }, + } as const satisfies GizmoAxisOptions; + + const gizmoConfig: GizmoOptions = { + type: 'rounded-cube', + placement: 'bottom-right', + size, + font: { + weight: 'normal', + family: 'monospace', + }, + radius: 0.3, + offset: { + bottom: 0, + right: 0, + }, + className, + resolution: 256, + container: containerToUse, + corners: cornerConfig, + edges: edgeConfig, + right: faceConfig, + top: faceConfig, + front: faceConfig, + back: faceConfig, + left: faceConfig, + bottom: faceConfig, + }; + + const gizmo = new ViewportGizmo(camera, renderer, gizmoConfig); + gizmoRef.current = gizmo; + rendererRef.current = renderer; + + syncGizmoFov(gizmo, fieldOfViewRef.current); + + gizmo.addEventListener('change', handleChange); + + gizmo.scale.multiplyScalar(0.7); + gizmo.add( + createViewportGizmoCubeAxes({ + axesSize: 2.1, + rendererSize: size, + xAxisColor: 'red', + yAxisColor: 'green', + zAxisColor: 'rgb(37, 78, 136)', + xLabelColor: 'red', + yLabelColor: 'green', + zLabelColor: 'rgb(37, 78, 136)', + lineWidth: 2, + }), + ); + + gizmo.attachControls(controls); + + return () => { + gizmoRef.current = null; + rendererRef.current = null; + + disposeGizmoResources({ gizmo, renderer, canvas, handleChange }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- dependencies array is user-provided for custom recreation triggers + }, [camera, gl, controls, scene, accentColor, theme, size, handleChange, container, ...dependencies]); + + useFrame(() => { + if (rendererRef.current && gizmoRef.current) { + rendererRef.current.toneMapping = THREE.NoToneMapping; + gizmoRef.current.render(); + } + }); + + useEffect(() => { + if (gizmoRef.current) { + syncGizmoFov(gizmoRef.current, fieldOfView); + } + }, [fieldOfView]); + + return null; +} From 6284b9b6acc381ee1a1d0d992b3ee177b0e9ca7d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 25 Feb 2026 10:36:56 +0000 Subject: [PATCH 08/14] decouple three package react components from app-specific hooks - Replace useGraphicsSelector with useViewerStore in use-camera-framing.ts - Remove useCameraCapability from use-camera-reset.ts, accept onResetCamera callback - Create use-geometry-bounds.ts using zustand viewer store - Create up-direction-handler.tsx with onResetCamera callback prop - Create use-section-view.ts using useSectionViewStore and useViewerStore - Convert stage.ts to stage.tsx with full Stage component using zustand stores - Delete stub hooks/use-graphics.ts - Export new components and hooks from react/index.ts Co-authored-by: richard --- packages/three/package.json | 71 + packages/three/project.json | 8 + .../three/src/controls/transform-controls.ts | 2096 +++++++++++++++++ .../src/controls/viewport-gizmo-cube-axes.ts | 133 ++ .../three/src/geometries/circle-geometry.ts | 27 + .../three/src/geometries/font-geometry.ts | 17 + .../src/geometries/geist-mono.typeface.json | 1 + .../three/src/geometries/label-geometry.ts | 45 + .../geometries/rounded-rectangle-geometry.ts | 61 + packages/three/src/geometries/svg-geometry.ts | 53 + packages/three/src/hooks/use-graphics.ts | 32 - .../three/src/icons/rotate-icon-single.svg | 14 + packages/three/src/icons/rotate-icon.svg | 11 + packages/three/src/icons/rotation-arrow.svg | 3 + .../three/src/icons/translation-arrow.svg | 5 + packages/three/src/materials/gltf-edges.ts | 380 +++ packages/three/src/materials/gltf-matcap.ts | 132 ++ .../src/materials/infinite-grid-material.ts | 348 +++ .../three/src/materials/matcap-material.ts | 42 + .../three/src/materials/striped-material.ts | 133 ++ .../react/hooks/use-camera-framing.test.ts | 4 + .../src/react/hooks/use-camera-framing.ts | 4 +- .../src/react/hooks/use-geometry-bounds.ts | 113 + .../three/src/react/hooks/use-section-view.ts | 92 + packages/three/src/react/index.ts | 6 + packages/three/src/react/stage.ts | 47 - packages/three/src/react/stage.tsx | 114 + .../three/src/react/up-direction-handler.tsx | 50 + packages/three/src/react/use-camera-reset.ts | 29 +- packages/three/src/vite-env.d.ts | 4 + packages/three/tsconfig.build.json | 7 + packages/three/tsconfig.json | 13 + packages/three/tsconfig.lib.json | 17 + packages/three/tsconfig.spec.json | 25 + packages/three/tsdown.config.ts | 39 + packages/three/vitest.config.ts | 23 + 36 files changed, 4096 insertions(+), 103 deletions(-) create mode 100644 packages/three/package.json create mode 100644 packages/three/project.json create mode 100644 packages/three/src/controls/transform-controls.ts create mode 100644 packages/three/src/controls/viewport-gizmo-cube-axes.ts create mode 100644 packages/three/src/geometries/circle-geometry.ts create mode 100644 packages/three/src/geometries/font-geometry.ts create mode 100644 packages/three/src/geometries/geist-mono.typeface.json create mode 100644 packages/three/src/geometries/label-geometry.ts create mode 100644 packages/three/src/geometries/rounded-rectangle-geometry.ts create mode 100644 packages/three/src/geometries/svg-geometry.ts delete mode 100644 packages/three/src/hooks/use-graphics.ts create mode 100644 packages/three/src/icons/rotate-icon-single.svg create mode 100644 packages/three/src/icons/rotate-icon.svg create mode 100644 packages/three/src/icons/rotation-arrow.svg create mode 100644 packages/three/src/icons/translation-arrow.svg create mode 100644 packages/three/src/materials/gltf-edges.ts create mode 100644 packages/three/src/materials/gltf-matcap.ts create mode 100644 packages/three/src/materials/infinite-grid-material.ts create mode 100644 packages/three/src/materials/matcap-material.ts create mode 100644 packages/three/src/materials/striped-material.ts create mode 100644 packages/three/src/react/hooks/use-geometry-bounds.ts create mode 100644 packages/three/src/react/hooks/use-section-view.ts delete mode 100644 packages/three/src/react/stage.ts create mode 100644 packages/three/src/react/stage.tsx create mode 100644 packages/three/src/react/up-direction-handler.tsx create mode 100644 packages/three/src/vite-env.d.ts create mode 100644 packages/three/tsconfig.build.json create mode 100644 packages/three/tsconfig.json create mode 100644 packages/three/tsconfig.lib.json create mode 100644 packages/three/tsconfig.spec.json create mode 100644 packages/three/tsdown.config.ts create mode 100644 packages/three/vitest.config.ts diff --git a/packages/three/package.json b/packages/three/package.json new file mode 100644 index 000000000..0cc13440e --- /dev/null +++ b/packages/three/package.json @@ -0,0 +1,71 @@ +{ + "name": "@taucad/three", + "version": "0.1.0", + "description": "CAD viewer components for Three.js and React Three Fiber", + "type": "module", + "private": false, + "repository": { + "type": "git", + "url": "git+https://github.com/taucad/tau.git", + "directory": "packages/three" + }, + "main": "./dist/cjs/index.cjs", + "types": "./dist/cjs/index.d.cts", + "module": "./dist/esm/index.js", + "files": [ + "dist", + "README.md" + ], + "keywords": [ + "cad", + "three", + "threejs", + "react-three-fiber", + "r3f", + "gltf", + "viewer", + "3d" + ], + "author": "Richard Fontein", + "license": "MIT", + "exports": { + ".": "./src/index.ts", + "./react": "./src/react/index.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "require": { "types": "./dist/cjs/index.d.cts", "default": "./dist/cjs/index.cjs" }, + "import": { "types": "./dist/esm/index.d.ts", "default": "./dist/esm/index.js" } + }, + "./react": { + "require": { "types": "./dist/cjs/react/index.d.cts", "default": "./dist/cjs/react/index.cjs" }, + "import": { "types": "./dist/esm/react/index.d.ts", "default": "./dist/esm/react/index.js" } + } + } + }, + "imports": { + "#*": "./src/*" + }, + "dependencies": { + "zustand": "^5.0.0", + "three-viewport-gizmo": "https://codeload.github.com/taucad/three-viewport-gizmo/tar.gz/d5ae66010e7f5f067931aaeb6a74880a73d23b5d" + }, + "peerDependencies": { + "react": ">=19.0.0", + "react-dom": ">=19.0.0", + "three": ">=0.170.0", + "@react-three/fiber": ">=9.0.0", + "@react-three/drei": ">=10.0.0", + "@react-three/postprocessing": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@react-three/postprocessing": { + "optional": true + } + }, + "devDependencies": { + "@types/three": "^0.178.1" + } +} diff --git a/packages/three/project.json b/packages/three/project.json new file mode 100644 index 000000000..a49fdad54 --- /dev/null +++ b/packages/three/project.json @@ -0,0 +1,8 @@ +{ + "name": "three", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/three", + "projectType": "library", + "tags": ["scope:shared", "type:lib"], + "targets": {} +} diff --git a/packages/three/src/controls/transform-controls.ts b/packages/three/src/controls/transform-controls.ts new file mode 100644 index 000000000..9b8ff2278 --- /dev/null +++ b/packages/three/src/controls/transform-controls.ts @@ -0,0 +1,2096 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call -- TODO: fix types here */ +/* eslint-disable max-lines -- This is a port of the original transform-controls.ts file */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment -- TODO: fix types here */ +/* eslint-disable @typescript-eslint/naming-convention -- This is a port of the original transform-controls.ts file */ +/* eslint-disable new-cap -- This is a port of the original transform-controls.ts file */ +/* eslint-disable @typescript-eslint/class-literal-property-style -- This is a port of the original transform-controls.ts file */ +/* eslint-disable max-depth -- This is a port of the original transform-controls.ts file */ +/* eslint-disable complexity -- This is a port of the original transform-controls.ts file */ +import type { OrthographicCamera, PerspectiveCamera, Intersection, Camera, Vector2 } from 'three'; +import { + Material, + BoxGeometry, + BufferGeometry, + Color, + CylinderGeometry, + DoubleSide, + Euler, + Float32BufferAttribute, + Line, + LineBasicMaterial, + Matrix4, + Mesh, + MeshBasicMaterial, + Object3D, + OctahedronGeometry, + PlaneGeometry, + Quaternion, + Raycaster, + SphereGeometry, + TorusGeometry, + Vector3, + MeshMatcapMaterial, + LineDashedMaterial, +} from 'three'; +import translationArrowSvg from '#icons/translation-arrow.svg?raw'; +import rotationArrowSvg from '#icons/rotation-arrow.svg?raw'; +import { SvgGeometry } from '#geometries/svg-geometry.js'; +import { matcapMaterial } from '#materials/matcap-material.js'; +import { FontGeometry } from '#geometries/font-geometry.js'; +import { RoundedRectangleGeometry } from '#geometries/rounded-rectangle-geometry.js'; +import { CircleGeometry } from '#geometries/circle-geometry.js'; + +export type TransformControlsPointerObject = { + x: number; + y: number; + button: number; +}; + +class TransformControls extends Object3D { + public readonly isTransformControls = true; + + public override visible = false; + + private domElement: HTMLElement | undefined; + + private readonly raycaster = new Raycaster(); + + private gizmo: TransformControlsGizmo; + private plane: TransformControlsPlane; + + private readonly tempVector = new Vector3(); + private readonly tempVector2 = new Vector3(); + private readonly tempQuaternion = new Quaternion(); + private readonly unit = { + X: new Vector3(1, 0, 0), + Y: new Vector3(0, 1, 0), + Z: new Vector3(0, 0, 1), + }; + + private readonly pointStart = new Vector3(); + private readonly pointEnd = new Vector3(); + private readonly offset = new Vector3(); + private readonly rotationAxis = new Vector3(); + private readonly startNorm = new Vector3(); + private readonly endNorm = new Vector3(); + private rotationAngle = 0; + + private readonly cameraPosition = new Vector3(); + private readonly cameraQuaternion = new Quaternion(); + private readonly cameraScale = new Vector3(); + + private readonly parentPosition = new Vector3(); + private readonly parentQuaternion = new Quaternion(); + private readonly parentQuaternionInv = new Quaternion(); + private readonly parentScale = new Vector3(); + + private readonly worldPositionStart = new Vector3(); + private readonly worldQuaternionStart = new Quaternion(); + private readonly worldScaleStart = new Vector3(); + + private readonly worldPosition = new Vector3(); + private readonly worldQuaternion = new Quaternion(); + private readonly worldQuaternionInv = new Quaternion(); + private readonly worldScale = new Vector3(); + + private readonly eye = new Vector3(); + + private readonly positionStart = new Vector3(); + private readonly quaternionStart = new Quaternion(); + private readonly scaleStart = new Vector3(); + + private readonly camera: TCamera; + private object: Object3D | undefined; + private readonly enabled: boolean = true; + private axis: string | undefined = undefined; + private mode: 'translate' | 'rotate' | 'scale' = 'translate'; + private translationSnap: number | undefined = undefined; + private rotationSnap: number | undefined = undefined; + private scaleSnap: number | undefined = undefined; + private space = 'world'; + private size = 1; + private dragging = false; + private readonly showX = true; + private readonly showY = true; + private readonly showZ = true; + + // Events + private readonly changeEvent = { type: 'change' }; + private readonly pointerDownEvent = { type: 'pointerDown', mode: this.mode }; + private readonly pointerUpEvent = { type: 'pointerUp', mode: this.mode }; + private readonly objectChangeEvent = { type: 'objectChange' }; + + public constructor(camera: TCamera, domElement: HTMLElement | undefined) { + super(); + + this.domElement = domElement; + this.camera = camera; + + this.gizmo = new TransformControlsGizmo(); + this.add(this.gizmo); + + this.plane = new TransformControlsPlane(); + this.add(this.plane); + + // Defined getter, setter and store for a property + const defineProperty = (propName: string, defaultValue: TValue): void => { + let propValue = defaultValue; + + Object.defineProperty(this, propName, { + get() { + return propValue ?? defaultValue; + }, + + set(value) { + if (propValue !== value) { + propValue = value; + this.plane[propName] = value; + this.gizmo[propName] = value; + + this.dispatchEvent({ type: propName + '-changed', value }); + this.dispatchEvent(this.changeEvent); + } + }, + }); + + // @ts-expect-error -- custom controls event, needs augmentation + this[propName] = defaultValue; + // @ts-expect-error -- custom controls event, needs augmentation + this.plane[propName] = defaultValue; + // @ts-expect-error -- custom controls event, needs augmentation + this.gizmo[propName] = defaultValue; + }; + + defineProperty('camera', this.camera); + defineProperty('object', this.object); + defineProperty('enabled', this.enabled); + defineProperty('axis', this.axis); + defineProperty('mode', this.mode); + defineProperty('translationSnap', this.translationSnap); + defineProperty('rotationSnap', this.rotationSnap); + defineProperty('scaleSnap', this.scaleSnap); + defineProperty('space', this.space); + defineProperty('size', this.size); + defineProperty('dragging', this.dragging); + defineProperty('showX', this.showX); + defineProperty('showY', this.showY); + defineProperty('showZ', this.showZ); + defineProperty('worldPosition', this.worldPosition); + defineProperty('worldPositionStart', this.worldPositionStart); + defineProperty('worldQuaternion', this.worldQuaternion); + defineProperty('worldQuaternionStart', this.worldQuaternionStart); + defineProperty('cameraPosition', this.cameraPosition); + defineProperty('cameraQuaternion', this.cameraQuaternion); + defineProperty('pointStart', this.pointStart); + defineProperty('pointEnd', this.pointEnd); + defineProperty('rotationAxis', this.rotationAxis); + defineProperty('rotationAngle', this.rotationAngle); + defineProperty('eye', this.eye); + + // Connect events + if (domElement !== undefined) { + this.connect(domElement); + } + } + + // Set current object + public override attach = (object: Object3D): this => { + this.object = object; + this.visible = true; + + return this; + }; + + // Detatch from object + public detach = (): this => { + this.object = undefined; + this.visible = false; + this.axis = undefined; + + return this; + }; + + // Reset + public reset = (): this => { + if (!this.enabled) { + return this; + } + + if (this.dragging && this.object !== undefined) { + this.object.position.copy(this.positionStart); + this.object.quaternion.copy(this.quaternionStart); + this.object.scale.copy(this.scaleStart); + // @ts-expect-error -- custom controls event, needs augmentation + this.dispatchEvent(this.changeEvent); + // @ts-expect-error -- custom controls event, needs augmentation + this.dispatchEvent(this.objectChangeEvent); + this.pointStart.copy(this.pointEnd); + } + + return this; + }; + + public override updateMatrixWorld = (): void => { + if (this.object !== undefined) { + this.object.updateMatrixWorld(); + + if (this.object.parent === null) { + console.error('TransformControls: The attached 3D object must be a part of the scene graph.'); + } else { + this.object.parent.matrixWorld.decompose(this.parentPosition, this.parentQuaternion, this.parentScale); + } + + this.object.matrixWorld.decompose(this.worldPosition, this.worldQuaternion, this.worldScale); + + this.parentQuaternionInv.copy(this.parentQuaternion).invert(); + this.worldQuaternionInv.copy(this.worldQuaternion).invert(); + } + + this.camera.updateMatrixWorld(); + this.camera.matrixWorld.decompose(this.cameraPosition, this.cameraQuaternion, this.cameraScale); + + this.eye.copy(this.cameraPosition).sub(this.worldPosition).normalize(); + + super.updateMatrixWorld(); + }; + + public getMode = (): TransformControls['mode'] => this.mode; + + public setMode = (mode: TransformControls['mode']): void => { + this.mode = mode; + }; + + public setTranslationSnap = (translationSnap: number): void => { + this.translationSnap = translationSnap; + }; + + public setRotationSnap = (rotationSnap: number): void => { + this.rotationSnap = rotationSnap; + }; + + public setScaleSnap = (scaleSnap: number): void => { + this.scaleSnap = scaleSnap; + }; + + public setSize = (size: number): void => { + this.size = size; + }; + + public setSpace = (space: string): void => { + this.space = space; + }; + + public update = (): void => { + console.warn( + 'THREE.TransformControls: update function has no more functionality and therefore has been deprecated.', + ); + }; + + public connect = (domElement: HTMLElement): void => { + if ((domElement as unknown) === document) { + console.error( + 'THREE.OrbitControls: "document" should not be used as the target "domElement". Please use "renderer.domElement" instead.', + ); + } + + this.domElement = domElement; + + this.domElement.addEventListener('pointerdown', this.onPointerDown); + this.domElement.addEventListener('pointermove', this.onPointerHover); + this.domElement.ownerDocument.addEventListener('pointerup', this.onPointerUp); + }; + + public dispose = (): void => { + this.domElement?.removeEventListener('pointerdown', this.onPointerDown); + this.domElement?.removeEventListener('pointermove', this.onPointerHover); + this.domElement?.ownerDocument.removeEventListener('pointermove', this.onPointerMove); + this.domElement?.ownerDocument.removeEventListener('pointerup', this.onPointerUp); + + this.traverse((child) => { + const mesh = child as Mesh; + if (mesh.geometry instanceof BufferGeometry) { + mesh.geometry.dispose(); + } + + if (mesh.material instanceof Material) { + mesh.material.dispose(); + } + }); + }; + + private readonly intersectObjectWithRay = ( + object: Object3D, + raycaster: Raycaster, + includeInvisible?: boolean, + ): false | Intersection => { + const allIntersections = raycaster.intersectObject(object, true); + + for (const allIntersection of allIntersections) { + if (allIntersection.object.visible || includeInvisible) { + return allIntersection; + } + } + + return false; + }; + + private readonly pointerHover = (pointer: TransformControlsPointerObject): void => { + if (this.object === undefined || this.dragging) { + return; + } + + this.raycaster.setFromCamera(pointer as unknown as Vector2, this.camera); + + const intersect = this.intersectObjectWithRay(this.gizmo.picker[this.mode], this.raycaster); + + this.axis = intersect ? intersect.object.name : undefined; + }; + + private readonly pointerDown = (pointer: TransformControlsPointerObject): void => { + if (this.object === undefined || this.dragging || pointer.button !== 0) { + return; + } + + if (this.axis !== undefined) { + this.raycaster.setFromCamera(pointer as unknown as Vector2, this.camera); + + const planeIntersect = this.intersectObjectWithRay(this.plane, this.raycaster, true); + + if (planeIntersect) { + let { space } = this; + + if (this.mode === 'scale') { + space = 'local'; + } else if (this.axis === 'E' || this.axis === 'XYZE' || this.axis === 'XYZ') { + space = 'world'; + } + + if (space === 'local' && this.mode === 'rotate') { + const snap = this.rotationSnap; + + if (this.axis === 'X' && snap) { + this.object.rotation.x = Math.round(this.object.rotation.x / snap) * snap; + } + + if (this.axis === 'Y' && snap) { + this.object.rotation.y = Math.round(this.object.rotation.y / snap) * snap; + } + + if (this.axis === 'Z' && snap) { + this.object.rotation.z = Math.round(this.object.rotation.z / snap) * snap; + } + } + + this.object.updateMatrixWorld(); + + if (this.object.parent) { + this.object.parent.updateMatrixWorld(); + } + + this.positionStart.copy(this.object.position); + this.quaternionStart.copy(this.object.quaternion); + this.scaleStart.copy(this.object.scale); + + this.object.matrixWorld.decompose(this.worldPositionStart, this.worldQuaternionStart, this.worldScaleStart); + + this.pointStart.copy(planeIntersect.point).sub(this.worldPositionStart); + } + + this.dragging = true; + this.pointerDownEvent.mode = this.mode; + // @ts-expect-error -- custom controls event, needs augmentation + this.dispatchEvent(this.pointerDownEvent); + } + }; + + private readonly pointerMove = (pointer: TransformControlsPointerObject): void => { + const { axis } = this; + const { mode } = this; + const { object } = this; + let { space } = this; + + if (mode === 'scale') { + space = 'local'; + } else if (axis === 'E' || axis === 'XYZE' || axis === 'XYZ') { + space = 'world'; + } + + if (object === undefined || axis === undefined || !this.dragging || pointer.button !== -1) { + return; + } + + this.raycaster.setFromCamera(pointer as unknown as Vector2, this.camera); + + const planeIntersect = this.intersectObjectWithRay(this.plane, this.raycaster, true); + + if (!planeIntersect) { + return; + } + + this.pointEnd.copy(planeIntersect.point).sub(this.worldPositionStart); + + switch (mode) { + case 'translate': { + // Apply translate + + this.offset.copy(this.pointEnd).sub(this.pointStart); + + if (space === 'local' && axis !== 'XYZ') { + this.offset.applyQuaternion(this.worldQuaternionInv); + } + + if (!axis.includes('X')) { + this.offset.x = 0; + } + + if (!axis.includes('Y')) { + this.offset.y = 0; + } + + if (!axis.includes('Z')) { + this.offset.z = 0; + } + + if (space === 'local' && axis !== 'XYZ') { + this.offset.applyQuaternion(this.quaternionStart).divide(this.parentScale); + } else { + this.offset.applyQuaternion(this.parentQuaternionInv).divide(this.parentScale); + } + + object.position.copy(this.offset).add(this.positionStart); + + // Apply translation snap + + if (this.translationSnap) { + if (space === 'local') { + object.position.applyQuaternion(this.tempQuaternion.copy(this.quaternionStart).invert()); + + if (axis.search('X') !== -1) { + object.position.x = Math.round(object.position.x / this.translationSnap) * this.translationSnap; + } + + if (axis.search('Y') !== -1) { + object.position.y = Math.round(object.position.y / this.translationSnap) * this.translationSnap; + } + + if (axis.search('Z') !== -1) { + object.position.z = Math.round(object.position.z / this.translationSnap) * this.translationSnap; + } + + object.position.applyQuaternion(this.quaternionStart); + } + + if (space === 'world') { + if (object.parent) { + object.position.add(this.tempVector.setFromMatrixPosition(object.parent.matrixWorld)); + } + + if (axis.search('X') !== -1) { + object.position.x = Math.round(object.position.x / this.translationSnap) * this.translationSnap; + } + + if (axis.search('Y') !== -1) { + object.position.y = Math.round(object.position.y / this.translationSnap) * this.translationSnap; + } + + if (axis.search('Z') !== -1) { + object.position.z = Math.round(object.position.z / this.translationSnap) * this.translationSnap; + } + + if (object.parent) { + object.position.sub(this.tempVector.setFromMatrixPosition(object.parent.matrixWorld)); + } + } + } + + break; + } + + case 'scale': { + if (axis.search('XYZ') === -1) { + this.tempVector.copy(this.pointStart); + this.tempVector2.copy(this.pointEnd); + + this.tempVector.applyQuaternion(this.worldQuaternionInv); + this.tempVector2.applyQuaternion(this.worldQuaternionInv); + + this.tempVector2.divide(this.tempVector); + + if (axis.search('X') === -1) { + this.tempVector2.x = 1; + } + + if (axis.search('Y') === -1) { + this.tempVector2.y = 1; + } + + if (axis.search('Z') === -1) { + this.tempVector2.z = 1; + } + } else { + let d = this.pointEnd.length() / this.pointStart.length(); + + if (this.pointEnd.dot(this.pointStart) < 0) { + d *= -1; + } + + this.tempVector2.set(d, d, d); + } + + // Apply scale + + object.scale.copy(this.scaleStart).multiply(this.tempVector2); + + if (this.scaleSnap && this.object) { + if (axis.search('X') !== -1) { + this.object.scale.x = Math.round(object.scale.x / this.scaleSnap) * this.scaleSnap || this.scaleSnap; + } + + if (axis.search('Y') !== -1) { + object.scale.y = Math.round(object.scale.y / this.scaleSnap) * this.scaleSnap || this.scaleSnap; + } + + if (axis.search('Z') !== -1) { + object.scale.z = Math.round(object.scale.z / this.scaleSnap) * this.scaleSnap || this.scaleSnap; + } + } + + break; + } + + case 'rotate': { + this.offset.copy(this.pointEnd).sub(this.pointStart); + + // Normalize rotation sensitivity by camera distance AND perspective FOV so drag + // speed feels consistent across different FOV values. + const cameraDistance = this.worldPosition.distanceTo( + this.tempVector.setFromMatrixPosition(this.camera.matrixWorld), + ); + const cameraMaybePerspective = this.camera as unknown as PerspectiveCamera; + let fovFactor = 1; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- type guard + if (cameraMaybePerspective.isPerspectiveCamera) { + fovFactor = Math.tan((cameraMaybePerspective.fov * Math.PI) / 360); + if (!Number.isFinite(fovFactor) || fovFactor === 0) { + fovFactor = 1; + } + } + + const ROTATION_SPEED = 20 / (cameraDistance * fovFactor); + + switch (axis) { + case 'E': { + this.rotationAxis.copy(this.eye); + this.rotationAngle = this.pointEnd.angleTo(this.pointStart); + + this.startNorm.copy(this.pointStart).normalize(); + this.endNorm.copy(this.pointEnd).normalize(); + + this.rotationAngle *= this.endNorm.cross(this.startNorm).dot(this.eye) < 0 ? 1 : -1; + + break; + } + + case 'XYZE': { + this.rotationAxis.copy(this.offset).cross(this.eye).normalize(); + this.rotationAngle = + this.offset.dot(this.tempVector.copy(this.rotationAxis).cross(this.eye)) * ROTATION_SPEED; + + break; + } + + case 'X': + case 'Y': + case 'Z': { + this.rotationAxis.copy(this.unit[axis]); + + this.tempVector.copy(this.unit[axis]); + + if (space === 'local') { + this.tempVector.applyQuaternion(this.worldQuaternion); + } + + this.rotationAngle = this.offset.dot(this.tempVector.cross(this.eye).normalize()) * ROTATION_SPEED; + + break; + } + // No default + } + + // Apply rotation snap + + if (this.rotationSnap) { + this.rotationAngle = Math.round(this.rotationAngle / this.rotationSnap) * this.rotationSnap; + } + + // Apply rotate + if (space === 'local' && axis !== 'E' && axis !== 'XYZE') { + object.quaternion.copy(this.quaternionStart); + object.quaternion + .multiply(this.tempQuaternion.setFromAxisAngle(this.rotationAxis, this.rotationAngle)) + .normalize(); + } else { + this.rotationAxis.applyQuaternion(this.parentQuaternionInv); + object.quaternion.copy(this.tempQuaternion.setFromAxisAngle(this.rotationAxis, this.rotationAngle)); + object.quaternion.multiply(this.quaternionStart).normalize(); + } + + break; + } + // No default + } + + // @ts-expect-error -- custom controls event, needs augmentation + this.dispatchEvent(this.changeEvent); + // @ts-expect-error -- custom controls event, needs augmentation + this.dispatchEvent(this.objectChangeEvent); + }; + + private readonly pointerUp = (pointer: TransformControlsPointerObject): void => { + if (pointer.button !== 0) { + return; + } + + if (this.dragging && this.axis !== undefined) { + this.pointerUpEvent.mode = this.mode; + // @ts-expect-error -- custom controls event, needs augmentation + this.dispatchEvent(this.pointerUpEvent); + } + + this.dragging = false; + this.axis = undefined; + }; + + private readonly getPointer = (event: Event): TransformControlsPointerObject => { + if (this.domElement?.ownerDocument.pointerLockElement) { + return { + x: 0, + y: 0, + button: (event as MouseEvent).button, + }; + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- type guard + const pointer = (event as TouchEvent).changedTouches + ? (event as TouchEvent).changedTouches[0]! + : (event as MouseEvent); + + const rect = this.domElement!.getBoundingClientRect(); + + return { + x: ((pointer.clientX - rect.left) / rect.width) * 2 - 1, + y: (-(pointer.clientY - rect.top) / rect.height) * 2 + 1, + button: (event as MouseEvent).button, + }; + }; + + private readonly onPointerHover = (event: Event): void => { + if (!this.enabled) { + return; + } + + switch ((event as PointerEvent).pointerType) { + case 'mouse': + case 'pen': { + this.pointerHover(this.getPointer(event)); + break; + } + } + }; + + private readonly onPointerDown = (event: Event): void => { + if (!this.enabled || !this.domElement) { + return; + } + + this.domElement.style.touchAction = 'none'; // Disable touch scroll + this.domElement.ownerDocument.addEventListener('pointermove', this.onPointerMove); + this.pointerHover(this.getPointer(event)); + this.pointerDown(this.getPointer(event)); + }; + + private readonly onPointerMove = (event: Event): void => { + if (!this.enabled) { + return; + } + + this.pointerMove(this.getPointer(event)); + }; + + private readonly onPointerUp = (event: Event): void => { + if (!this.enabled || !this.domElement) { + return; + } + + this.domElement.style.touchAction = ''; + this.domElement.ownerDocument.removeEventListener('pointermove', this.onPointerMove); + + this.pointerUp(this.getPointer(event)); + }; +} + +type TransformControlsGizmoPrivateGizmos = { + ['translate']: Object3D; + ['scale']: Object3D; + ['rotate']: Object3D; + ['visible']: boolean; +}; + +class TransformControlsGizmo extends Object3D { + public override type = 'TransformControlsGizmo'; + public isTransformControlsGizmo = true; + public picker: TransformControlsGizmoPrivateGizmos; + + private readonly tempVector = new Vector3(0, 0, 0); + private readonly tempEuler = new Euler(); + private readonly alignVector = new Vector3(0, 1, 0); + private readonly zeroVector = new Vector3(0, 0, 0); + private readonly lookAtMatrix = new Matrix4(); + private readonly tempQuaternion = new Quaternion(); + private readonly tempQuaternion2 = new Quaternion(); + private readonly identityQuaternion = new Quaternion(); + + private readonly unitX = new Vector3(1, 0, 0); + private readonly unitY = new Vector3(0, 1, 0); + private readonly unitZ = new Vector3(0, 0, 1); + + private readonly gizmo: TransformControlsGizmoPrivateGizmos; + private readonly helper: TransformControlsGizmoPrivateGizmos; + + // These are set from parent class TransformControls + private readonly rotationAxis = new Vector3(); + + private readonly cameraPosition = new Vector3(); + + private readonly worldPositionStart = new Vector3(); + private readonly worldQuaternionStart = new Quaternion(); + + private readonly worldPosition = new Vector3(); + private readonly worldQuaternion = new Quaternion(); + + private readonly eye = new Vector3(); + + private readonly camera: PerspectiveCamera | OrthographicCamera = undefined!; + private readonly enabled: boolean = true; + private readonly axis: string | undefined = undefined; + private readonly mode: 'translate' | 'rotate' | 'scale' = 'translate'; + private readonly space: 'world' | 'local' = 'world'; + private readonly size = 1; + private readonly dragging: boolean = false; + private readonly showX: boolean = true; + private readonly showY: boolean = true; + private readonly showZ: boolean = true; + + public constructor() { + super(); + + const matcapTexture = matcapMaterial(); + + const gizmoMaterial = new MeshMatcapMaterial({ + matcap: matcapTexture, + depthTest: false, + depthWrite: false, + transparent: true, + side: DoubleSide, + fog: false, + toneMapped: false, + }); + + const gizmoLineMaterial = new LineBasicMaterial({ + depthTest: false, + depthWrite: false, + transparent: true, + linewidth: 1, + fog: false, + toneMapped: false, + }); + + // Helper dotted line configuration (inline defaults) + const helperDashSize = 2; + const helperGapSize = 1.5; + + const helperLineDashedMaterial = new LineDashedMaterial({ + depthTest: false, + depthWrite: false, + transparent: true, + linewidth: 1, + fog: false, + toneMapped: false, + dashSize: helperDashSize, + gapSize: helperGapSize, + color: 0x00_00_00, + }); + + // Make unique material for each axis/color + const matInvisible = gizmoMaterial.clone(); + matInvisible.opacity = 0.15; + + const matHelper = gizmoMaterial.clone(); + matHelper.color.set(0x00_00_00); + matHelper.opacity = 0.5; + + const matLabelBackground = gizmoMaterial.clone(); + matLabelBackground.color.set(0xff_ff_ff); + matLabelBackground.visible = false; // TODO: Show label text, update text as transform changes + + const matLabelText = gizmoMaterial.clone(); + matLabelText.color.set(0x00_00_00); + matLabelText.visible = false; // TODO: Show label text, update text as transform changes + + const matRed = gizmoMaterial.clone(); + matRed.color.set(0xef_44_44); + + const matGreen = gizmoMaterial.clone(); + matGreen.color.set(0x22_c5_5e); + + const matBlue = gizmoMaterial.clone(); + matBlue.color.set(0x3b_82_f6); + + const matWhiteTransparent = gizmoMaterial.clone(); + matWhiteTransparent.opacity = 0.25; + + const matYellowTransparent = matWhiteTransparent.clone(); + matYellowTransparent.color.set(0xff_ff_00); + + const matCyanTransparent = matWhiteTransparent.clone(); + matCyanTransparent.color.set(0x00_ff_ff); + + const matMagentaTransparent = matWhiteTransparent.clone(); + matMagentaTransparent.color.set(0xff_00_ff); + + const matYellow = gizmoMaterial.clone(); + matYellow.color.set(0xff_ff_00); + + const matLineRed = gizmoLineMaterial.clone(); + matLineRed.color.set(0xef_44_44); + + const matLineGreen = gizmoLineMaterial.clone(); + matLineGreen.color.set(0x22_c5_5e); + + const matLineBlue = gizmoLineMaterial.clone(); + matLineBlue.color.set(0x3b_82_f6); + + const matLineCyan = gizmoLineMaterial.clone(); + matLineCyan.color.set(0x00_ff_ff); + + const matLineMagenta = gizmoLineMaterial.clone(); + matLineMagenta.color.set(0xff_00_ff); + + const matLineYellow = gizmoLineMaterial.clone(); + matLineYellow.color.set(0xff_ff_00); + + const matLineGray = gizmoLineMaterial.clone(); + matLineGray.color.set(0x78_78_78); + + const matLineYellowTransparent = matLineYellow.clone(); + matLineYellowTransparent.opacity = 0.25; + + // Reusable geometry + + const arrowDepth = 100; + const textDepth = 25; + const boxDepth = 200; + + const scaleHandleGeometry = new BoxGeometry(0.125, 0.125, 0.125); + + const translationArrowGeometry = SvgGeometry({ svg: translationArrowSvg, depth: arrowDepth }); + const rotationArrowGeometry = SvgGeometry({ svg: rotationArrowSvg, depth: arrowDepth }); + + const fontGeometryTranslation = FontGeometry({ text: '24 mm', depth: textDepth, size: 300 }); + const fontGeometryRotationX = FontGeometry({ text: '45°', depth: textDepth, size: 400 }); + const fontGeometryRotationY = FontGeometry({ text: '15°', depth: textDepth, size: 400 }); + const fontGeometryRotationZ = FontGeometry({ text: '67°', depth: textDepth, size: 400 }); + const roundedBoxGeometry = RoundedRectangleGeometry({ + width: 1500, + height: 700, + radius: 200, + smoothness: 16, + depth: 100, + }); + + const lineGeometry = new BufferGeometry(); + lineGeometry.setAttribute('position', new Float32BufferAttribute([0, 0, 0, 1, 0, 0], 3)); + + // Special geometry for transform helper. If scaled with position vector it spans from [0,0,0] to position + + const TranslateHelperGeometry = (): BufferGeometry => { + const geometry = new BufferGeometry(); + + geometry.setAttribute('position', new Float32BufferAttribute([0, 0, 0, 1, 1, 1], 3)); + + return geometry; + }; + + // Gizmo definitions - custom hierarchy definitions for setupGizmo() function + + const gizmoTranslationScaleFactor = 0.000_25; + const pickerTranslationScaleFactor = 0.000_25; + + const gizmoTranslationScale = [ + gizmoTranslationScaleFactor, + gizmoTranslationScaleFactor, + gizmoTranslationScaleFactor, + ]; + const pickerTranslationScale = [ + pickerTranslationScaleFactor, + pickerTranslationScaleFactor, + pickerTranslationScaleFactor, + ]; + const gizmoMeshOffset = 0.3; + const gizmoMeshTranslationTextOffset = 0.7; + + // Rotation text and box offsets + const gizmoRotationScaleFactor = 0.000_15; + const gizmoRotationScaleFactorZ = gizmoTranslationScaleFactor; + const pickerRotationScaleFactor = 0.0003; + const gizmoMeshRotationTextOffset = 1.2; + const gizmoTextBoxOffset = ((boxDepth + textDepth) / 2) * gizmoRotationScaleFactor; + const gizmoRotationScale = [gizmoRotationScaleFactor, gizmoRotationScaleFactor, gizmoRotationScaleFactorZ]; + const pickerRotationScale = [ + // + pickerRotationScaleFactor, + pickerRotationScaleFactor, + pickerRotationScaleFactor, + ]; + + // Order is: + // 1. The Object3D to render + // 2. The position of the object + // 3. The rotation of the object + // 4. The scale of the object + // 5. The name of the object + const gizmoTranslate = { + X: [ + [ + new Mesh(translationArrowGeometry, matRed), + [gizmoMeshOffset, 0, 0], + [Math.PI / 2, 0, -Math.PI / 2], + gizmoTranslationScale, + 'fwd-handle', + ], + [ + new Mesh(translationArrowGeometry, matRed), + [-gizmoMeshOffset, 0, 0], + [Math.PI / 2, 0, Math.PI / 2], + gizmoTranslationScale, + 'bwd-handle', + ], + [ + new Mesh(fontGeometryTranslation, matLabelText), + [gizmoMeshTranslationTextOffset, gizmoTextBoxOffset, 0], + [Math.PI / 2, Math.PI, Math.PI], + gizmoTranslationScale, + 'fwd-label', + ], + [ + new Mesh(roundedBoxGeometry, matLabelBackground), + [gizmoMeshTranslationTextOffset, 0, 0], + [Math.PI / 2, 0, 0], + gizmoTranslationScale, + 'fwd-label', + ], + [ + new Mesh(fontGeometryTranslation, matLabelText), + [-gizmoMeshTranslationTextOffset, gizmoTextBoxOffset, 0], + [Math.PI / 2, 0, Math.PI], + gizmoTranslationScale, + 'bwd-label', + ], + [ + new Mesh(roundedBoxGeometry, matLabelBackground), + [-gizmoMeshTranslationTextOffset, 0, 0], + [Math.PI / 2, 0, Math.PI], + gizmoTranslationScale, + 'bwd-label', + ], + ], + Y: [ + [ + new Mesh(translationArrowGeometry, matGreen), + [0, gizmoMeshOffset, 0], + undefined, + gizmoTranslationScale, + 'fwd-handle', + ], + [ + new Mesh(translationArrowGeometry, matGreen), + [0, -gizmoMeshOffset, 0], + [Math.PI, 0, 0], + gizmoTranslationScale, + 'bwd-handle', + ], + [ + new Mesh(fontGeometryTranslation, matLabelText), + [0, gizmoMeshTranslationTextOffset, gizmoTextBoxOffset], + [0, 0, Math.PI / 2], + gizmoTranslationScale, + 'fwd-label', + ], + [ + new Mesh(roundedBoxGeometry, matLabelBackground), + [0, gizmoMeshTranslationTextOffset, 0], + [0, 0, Math.PI / 2], + gizmoTranslationScale, + 'fwd-label', + ], + [ + new Mesh(fontGeometryTranslation, matLabelText), + [0, -gizmoMeshTranslationTextOffset, gizmoTextBoxOffset], + [Math.PI, 0, Math.PI / 2], + gizmoTranslationScale, + 'bwd-label', + ], + [ + new Mesh(roundedBoxGeometry, matLabelBackground), + [0, -gizmoMeshTranslationTextOffset, 0], + [0, 0, Math.PI / 2], + gizmoTranslationScale, + 'bwd-label', + ], + ], + Z: [ + [ + new Mesh(translationArrowGeometry, matBlue), + [0, 0, gizmoMeshOffset], + [Math.PI / 2, 0, 0], + gizmoTranslationScale, + 'fwd-handle', + ], + [ + new Mesh(translationArrowGeometry, matBlue), + [0, 0, -gizmoMeshOffset], + [-Math.PI / 2, 0, 0], + gizmoTranslationScale, + 'bwd-handle', + ], + [ + new Mesh(fontGeometryTranslation, matLabelText), + [0, gizmoTextBoxOffset, gizmoMeshTranslationTextOffset], + [Math.PI / 2, Math.PI, 0], + gizmoTranslationScale, + 'fwd-label', + ], + [ + new Mesh(roundedBoxGeometry, matLabelBackground), + [0, 0, gizmoMeshTranslationTextOffset], + [-Math.PI / 2, 0, 0], + gizmoTranslationScale, + 'fwd-label', + ], + [ + new Mesh(fontGeometryTranslation, matLabelText), + [0, gizmoTextBoxOffset, -gizmoMeshTranslationTextOffset], + [Math.PI / 2, 0, Math.PI], + gizmoTranslationScale, + 'bwd-label', + ], + [ + new Mesh(roundedBoxGeometry, matLabelBackground), + [0, 0, -gizmoMeshTranslationTextOffset], + [Math.PI / 2, 0, Math.PI], + gizmoTranslationScale, + 'bwd-label', + ], + ], + XYZ: [[new Mesh(new OctahedronGeometry(0.1, 0), matWhiteTransparent.clone()), [0, 0, 0], [0, 0, 0]]], + XY: [ + [new Mesh(new PlaneGeometry(0.295, 0.295), matYellowTransparent.clone()), [0.15, 0.15, 0]], + [new Line(lineGeometry, matLineYellow), [0.18, 0.3, 0], undefined, [0.125, 1, 1]], + [new Line(lineGeometry, matLineYellow), [0.3, 0.18, 0], [0, 0, Math.PI / 2], [0.125, 1, 1]], + ], + YZ: [ + [new Mesh(new PlaneGeometry(0.295, 0.295), matCyanTransparent.clone()), [0, 0.15, 0.15], [0, Math.PI / 2, 0]], + [new Line(lineGeometry, matLineCyan), [0, 0.18, 0.3], [0, 0, Math.PI / 2], [0.125, 1, 1]], + [new Line(lineGeometry, matLineCyan), [0, 0.3, 0.18], [0, -Math.PI / 2, 0], [0.125, 1, 1]], + ], + XZ: [ + [ + new Mesh(new PlaneGeometry(0.295, 0.295), matMagentaTransparent.clone()), + [0.15, 0, 0.15], + [-Math.PI / 2, 0, 0], + ], + [new Line(lineGeometry, matLineMagenta), [0.18, 0, 0.3], undefined, [0.125, 1, 1]], + [new Line(lineGeometry, matLineMagenta), [0.3, 0, 0.18], [0, -Math.PI / 2, 0], [0.125, 1, 1]], + ], + }; + + const pickerTranslate = { + X: [ + [ + new Mesh(translationArrowGeometry, matInvisible), + [gizmoMeshOffset, 0, 0], + [Math.PI / 2, 0, -Math.PI / 2], + pickerTranslationScale, + 'fwd-picker', + ], + [ + new Mesh(translationArrowGeometry, matInvisible), + [-gizmoMeshOffset, 0, 0], + [0, 0, Math.PI / 2], + pickerTranslationScale, + 'bwd-picker', + ], + ], + Y: [ + [ + new Mesh(translationArrowGeometry, matGreen), + [0, gizmoMeshOffset, 0], + undefined, + pickerTranslationScale, + 'fwd-picker', + ], + [ + new Mesh(translationArrowGeometry, matGreen), + [0, -gizmoMeshOffset, 0], + [Math.PI, 0, 0], + pickerTranslationScale, + 'bwd-picker', + ], + ], + Z: [ + [ + new Mesh(translationArrowGeometry, matBlue), + [0, 0, gizmoMeshOffset], + [Math.PI / 2, 0, 0], + pickerTranslationScale, + 'fwd-picker', + ], + [ + new Mesh(translationArrowGeometry, matBlue), + [0, 0, -gizmoMeshOffset], + [-Math.PI / 2, 0, 0], + pickerTranslationScale, + 'bwd-picker', + ], + ], + XYZ: [[new Mesh(new OctahedronGeometry(0.2, 0), matInvisible)]], + XY: [[new Mesh(new PlaneGeometry(0.4, 0.4), matInvisible), [0.2, 0.2, 0]]], + YZ: [[new Mesh(new PlaneGeometry(0.4, 0.4), matInvisible), [0, 0.2, 0.2], [0, Math.PI / 2, 0]]], + XZ: [[new Mesh(new PlaneGeometry(0.4, 0.4), matInvisible), [0.2, 0, 0.2], [-Math.PI / 2, 0, 0]]], + }; + + // Dashed translate helper line + const helperTranslateDeltaLine = new Line(TranslateHelperGeometry(), helperLineDashedMaterial.clone()); + helperTranslateDeltaLine.computeLineDistances(); + + const helperTranslate = { + START: [[new Mesh(new OctahedronGeometry(0.02, 2), matHelper), undefined, undefined, undefined, 'helper']], + END: [[new Mesh(new OctahedronGeometry(0.02, 2), matHelper), undefined, undefined, undefined, 'helper']], + DELTA: [[helperTranslateDeltaLine, undefined, undefined, undefined, 'helper']], + }; + + const gizmoRotate = { + X: [ + [new Line(CircleGeometry({ radius: 1, arc: 0.1, arcOffset: (Math.PI / 4) * 1.625 }), matLineRed)], + [new Mesh(rotationArrowGeometry, matRed), [0, 0, 1], [0, Math.PI / 2, Math.PI / 2], gizmoRotationScale], + [ + new Mesh(fontGeometryRotationX, matLabelText), + [gizmoTextBoxOffset, 0, gizmoMeshRotationTextOffset], + [Math.PI / 2, Math.PI / 2, 0], + gizmoRotationScale, + 'rotation-label', + ], + [ + new Mesh(roundedBoxGeometry, matLabelBackground), + [0, 0, gizmoMeshRotationTextOffset], + [Math.PI / 2, Math.PI / 2, 0], + gizmoRotationScale, + 'rotation-label', + ], + ], + Y: [ + [ + new Line(CircleGeometry({ radius: 1, arc: 0.1, arcOffset: (Math.PI / 4) * 1.625 }), matLineGreen), + undefined, + [0, 0, -Math.PI / 2], + ], + [new Mesh(rotationArrowGeometry, matGreen), [0, 0, 1], [Math.PI / 2, 0, 0], gizmoRotationScale], + [ + new Mesh(fontGeometryRotationY, matLabelText), + [0, -gizmoTextBoxOffset, gizmoMeshRotationTextOffset], + [Math.PI / 2, 0, 0], + gizmoRotationScale, + 'rotation-label', + ], + [ + new Mesh(roundedBoxGeometry, matLabelBackground), + [0, 0, gizmoMeshRotationTextOffset], + [Math.PI / 2, 0, 0], + gizmoRotationScale, + 'rotation-label', + ], + ], + Z: [ + [ + new Line(CircleGeometry({ radius: 1, arc: 0.1, arcOffset: (Math.PI / 4) * 1.625 }), matLineBlue), + undefined, + [0, Math.PI / 2, 0], + ], + [new Mesh(rotationArrowGeometry, matBlue), [1, 0, 0], [0, 0, -Math.PI / 2], gizmoRotationScale], + [ + new Mesh(fontGeometryRotationZ, matLabelText), + [gizmoMeshRotationTextOffset, 0, gizmoTextBoxOffset], + [0, 0, Math.PI / 2], + gizmoRotationScale, + 'rotation-label', + ], + [ + new Mesh(roundedBoxGeometry, matLabelBackground), + [gizmoMeshRotationTextOffset, 0, 0], + [0, 0, Math.PI / 2], + gizmoRotationScale, + 'rotation-label', + ], + ], + E: [ + [new Line(CircleGeometry({ radius: 1.25, arc: 1 }), matLineYellowTransparent), undefined, [0, Math.PI / 2, 0]], + [ + new Mesh(new CylinderGeometry(0.03, 0, 0.15, 4, 1, false), matLineYellowTransparent), + [1.17, 0, 0], + [0, 0, -Math.PI / 2], + [1, 1, 0.001], + ], + [ + new Mesh(new CylinderGeometry(0.03, 0, 0.15, 4, 1, false), matLineYellowTransparent), + [-1.17, 0, 0], + [0, 0, Math.PI / 2], + [1, 1, 0.001], + ], + [ + new Mesh(new CylinderGeometry(0.03, 0, 0.15, 4, 1, false), matLineYellowTransparent), + [0, -1.17, 0], + [Math.PI, 0, 0], + [1, 1, 0.001], + ], + [ + new Mesh(new CylinderGeometry(0.03, 0, 0.15, 4, 1, false), matLineYellowTransparent), + [0, 1.17, 0], + [0, 0, 0], + [1, 1, 0.001], + ], + ], + XYZE: [[new Line(CircleGeometry({ radius: 1, arc: 1 }), matLineGray), undefined, [0, Math.PI / 2, 0]]], + }; + + // Dashed rotate helper long axis + const helperRotateAxisLine = new Line(lineGeometry, helperLineDashedMaterial.clone()); + helperRotateAxisLine.computeLineDistances(); + + const helperRotate = { + AXIS: [[helperRotateAxisLine, [-1e3, 0, 0], undefined, [1e6, 1, 1], 'helper']], + }; + + const pickerRotate = { + X: [[new Mesh(rotationArrowGeometry, matRed), [0, 0, 1], [0, Math.PI / 2, Math.PI / 2], pickerRotationScale]], + Y: [[new Mesh(rotationArrowGeometry, matGreen), [0, 0, 1], [Math.PI / 2, 0, 0], pickerRotationScale]], + Z: [[new Mesh(rotationArrowGeometry, matBlue), [1, 0, 0], [0, 0, -Math.PI / 2], pickerRotationScale]], + // X: [[new Mesh(new TorusGeometry(1, 0.1, 4, 24), matInvisible), [0, 0, 0], [0, -Math.PI / 2, -Math.PI / 2]]], + // Y: [[new Mesh(new TorusGeometry(1, 0.1, 4, 24), matInvisible), [0, 0, 0], [Math.PI / 2, 0, 0]]], + // Z: [[new Mesh(new TorusGeometry(1, 0.1, 4, 24), matInvisible), [0, 0, 0], [0, 0, -Math.PI / 2]]], + E: [[new Mesh(new TorusGeometry(1.25, 0.1, 2, 24), matInvisible)]], + XYZE: [[new Mesh(new SphereGeometry(0.7, 10, 8), matInvisible)]], + }; + + const gizmoScale = { + X: [ + [new Mesh(scaleHandleGeometry, matRed), [0.8, 0, 0], [0, 0, -Math.PI / 2]], + [new Line(lineGeometry, matLineRed), undefined, undefined, [0.8, 1, 1]], + ], + Y: [ + [new Mesh(scaleHandleGeometry, matGreen), [0, 0.8, 0]], + [new Line(lineGeometry, matLineGreen), undefined, [0, 0, Math.PI / 2], [0.8, 1, 1]], + ], + Z: [ + [new Mesh(scaleHandleGeometry, matBlue), [0, 0, 0.8], [Math.PI / 2, 0, 0]], + [new Line(lineGeometry, matLineBlue), undefined, [0, -Math.PI / 2, 0], [0.8, 1, 1]], + ], + XY: [ + [new Mesh(scaleHandleGeometry, matYellowTransparent), [0.85, 0.85, 0], undefined, [2, 2, 0.2]], + [new Line(lineGeometry, matLineYellow), [0.855, 0.98, 0], undefined, [0.125, 1, 1]], + [new Line(lineGeometry, matLineYellow), [0.98, 0.855, 0], [0, 0, Math.PI / 2], [0.125, 1, 1]], + ], + YZ: [ + [new Mesh(scaleHandleGeometry, matCyanTransparent), [0, 0.85, 0.85], undefined, [0.2, 2, 2]], + [new Line(lineGeometry, matLineCyan), [0, 0.855, 0.98], [0, 0, Math.PI / 2], [0.125, 1, 1]], + [new Line(lineGeometry, matLineCyan), [0, 0.98, 0.855], [0, -Math.PI / 2, 0], [0.125, 1, 1]], + ], + XZ: [ + [new Mesh(scaleHandleGeometry, matMagentaTransparent), [0.85, 0, 0.85], undefined, [2, 0.2, 2]], + [new Line(lineGeometry, matLineMagenta), [0.855, 0, 0.98], undefined, [0.125, 1, 1]], + [new Line(lineGeometry, matLineMagenta), [0.98, 0, 0.855], [0, -Math.PI / 2, 0], [0.125, 1, 1]], + ], + XYZX: [[new Mesh(new BoxGeometry(0.125, 0.125, 0.125), matWhiteTransparent.clone()), [1.1, 0, 0]]], + XYZY: [[new Mesh(new BoxGeometry(0.125, 0.125, 0.125), matWhiteTransparent.clone()), [0, 1.1, 0]]], + XYZZ: [[new Mesh(new BoxGeometry(0.125, 0.125, 0.125), matWhiteTransparent.clone()), [0, 0, 1.1]]], + }; + + const pickerScale = { + X: [[new Mesh(new CylinderGeometry(0.2, 0, 0.8, 4, 1, false), matInvisible), [0.5, 0, 0], [0, 0, -Math.PI / 2]]], + Y: [[new Mesh(new CylinderGeometry(0.2, 0, 0.8, 4, 1, false), matInvisible), [0, 0.5, 0]]], + Z: [[new Mesh(new CylinderGeometry(0.2, 0, 0.8, 4, 1, false), matInvisible), [0, 0, 0.5], [Math.PI / 2, 0, 0]]], + XY: [[new Mesh(scaleHandleGeometry, matInvisible), [0.85, 0.85, 0], undefined, [3, 3, 0.2]]], + YZ: [[new Mesh(scaleHandleGeometry, matInvisible), [0, 0.85, 0.85], undefined, [0.2, 3, 3]]], + XZ: [[new Mesh(scaleHandleGeometry, matInvisible), [0.85, 0, 0.85], undefined, [3, 0.2, 3]]], + XYZX: [[new Mesh(new BoxGeometry(0.2, 0.2, 0.2), matInvisible), [1.1, 0, 0]]], + XYZY: [[new Mesh(new BoxGeometry(0.2, 0.2, 0.2), matInvisible), [0, 1.1, 0]]], + XYZZ: [[new Mesh(new BoxGeometry(0.2, 0.2, 0.2), matInvisible), [0, 0, 1.1]]], + }; + + // Dashed scale helpers for X/Y/Z + const helperScaleXLine = new Line(lineGeometry, helperLineDashedMaterial.clone()); + helperScaleXLine.computeLineDistances(); + const helperScaleYLine = new Line(lineGeometry, helperLineDashedMaterial.clone()); + helperScaleYLine.computeLineDistances(); + const helperScaleZLine = new Line(lineGeometry, helperLineDashedMaterial.clone()); + helperScaleZLine.computeLineDistances(); + + const helperScale = { + X: [[helperScaleXLine, [-1e3, 0, 0], undefined, [1e6, 1, 1], 'helper']], + Y: [[helperScaleYLine, [0, -1e3, 0], [0, 0, Math.PI / 2], [1e6, 1, 1], 'helper']], + Z: [[helperScaleZLine, [0, 0, -1e3], [0, -Math.PI / 2, 0], [1e6, 1, 1], 'helper']], + }; + + // Creates an Object3D with gizmos described in custom hierarchy definition. + // this is nearly impossible to Type so i'm leaving it + const setupGizmo = ( + gizmoMap: Record< + string, + Array<[Mesh, number[] | undefined, number[] | undefined, number[] | undefined, string | undefined]> + >, + ): Object3D => { + const gizmo = new Object3D(); + + // eslint-disable-next-line guard-for-in -- TODO + for (const name in gizmoMap) { + for (let i = gizmoMap[name]!.length; i--; ) { + const object = gizmoMap[name]![i]![0].clone(); + const position = gizmoMap[name]![i]![1]; + const rotation = gizmoMap[name]![i]![2]; + const scale = gizmoMap[name]![i]![3]; + const tag = gizmoMap[name]![i]![4]; + + // Name and tag properties are essential for picking and updating logic. + object.name = name; + // @ts-expect-error -- TODO: augment types or replace mechanism altogether + object.tag = tag; + + if (position) { + object.position.set(position[0]!, position[1]!, position[2]!); + } + + if (rotation) { + object.rotation.set(rotation[0]!, rotation[1]!, rotation[2]!); + } + + if (scale) { + object.scale.set(scale[0]!, scale[1]!, scale[2]!); + } + + object.updateMatrix(); + + const temporaryGeometry = object.geometry.clone(); + temporaryGeometry.applyMatrix4(object.matrix); + object.geometry = temporaryGeometry; + if (object instanceof Line) { + // Ensure dashed materials render correctly after baking transforms + object.computeLineDistances(); + } + + object.renderOrder = Infinity; + + object.position.set(0, 0, 0); + object.rotation.set(0, 0, 0); + object.scale.set(1, 1, 1); + + gizmo.add(object); + } + } + + return gizmo; + }; + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- TODO: fix typings + this.gizmo = {} as TransformControlsGizmoPrivateGizmos; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- TODO: fix typings + this.picker = {} as TransformControlsGizmoPrivateGizmos; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- TODO: fix typings + this.helper = {} as TransformControlsGizmoPrivateGizmos; + + // @ts-expect-error -- fix typings + this.add((this.gizmo.translate = setupGizmo(gizmoTranslate))); + // @ts-expect-error -- fix typings + this.add((this.gizmo.rotate = setupGizmo(gizmoRotate))); + // @ts-expect-error -- fix typings + this.add((this.gizmo.scale = setupGizmo(gizmoScale))); + // @ts-expect-error -- fix typings + this.add((this.picker.translate = setupGizmo(pickerTranslate))); + // @ts-expect-error -- fix typings + this.add((this.picker.rotate = setupGizmo(pickerRotate))); + // @ts-expect-error -- fix typings + this.add((this.picker.scale = setupGizmo(pickerScale))); + // @ts-expect-error -- fix typings + this.add((this.helper.translate = setupGizmo(helperTranslate))); + // @ts-expect-error -- fix typings + this.add((this.helper.rotate = setupGizmo(helperRotate))); + // @ts-expect-error -- fix typings + this.add((this.helper.scale = setupGizmo(helperScale))); + + // Pickers should be hidden always + + this.picker.translate.visible = false; + this.picker.rotate.visible = false; + this.picker.scale.visible = false; + } + + // UpdateMatrixWorld will update transformations and appearance of individual handles + public override updateMatrixWorld = (): void => { + let { space } = this; + + if (this.mode === 'scale') { + space = 'local'; // Scale always oriented to local rotation + } + + const quaternion = space === 'local' ? this.worldQuaternion : this.identityQuaternion; + + // Show only gizmos for current transform mode + + this.gizmo.translate.visible = this.mode === 'translate'; + this.gizmo.rotate.visible = this.mode === 'rotate'; + this.gizmo.scale.visible = this.mode === 'scale'; + + this.helper.translate.visible = this.mode === 'translate'; + this.helper.rotate.visible = this.mode === 'rotate'; + this.helper.scale.visible = this.mode === 'scale'; + + const handles: Array = [ + ...this.picker[this.mode].children, + ...this.gizmo[this.mode].children, + ...this.helper[this.mode].children, + ]; + + for (const handle of handles) { + // Hide aligned to camera + + handle.visible = true; + handle.rotation.set(0, 0, 0); + handle.position.copy(this.worldPosition); + + let factor; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- camera is possible perspective or orthographic + if ((this.camera as OrthographicCamera).isOrthographicCamera) { + factor = + ((this.camera as OrthographicCamera).top - (this.camera as OrthographicCamera).bottom) / + (this.camera as OrthographicCamera).zoom; + } else { + factor = + this.worldPosition.distanceTo(this.cameraPosition) * + Math.min((1.9 * Math.tan((Math.PI * (this.camera as PerspectiveCamera).fov) / 360)) / this.camera.zoom, 7); + } + + handle.scale.set(1, 1, 1).multiplyScalar((factor * this.size) / 7); + + // TODO: simplify helpers and consider decoupling from gizmo + + if (handle.tag === 'helper') { + handle.visible = false; + + switch (handle.name) { + case 'AXIS': { + // During hover (not dragging) anchor to current world position; + // once dragging, keep the captured start position + handle.position.copy(this.dragging ? this.worldPositionStart : this.worldPosition); + handle.visible = Boolean(this.axis); + + if (this.axis === 'X') { + this.tempQuaternion.setFromEuler(this.tempEuler.set(0, 0, 0)); + handle.quaternion.copy(quaternion).multiply(this.tempQuaternion); + + if (Math.abs(this.alignVector.copy(this.unitX).applyQuaternion(quaternion).dot(this.eye)) > 0.9) { + handle.visible = false; + } + } + + if (this.axis === 'Y') { + this.tempQuaternion.setFromEuler(this.tempEuler.set(0, 0, Math.PI / 2)); + handle.quaternion.copy(quaternion).multiply(this.tempQuaternion); + + if (Math.abs(this.alignVector.copy(this.unitY).applyQuaternion(quaternion).dot(this.eye)) > 0.9) { + handle.visible = false; + } + } + + if (this.axis === 'Z') { + this.tempQuaternion.setFromEuler(this.tempEuler.set(0, Math.PI / 2, 0)); + handle.quaternion.copy(quaternion).multiply(this.tempQuaternion); + + if (Math.abs(this.alignVector.copy(this.unitZ).applyQuaternion(quaternion).dot(this.eye)) > 0.9) { + handle.visible = false; + } + } + + // Dynamically size and center the axis helper near the gizmo so dash density + // stays consistent on screen and isn't skewed by extreme world lengths. + // Local line geometry runs along +X; we align quaternion above per-axis. + const axisWorldDir = new Vector3(1, 0, 0).applyQuaternion(handle.quaternion).normalize(); + const axisHelperLength = Math.max(factor * 10, 1); // World units, camera-relative + const halfLength = axisHelperLength * 0.5; + + // Center around pivot + handle.position + .copy(this.dragging ? this.worldPositionStart : this.worldPosition) + .addScaledVector(axisWorldDir, -halfLength); + + // Stretch the 1-unit line to the desired length + handle.scale.set(axisHelperLength, 1, 1); + + const dashedMaterial = (handle as Line).material as LineDashedMaterial; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- ensure existence + if (dashedMaterial) { + // Compensate for object scale so dashes stay constant in world units, + // then scale dash/gap with camera factor so they stay constant on screen. + dashedMaterial.scale = axisHelperLength; + + type DashedWithUserData = LineDashedMaterial & { + userData: { baseDashSize?: number; baseGapSize?: number }; + }; + const dashed = dashedMaterial as DashedWithUserData; + dashed.userData.baseDashSize ??= dashed.dashSize; + dashed.userData.baseGapSize ??= dashed.gapSize; + + const baseDash = dashed.userData.baseDashSize ?? dashed.dashSize; + const baseGap = dashed.userData.baseGapSize ?? dashed.gapSize; + + const dashZoomScale = Math.max(factor / 200, 0.001); + dashed.dashSize = baseDash * dashZoomScale; + dashed.gapSize = baseGap * dashZoomScale; + dashed.needsUpdate = true; + } + + if (this.axis === 'XYZE') { + this.tempQuaternion.setFromEuler(this.tempEuler.set(0, Math.PI / 2, 0)); + this.alignVector.copy(this.rotationAxis); + handle.quaternion.setFromRotationMatrix( + this.lookAtMatrix.lookAt(this.zeroVector, this.alignVector, this.unitY), + ); + handle.quaternion.multiply(this.tempQuaternion); + handle.visible = this.dragging; + } + + if (this.axis === 'E') { + handle.visible = false; + } + + break; + } + + case 'START': { + handle.position.copy(this.worldPositionStart); + handle.visible = this.dragging; + + // Keep start marker a constant on-screen size based on its own distance to the camera + let startFactor: number; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- camera can be ortho or perspective + if ((this.camera as OrthographicCamera).isOrthographicCamera) { + startFactor = + ((this.camera as OrthographicCamera).top - (this.camera as OrthographicCamera).bottom) / + (this.camera as OrthographicCamera).zoom; + } else { + startFactor = + handle.position.distanceTo(this.cameraPosition) * + Math.min( + (1.9 * Math.tan((Math.PI * (this.camera as PerspectiveCamera).fov) / 360)) / this.camera.zoom, + 7, + ); + } + + handle.scale.set(1, 1, 1).multiplyScalar((startFactor * this.size) / 7); + + break; + } + + case 'END': { + handle.position.copy(this.worldPosition); + handle.visible = this.dragging; + + // Keep end marker a constant on-screen size based on its own distance to the camera + let endFactor: number; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- camera can be ortho or perspective + if ((this.camera as OrthographicCamera).isOrthographicCamera) { + endFactor = + ((this.camera as OrthographicCamera).top - (this.camera as OrthographicCamera).bottom) / + (this.camera as OrthographicCamera).zoom; + } else { + endFactor = + handle.position.distanceTo(this.cameraPosition) * + Math.min( + (1.9 * Math.tan((Math.PI * (this.camera as PerspectiveCamera).fov) / 360)) / this.camera.zoom, + 7, + ); + } + + handle.scale.set(1, 1, 1).multiplyScalar((endFactor * this.size) / 7); + + break; + } + + case 'DELTA': { + handle.position.copy(this.worldPositionStart); + handle.quaternion.copy(this.worldQuaternionStart); + this.tempVector + .set(1e-10, 1e-10, 1e-10) + .add(this.worldPositionStart) + .sub(this.worldPosition) + .multiplyScalar(-1); + this.tempVector.applyQuaternion(this.worldQuaternionStart.clone().invert()); + handle.scale.copy(this.tempVector); + // Keep dash size constant in world units: adjust material scale by geometric stretch + // Base line geometry for translate helper is from (0,0,0) to (1,1,1), whose length is sqrt(3) + const baseLength = Math.sqrt(3); + const worldLength = this.tempVector.length(); + const scaleFactor = worldLength / baseLength || 1; + const dashedMaterial = (handle as Line).material as LineDashedMaterial; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- ensure the cast worked + if (dashedMaterial && typeof dashedMaterial.scale === 'number') { + dashedMaterial.scale = scaleFactor; + // Also make the dash/gap appear constant in screen-space like rotation helper, + // but anchor the zoom scale at drag start so dash lengths don't change while moving. + type DashedWithUserData = LineDashedMaterial & { + userData: { + baseDashSize?: number; + baseGapSize?: number; + dashZoomScaleAtStart?: number; + }; + }; + const dashed = dashedMaterial as DashedWithUserData; + dashed.userData.baseDashSize ??= dashed.dashSize; + dashed.userData.baseGapSize ??= dashed.gapSize; + + if (dashed.userData.dashZoomScaleAtStart === undefined) { + // Compute zoom factor anchored at the drag start position + let startFactor: number; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- camera can be ortho or perspective + if ((this.camera as OrthographicCamera).isOrthographicCamera) { + startFactor = + ((this.camera as OrthographicCamera).top - (this.camera as OrthographicCamera).bottom) / + (this.camera as OrthographicCamera).zoom; + } else { + startFactor = + this.worldPositionStart.distanceTo(this.cameraPosition) * + Math.min( + (1.9 * Math.tan((Math.PI * (this.camera as PerspectiveCamera).fov) / 360)) / this.camera.zoom, + 7, + ); + } + + dashed.userData.dashZoomScaleAtStart = Math.max(startFactor / 200, 0.001); + } + + const baseDash = dashed.userData.baseDashSize ?? dashed.dashSize; + const baseGap = dashed.userData.baseGapSize ?? dashed.gapSize; + const dashZoom = dashed.userData.dashZoomScaleAtStart ?? 1; + dashed.dashSize = baseDash * dashZoom; + dashed.gapSize = baseGap * dashZoom; + dashed.needsUpdate = true; + + // If dragging has ended, clear the cached zoom scale and restore base sizes + if (!this.dragging) { + dashed.userData.dashZoomScaleAtStart = undefined; + dashed.dashSize = dashed.userData.baseDashSize ?? dashed.dashSize; + dashed.gapSize = dashed.userData.baseGapSize ?? dashed.gapSize; + dashed.needsUpdate = true; + } + } + + handle.visible = this.dragging; + + break; + } + + default: { + handle.quaternion.copy(quaternion); + + if (this.dragging) { + handle.position.copy(this.worldPositionStart); + } else { + handle.position.copy(this.worldPosition); + } + + if (this.axis) { + handle.visible = this.axis.search(handle.name) !== -1; + } + } + } + + // If updating helper, skip rest of the loop + continue; + } + + // Align handles to current local or world rotation + + handle.quaternion.copy(quaternion); + + if (this.mode === 'translate' || this.mode === 'scale') { + // Ensure translation arrows face the user by twisting around their axis using the camera orientation + if (this.mode === 'translate') { + const hasFwd = handle.tag?.includes('fwd') ?? false; + const hasBwd = handle.tag?.includes('bwd') ?? false; + const isAxisArrow = (hasFwd || hasBwd) && (handle.name === 'X' || handle.name === 'Y' || handle.name === 'Z'); + if (isAxisArrow) { + // Calculate the camera rotation relative to the handle axis + const handleAxis = handle.name === 'X' ? this.unitX : handle.name === 'Y' ? this.unitY : this.unitZ; + const handleAxisWorld = this.alignVector.copy(handleAxis).applyQuaternion(quaternion).normalize(); + + // Project the camera direction (eye vector) onto the plane perpendicular to the handle axis + // This gives us the direction towards the camera, constrained to rotation around the axis + const eyeProjected = this.tempVector + .copy(this.eye) + .addScaledVector(handleAxisWorld, -this.eye.dot(handleAxisWorld)) + .normalize(); + + // Get a reference "up" vector perpendicular to the handle axis + // We use the current handle's local Y-axis as the reference + const referenceAxis = handle.name === 'X' ? this.unitY : handle.name === 'Y' ? this.unitZ : this.unitY; + const handleUpWorld = new Vector3().copy(referenceAxis).applyQuaternion(quaternion).normalize(); + + // Project the reference up vector onto the same plane + const upProjected = handleUpWorld + .addScaledVector(handleAxisWorld, -handleUpWorld.dot(handleAxisWorld)) + .normalize(); + + // Calculate the signed angle between the projected vectors + const cosAngle = upProjected.dot(eyeProjected); + const crossProduct = new Vector3().crossVectors(upProjected, eyeProjected); + const sinAngle = crossProduct.dot(handleAxisWorld); + const cameraRotationRelative = Math.atan2(sinAngle, cosAngle); + + handle.rotateOnAxis(handleAxis, cameraRotationRelative); + } + } + // Hide translate and scale axis facing the camera + + const AXIS_HIDE_TRESHOLD = 1; + const PLANE_HIDE_TRESHOLD = 0.2; + const AXIS_FLIP_TRESHOLD = 0; + + if ( + (handle.name === 'X' || handle.name === 'XYZX') && + Math.abs(this.alignVector.copy(this.unitX).applyQuaternion(quaternion).dot(this.eye)) > AXIS_HIDE_TRESHOLD + ) { + handle.scale.set(1e-10, 1e-10, 1e-10); + handle.visible = false; + } + + if ( + (handle.name === 'Y' || handle.name === 'XYZY') && + Math.abs(this.alignVector.copy(this.unitY).applyQuaternion(quaternion).dot(this.eye)) > AXIS_HIDE_TRESHOLD + ) { + handle.scale.set(1e-10, 1e-10, 1e-10); + handle.visible = false; + } + + if ( + (handle.name === 'Z' || handle.name === 'XYZZ') && + Math.abs(this.alignVector.copy(this.unitZ).applyQuaternion(quaternion).dot(this.eye)) > AXIS_HIDE_TRESHOLD + ) { + handle.scale.set(1e-10, 1e-10, 1e-10); + handle.visible = false; + } + + if ( + handle.name === 'XY' && + Math.abs(this.alignVector.copy(this.unitZ).applyQuaternion(quaternion).dot(this.eye)) < PLANE_HIDE_TRESHOLD + ) { + handle.scale.set(1e-10, 1e-10, 1e-10); + handle.visible = false; + } + + if ( + handle.name === 'YZ' && + Math.abs(this.alignVector.copy(this.unitX).applyQuaternion(quaternion).dot(this.eye)) < PLANE_HIDE_TRESHOLD + ) { + handle.scale.set(1e-10, 1e-10, 1e-10); + handle.visible = false; + } + + if ( + handle.name === 'XZ' && + Math.abs(this.alignVector.copy(this.unitY).applyQuaternion(quaternion).dot(this.eye)) < PLANE_HIDE_TRESHOLD + ) { + handle.scale.set(1e-10, 1e-10, 1e-10); + handle.visible = false; + } + + // Flip translate and scale axis ocluded behind another axis + + if (handle.name.search('X') !== -1) { + if (this.alignVector.copy(this.unitX).applyQuaternion(quaternion).dot(this.eye) < AXIS_FLIP_TRESHOLD) { + if (handle.tag?.includes('fwd')) { + handle.visible = false; + } else { + handle.scale.x *= -1; + } + } else if (handle.tag?.includes('bwd')) { + handle.visible = false; + } + } + + if (handle.name.search('Y') !== -1) { + if (this.alignVector.copy(this.unitY).applyQuaternion(quaternion).dot(this.eye) < AXIS_FLIP_TRESHOLD) { + if (handle.tag?.includes('fwd')) { + handle.visible = false; + } else { + handle.scale.y *= -1; + } + } else if (handle.tag?.includes('bwd')) { + handle.visible = false; + } + } + + if (handle.name.search('Z') !== -1) { + if (this.alignVector.copy(this.unitZ).applyQuaternion(quaternion).dot(this.eye) < AXIS_FLIP_TRESHOLD) { + if (handle.tag?.includes('fwd')) { + handle.visible = false; + } else { + handle.scale.z *= -1; + } + } else if (handle.tag?.includes('bwd')) { + handle.visible = false; + } + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- exhausive check + } else if (this.mode === 'rotate') { + // Align handles to current local or world rotation + + this.tempQuaternion2.copy(quaternion); + this.alignVector.copy(this.eye).applyQuaternion(this.tempQuaternion.copy(quaternion).invert()); + + if (handle.name.search('E') !== -1) { + handle.quaternion.setFromRotationMatrix(this.lookAtMatrix.lookAt(this.eye, this.zeroVector, this.unitY)); + } + + // Const isLabel = handle.tag?.includes('label'); + // // Calculate the camera rotation relative to the handle axis + // const handleAxis = handle.name === 'X' ? this.unitX : handle.name === 'Y' ? this.unitY : this.unitZ; + // const handleAxisWorld = this.alignVector.copy(handleAxis).applyQuaternion(quaternion).normalize(); + + // // Project the camera direction (eye vector) onto the plane perpendicular to the handle axis + // // This gives us the direction towards the camera, constrained to rotation around the axis + // const eyeProjected = this.tempVector + // .copy(this.eye) + // .addScaledVector(handleAxisWorld, this.eye.dot(handleAxisWorld)) + // .normalize(); + + // // Get a reference "up" vector perpendicular to the handle axis + // // We use the current handle's local Y-axis as the reference + // const referenceAxis = handle.name === 'X' ? this.unitY : handle.name === 'Y' ? this.unitZ : this.unitY; + // const handleUpWorld = new Vector3().copy(referenceAxis).applyQuaternion(quaternion).normalize(); + + // // Project the reference up vector onto the same plane + // const upProjected = handleUpWorld + // .addScaledVector(handleAxisWorld, handleUpWorld.dot(handleAxisWorld)) + // .normalize(); + + // // Calculate the signed angle between the projected vectors + // const cosAngle = upProjected.dot(eyeProjected); + // const crossProduct = new Vector3().crossVectors(upProjected, eyeProjected); + // const sinAngle = crossProduct.dot(handleAxisWorld); + // const cameraRotationRelative = Math.atan2(sinAngle, cosAngle); + + // if (isLabel) { + // handle.rotateOnAxis(handleAxis, cameraRotationRelative); + // } + + if (handle.name === 'X') { + this.tempQuaternion.setFromAxisAngle(this.unitX, Math.atan2(-this.alignVector.y, this.alignVector.z)); + this.tempQuaternion.multiplyQuaternions(this.tempQuaternion2, this.tempQuaternion); + handle.quaternion.copy(this.tempQuaternion); + } + + if (handle.name === 'Y') { + this.tempQuaternion.setFromAxisAngle(this.unitY, Math.atan2(this.alignVector.x, this.alignVector.z)); + this.tempQuaternion.multiplyQuaternions(this.tempQuaternion2, this.tempQuaternion); + handle.quaternion.copy(this.tempQuaternion); + } + + if (handle.name === 'Z') { + this.tempQuaternion.setFromAxisAngle(this.unitZ, Math.atan2(this.alignVector.y, this.alignVector.x)); + this.tempQuaternion.multiplyQuaternions(this.tempQuaternion2, this.tempQuaternion); + handle.quaternion.copy(this.tempQuaternion); + } + } + + // Hide disabled axes + handle.visible &&= !handle.name.includes('X') || this.showX; + handle.visible &&= !handle.name.includes('Y') || this.showY; + handle.visible &&= !handle.name.includes('Z') || this.showZ; + handle.visible &&= !handle.name.includes('E') || (this.showX && this.showY && this.showZ); + + // Highlight selected axis + if (!handle.tag?.includes('label')) { + // @ts-expect-error -- TODO + handle.material.tempOpacity ??= handle.material.opacity; + // @ts-expect-error -- TODO + handle.material.tempColor ??= handle.material.color.clone(); + // @ts-expect-error -- TODO + handle.material.color.copy(handle.material.tempColor); + // @ts-expect-error -- TODO + handle.material.opacity = handle.material.tempOpacity; + + if (!this.enabled) { + // @ts-expect-error -- TODO + handle.material.opacity *= 0.5; + // @ts-expect-error -- TODO + handle.material.color.lerp(new Color(1, 1, 1), 0.5); + } else if (this.axis) { + if (handle.name === this.axis) { + // @ts-expect-error -- TODO + handle.material.opacity = 1; + // @ts-expect-error -- TODO + handle.material.color.lerp(new Color(1, 1, 1), 0.5); + } else if ([...this.axis].includes(handle.name)) { + // @ts-expect-error -- TODO + handle.material.opacity = 1; + // @ts-expect-error -- TODO + handle.material.color.lerp(new Color(1, 1, 1), 0.5); + } else { + // @ts-expect-error -- TODO + handle.material.opacity *= 0.25; + // @ts-expect-error -- TODO + handle.material.color.lerp(new Color(1, 1, 1), 0.5); + } + } + } + } + + super.updateMatrixWorld(); + }; +} + +class TransformControlsPlane extends Mesh { + public override type = 'TransformControlsPlane'; + public isTransformControlsPlane = true; + + private readonly unitX = new Vector3(1, 0, 0); + private readonly unitY = new Vector3(0, 1, 0); + private readonly unitZ = new Vector3(0, 0, 1); + + private readonly tempVector = new Vector3(); + private readonly dirVector = new Vector3(); + private readonly alignVector = new Vector3(); + private readonly tempMatrix = new Matrix4(); + private readonly identityQuaternion = new Quaternion(); + + // These are set from parent class TransformControls + private readonly cameraQuaternion = new Quaternion(); + + private readonly worldPosition = new Vector3(); + private readonly worldQuaternion = new Quaternion(); + + private readonly eye = new Vector3(); + + private readonly axis: string | undefined = undefined; + private readonly mode: 'translate' | 'rotate' | 'scale' = 'translate'; + private readonly space: 'world' | 'local' = 'world'; + + public constructor() { + super( + new PlaneGeometry(100_000, 100_000, 2, 2), + new MeshBasicMaterial({ + visible: false, + wireframe: true, + side: DoubleSide, + transparent: true, + opacity: 0.1, + toneMapped: false, + }), + ); + } + + public override updateMatrixWorld = (): void => { + let { space } = this; + + this.position.copy(this.worldPosition); + + if (this.mode === 'scale') { + space = 'local'; + } // Scale always oriented to local rotation + + this.unitX.set(1, 0, 0).applyQuaternion(space === 'local' ? this.worldQuaternion : this.identityQuaternion); + this.unitY.set(0, 1, 0).applyQuaternion(space === 'local' ? this.worldQuaternion : this.identityQuaternion); + this.unitZ.set(0, 0, 1).applyQuaternion(space === 'local' ? this.worldQuaternion : this.identityQuaternion); + + // Align the plane for current transform mode, axis and space. + + this.alignVector.copy(this.unitY); + + switch (this.mode) { + case 'translate': + case 'scale': { + switch (this.axis) { + case 'X': { + this.alignVector.copy(this.eye).cross(this.unitX); + this.dirVector.copy(this.unitX).cross(this.alignVector); + break; + } + + case 'Y': { + this.alignVector.copy(this.eye).cross(this.unitY); + this.dirVector.copy(this.unitY).cross(this.alignVector); + break; + } + + case 'Z': { + this.alignVector.copy(this.eye).cross(this.unitZ); + this.dirVector.copy(this.unitZ).cross(this.alignVector); + break; + } + + case 'XY': { + this.dirVector.copy(this.unitZ); + break; + } + + case 'YZ': { + this.dirVector.copy(this.unitX); + break; + } + + case 'XZ': { + this.alignVector.copy(this.unitZ); + this.dirVector.copy(this.unitY); + break; + } + + case 'XYZ': + case 'E': { + this.dirVector.set(0, 0, 0); + break; + } + } + + break; + } + + // eslint-disable-next-line unicorn/no-useless-switch-case -- exhaustive check + case 'rotate': + default: { + // Special case for rotate + this.dirVector.set(0, 0, 0); + } + } + + if (this.dirVector.length() === 0) { + // If in rotate mode, make the plane parallel to camera + this.quaternion.copy(this.cameraQuaternion); + } else { + this.tempMatrix.lookAt(this.tempVector.set(0, 0, 0), this.dirVector, this.alignVector); + + this.quaternion.setFromRotationMatrix(this.tempMatrix); + } + + super.updateMatrixWorld(); + }; +} + +export { TransformControls, TransformControlsGizmo, TransformControlsPlane }; diff --git a/packages/three/src/controls/viewport-gizmo-cube-axes.ts b/packages/three/src/controls/viewport-gizmo-cube-axes.ts new file mode 100644 index 000000000..622e1dd10 --- /dev/null +++ b/packages/three/src/controls/viewport-gizmo-cube-axes.ts @@ -0,0 +1,133 @@ +import * as THREE from 'three'; +import { Line2, LineGeometry, LineMaterial } from 'three/addons'; +import { gizmoBaseDistance } from '#utils/math.utils.js'; + +export type ViewportGizmoCubeAxesProps = { + readonly axesSize?: number; + /** + * The gizmo canvas size in CSS pixels. Used to set the correct + * `LineMaterial.resolution` so line widths render at the intended pixel size. + */ + readonly rendererSize?: number; + readonly xAxisColor?: string; + readonly yAxisColor?: string; + readonly zAxisColor?: string; + readonly xLabelColor?: string; + readonly yLabelColor?: string; + readonly zLabelColor?: string; + readonly lineOpacity?: number; + readonly lineWidth?: number; +}; + +export const createViewportGizmoCubeAxes = ({ + axesSize = 2.1, + rendererSize = 96, + xAxisColor = 'red', + yAxisColor = 'green', + zAxisColor = 'blue', + xLabelColor = 'red', + yLabelColor = 'green', + zLabelColor = 'blue', + lineOpacity = 0.6, + lineWidth = 1.5, +}: ViewportGizmoCubeAxesProps): THREE.Group => { + const axesLines = [ + { + id: 'x', + points: [new THREE.Vector3(0, 0, 0), new THREE.Vector3(axesSize, 0, 0)], + lineColor: xAxisColor, + labelColor: xLabelColor, + label: 'X', + }, + { + id: 'y', + points: [new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, axesSize, 0)], + lineColor: yAxisColor, + labelColor: yLabelColor, + label: 'Y', + }, + { + id: 'z', + points: [new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, axesSize)], + lineColor: zAxisColor, + labelColor: zLabelColor, + label: 'Z', + }, + ]; + + const axes = new THREE.Group(); + for (const line of axesLines) { + // Convert points to flat array for LineGeometry + const positions = []; + for (const point of line.points) { + positions.push(point.x, point.y, point.z); + } + + const geometry = new LineGeometry(); + geometry.setPositions(positions); + + const material = new LineMaterial({ + color: line.lineColor, + linewidth: lineWidth, + opacity: lineOpacity, + resolution: new THREE.Vector2(rendererSize, rendererSize), + }); + + const lineObject = new Line2(geometry, material); + axes.add(lineObject); + + // Add text label at the end of each axis + const endPoint = line.points[1]!; + + // Create a canvas for the text + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + const textCanvasSize = 64; + canvas.width = textCanvasSize; + canvas.height = textCanvasSize; + + if (context) { + // Set the entire canvas to transparent + context.clearRect(0, 0, canvas.width, canvas.height); + + // Draw the text with smaller font size + context.fillStyle = line.labelColor; + context.font = '100 48px monospace'; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.fillText(line.label, textCanvasSize / 2, textCanvasSize / 2); + } + + // Create texture from canvas + const texture = new THREE.CanvasTexture(canvas); + texture.needsUpdate = true; + + // Create a sprite with the texture. + // sizeAttenuation: true makes the sprite size proportional to + // 1 / (distance * tan(fov/2)). Because our FOV-sync distance compensation + // keeps distance * tan(fov/2) constant, the labels maintain a fixed visual + // size regardless of FOV. The scale values are multiplied by + // GIZMO_BASE_DISTANCE to preserve the same appearance at the default FOV. + const spriteMaterial = new THREE.SpriteMaterial({ + map: texture, + sizeAttenuation: true, + depthTest: true, + transparent: true, + }); + + // Set render order to ensure it renders on top + const sprite = new THREE.Sprite(spriteMaterial); + sprite.renderOrder = 3; + sprite.position.copy(endPoint); + sprite.scale.set(0.08 * gizmoBaseDistance, 0.06 * gizmoBaseDistance, 1); + + // Add increased offset to move labels further from line ends + const direction = new THREE.Vector3().subVectors(endPoint, new THREE.Vector3(0, 0, 0)).normalize(); + sprite.position.add(direction.multiplyScalar(0.2)); + + axes.add(sprite); + } + + axes.position.set(-axesSize / 2, -axesSize / 2, -axesSize / 2); + return axes; +}; diff --git a/packages/three/src/geometries/circle-geometry.ts b/packages/three/src/geometries/circle-geometry.ts new file mode 100644 index 000000000..123eeb326 --- /dev/null +++ b/packages/three/src/geometries/circle-geometry.ts @@ -0,0 +1,27 @@ +import { BufferGeometry, Float32BufferAttribute } from 'three'; + +// eslint-disable-next-line @typescript-eslint/naming-convention -- Three.js naming convention +export const CircleGeometry = ({ + radius, + arc, + arcOffset = 0, +}: { + radius: number; + arc: number; + arcOffset?: number; +}): BufferGeometry => { + const geometry = new BufferGeometry(); + const vertices = []; + + for (let i = 0; i <= 64 * arc; ++i) { + vertices.push( + 0, + Math.cos((i / 32) * Math.PI + arcOffset) * radius, + Math.sin((i / 32) * Math.PI + arcOffset) * radius, + ); + } + + geometry.setAttribute('position', new Float32BufferAttribute(vertices, 3)); + + return geometry; +}; diff --git a/packages/three/src/geometries/font-geometry.ts b/packages/three/src/geometries/font-geometry.ts new file mode 100644 index 000000000..38707fed8 --- /dev/null +++ b/packages/three/src/geometries/font-geometry.ts @@ -0,0 +1,17 @@ +import type { BufferGeometry } from 'three'; +import { ExtrudeGeometry } from 'three'; +import { FontLoader } from 'three/examples/jsm/Addons.js'; +import type { FontData } from 'three/examples/jsm/Addons.js'; +// eslint-disable-next-line import-x/no-extraneous-dependencies -- local file via package imports (#* → ./src/*) +import fontData from '#geometries/geist-mono.typeface.json'; + +// eslint-disable-next-line @typescript-eslint/naming-convention -- Three.js naming convention +export const FontGeometry = ({ text, depth, size }: { text: string; depth: number; size: number }): BufferGeometry => { + const loader = new FontLoader(); + const font = loader.parse(fontData as unknown as FontData); + const shapes = font.generateShapes(text, size); + const geometry = new ExtrudeGeometry(shapes, { depth, bevelEnabled: false }); + geometry.center(); + + return geometry; +}; diff --git a/packages/three/src/geometries/geist-mono.typeface.json b/packages/three/src/geometries/geist-mono.typeface.json new file mode 100644 index 000000000..8fc86e91e --- /dev/null +++ b/packages/three/src/geometries/geist-mono.typeface.json @@ -0,0 +1 @@ +{"glyphs":{"0":{"ha":833,"x_min":69,"x_max":764,"o":"m 417 -22 q 232 40 310 -22 q 112 217 154 101 q 69 492 69 333 q 112 767 69 650 q 233 946 154 883 q 417 1008 311 1008 q 601 946 522 1008 q 722 767 679 883 q 764 492 764 650 q 722 217 764 333 q 601 40 679 101 q 417 -22 524 -22 m 417 94 q 580 201 521 94 q 639 492 639 308 q 604 729 639 632 l 288 153 q 417 94 342 94 m 194 492 q 229 256 194 353 l 546 832 q 417 892 493 892 q 253 784 313 892 q 194 492 194 676 z "},"1":{"ha":833,"x_min":83,"x_max":750,"o":"m 83 114 l 394 114 l 394 731 l 136 731 l 136 833 l 269 833 q 383 868 349 833 q 417 986 417 903 l 514 986 l 514 114 l 750 114 l 750 0 l 83 0 l 83 114 z "},"2":{"ha":833,"x_min":69,"x_max":764,"o":"m 69 0 q 103 192 69 108 q 219 350 136 275 q 447 503 301 425 q 560 572 521 542 q 619 635 600 601 q 638 719 638 669 q 587 844 638 797 q 440 892 536 892 q 276 838 338 892 q 200 683 215 785 l 75 692 q 187 924 92 839 q 440 1008 282 1008 q 613 972 540 1008 q 724 872 686 936 q 763 722 763 807 q 738 592 763 646 q 655 492 713 539 q 492 389 597 444 q 292 248 363 321 q 217 117 221 175 l 764 117 l 764 0 l 69 0 z "},"3":{"ha":833,"x_min":69,"x_max":764,"o":"m 408 -22 q 164 53 251 -22 q 69 254 76 129 l 193 263 q 258 135 201 176 q 408 94 315 94 q 574 138 508 94 q 639 275 639 182 q 578 417 639 369 q 415 465 517 465 l 338 465 l 338 582 l 415 582 q 551 619 499 582 q 604 736 604 657 q 556 852 604 813 q 415 892 507 892 q 217 751 236 892 l 92 760 q 190 941 106 874 q 415 1008 275 1008 q 644 936 560 1008 q 729 742 729 864 q 558 531 729 583 q 709 437 654 504 q 764 275 764 369 q 718 115 764 182 q 592 13 672 49 q 408 -22 511 -22 z "},"4":{"ha":833,"x_min":56,"x_max":778,"o":"m 539 214 l 56 214 l 56 322 l 531 986 l 658 986 l 658 331 l 778 331 l 778 214 l 658 214 l 658 0 l 539 0 l 539 214 m 539 331 l 539 803 l 200 331 l 539 331 z "},"5":{"ha":833,"x_min":83,"x_max":750,"o":"m 414 -22 q 183 53 272 -22 q 83 254 94 129 l 208 263 q 272 138 218 181 q 414 94 326 94 q 568 154 511 94 q 625 317 625 214 q 569 481 625 421 q 417 542 514 542 q 304 513 357 542 q 228 432 251 483 l 100 432 l 165 986 l 690 986 l 690 869 l 275 869 l 239 572 q 326 634 274 613 q 433 656 379 656 q 599 612 526 656 q 710 491 671 568 q 750 317 750 414 q 707 141 750 218 q 588 21 664 64 q 414 -22 511 -22 z "},"6":{"ha":833,"x_min":69,"x_max":764,"o":"m 419 -22 q 161 92 253 -22 q 69 417 69 206 q 161 847 69 685 q 458 1008 253 1008 q 764 775 699 1008 l 639 764 q 578 859 621 826 q 458 892 536 892 q 276 803 344 892 q 194 538 208 714 q 297 629 232 594 q 442 664 361 664 q 610 622 538 664 q 724 504 683 581 q 764 328 764 428 q 721 142 764 221 q 599 20 678 63 q 419 -22 521 -22 m 422 94 q 580 157 521 94 q 639 328 639 219 q 583 490 639 429 q 433 550 526 550 q 266 489 332 550 q 200 328 200 428 q 260 160 200 225 q 422 94 321 94 z "},"7":{"ha":833,"x_min":83,"x_max":750,"o":"m 269 0 q 361 452 269 226 q 619 869 453 678 l 83 869 l 83 986 l 750 986 l 750 878 q 477 456 563 664 q 392 0 392 249 l 269 0 z "},"8":{"ha":833,"x_min":69,"x_max":764,"o":"m 417 -22 q 238 12 317 -22 q 115 112 160 46 q 69 271 69 178 q 120 432 69 365 q 260 531 171 499 q 117 738 117 590 q 155 876 117 814 q 262 973 193 938 q 417 1008 331 1008 q 572 973 503 1008 q 678 876 640 938 q 717 738 717 814 q 574 531 717 590 q 713 432 663 499 q 764 271 764 365 q 719 112 764 178 q 595 12 674 46 q 417 -22 517 -22 m 417 94 q 578 141 518 94 q 639 278 639 188 q 582 417 639 367 q 417 468 525 468 q 251 417 308 468 q 194 278 194 367 q 255 141 194 188 q 417 94 315 94 m 417 579 q 545 619 499 579 q 592 738 592 660 q 544 850 592 808 q 417 892 497 892 q 289 850 336 892 q 242 738 242 808 q 289 619 242 660 q 417 579 336 579 z "},"9":{"ha":833,"x_min":69,"x_max":764,"o":"m 414 1008 q 672 894 581 1008 q 764 569 764 781 q 672 140 764 301 q 375 -22 581 -22 q 69 211 135 -22 l 194 222 q 255 127 213 160 q 375 94 297 94 q 557 183 489 94 q 639 449 625 272 q 537 357 601 392 q 392 322 472 322 q 223 364 296 322 q 110 482 150 406 q 69 658 69 558 q 113 844 69 765 q 234 966 156 924 q 414 1008 313 1008 m 411 892 q 253 829 313 892 q 194 658 194 767 q 251 497 194 557 q 400 436 307 436 q 567 497 501 436 q 633 658 633 558 q 573 826 633 761 q 411 892 513 892 z "}," ":{"ha":833,"x_min":0,"x_max":0,"o":""},"A":{"ha":833,"x_min":36,"x_max":797,"o":"m 336 986 l 497 986 l 797 0 l 675 0 l 594 275 l 239 275 l 158 0 l 36 0 l 336 986 m 561 389 l 417 878 l 272 389 l 561 389 z "},"Á":{"ha":833,"x_min":36,"x_max":797,"o":"m 336 986 l 497 986 l 797 0 l 675 0 l 594 275 l 239 275 l 158 0 l 36 0 l 336 986 m 561 389 l 417 878 l 272 389 l 561 389 m 456 1264 l 586 1264 l 461 1086 l 364 1086 l 456 1264 z "},"Ă":{"ha":833,"x_min":36,"x_max":797,"o":"m 336 986 l 497 986 l 797 0 l 675 0 l 594 275 l 239 275 l 158 0 l 36 0 l 336 986 m 561 389 l 417 878 l 272 389 l 561 389 m 417 1088 q 279 1135 332 1088 q 222 1265 226 1183 l 303 1265 q 417 1174 315 1174 q 531 1265 518 1174 l 611 1265 q 554 1135 607 1183 q 417 1088 501 1088 z "},"Ǎ":{"ha":833,"x_min":36,"x_max":797,"o":"m 336 986 l 497 986 l 797 0 l 675 0 l 594 275 l 239 275 l 158 0 l 36 0 l 336 986 m 561 389 l 417 878 l 272 389 l 561 389 m 319 1264 l 417 1139 l 514 1264 l 611 1264 l 482 1079 l 351 1079 l 222 1264 l 319 1264 z "},"Â":{"ha":833,"x_min":36,"x_max":797,"o":"m 336 986 l 497 986 l 797 0 l 675 0 l 594 275 l 239 275 l 158 0 l 36 0 l 336 986 m 561 389 l 417 878 l 272 389 l 561 389 m 350 1265 l 481 1265 l 610 1081 l 513 1081 l 415 1206 l 318 1081 l 221 1081 l 350 1265 z "},"Ä":{"ha":833,"x_min":36,"x_max":797,"o":"m 336 986 l 497 986 l 797 0 l 675 0 l 594 275 l 239 275 l 158 0 l 36 0 l 336 986 m 561 389 l 417 878 l 272 389 l 561 389 z "},"À":{"ha":833,"x_min":36,"x_max":797,"o":"m 336 986 l 497 986 l 797 0 l 675 0 l 594 275 l 239 275 l 158 0 l 36 0 l 336 986 m 561 389 l 417 878 l 272 389 l 561 389 m 250 1264 l 381 1264 l 472 1086 l 375 1086 l 250 1264 z "},"Ā":{"ha":833,"x_min":36,"x_max":797,"o":"m 336 986 l 497 986 l 797 0 l 675 0 l 594 275 l 239 275 l 158 0 l 36 0 l 336 986 m 561 389 l 417 878 l 272 389 l 561 389 m 229 1218 l 601 1218 l 601 1118 l 229 1118 l 229 1218 z "},"Ą":{"ha":833,"x_min":36,"x_max":831,"o":"m 336 986 l 497 986 l 797 0 l 783 0 l 735 -62 q 701 -117 710 -96 q 692 -156 692 -137 q 733 -197 692 -197 q 768 -192 750 -197 q 797 -178 786 -187 l 831 -256 q 782 -282 814 -272 q 711 -292 750 -292 q 619 -262 653 -292 q 586 -181 586 -232 q 643 -42 586 -112 l 676 0 l 675 0 l 594 275 l 239 275 l 158 0 l 36 0 l 336 986 m 561 389 l 417 878 l 272 389 l 561 389 z "},"Å":{"ha":833,"x_min":36,"x_max":797,"o":"m 336 986 l 497 986 l 797 0 l 675 0 l 594 275 l 239 275 l 158 0 l 36 0 l 336 986 m 561 389 l 417 878 l 272 389 l 561 389 m 417 1064 q 326 1101 363 1064 q 289 1192 289 1138 q 326 1283 289 1246 q 417 1319 363 1319 q 508 1283 471 1319 q 544 1192 544 1246 q 508 1101 544 1138 q 417 1064 471 1064 m 417 1133 q 458 1150 442 1133 q 475 1192 475 1167 q 458 1233 475 1217 q 417 1250 442 1250 q 375 1233 392 1250 q 358 1192 358 1217 q 375 1150 358 1167 q 417 1133 392 1133 z "},"Ã":{"ha":833,"x_min":36,"x_max":797,"o":"m 336 986 l 497 986 l 797 0 l 675 0 l 594 275 l 239 275 l 158 0 l 36 0 l 336 986 m 561 389 l 417 878 l 272 389 l 561 389 m 500 1097 q 403 1139 454 1097 q 363 1168 376 1161 q 331 1175 350 1175 q 269 1088 274 1175 l 197 1088 q 235 1208 199 1165 q 331 1250 272 1250 q 385 1239 361 1250 q 436 1206 410 1228 q 474 1180 458 1188 q 506 1172 489 1172 q 546 1192 532 1172 q 564 1264 560 1213 l 636 1264 q 596 1141 632 1185 q 500 1097 560 1097 z "},"Æ":{"ha":833,"x_min":14,"x_max":808,"o":"m 339 986 l 801 986 l 801 869 l 565 869 l 565 551 l 792 551 l 792 438 l 565 438 l 565 117 l 808 117 l 808 0 l 446 0 l 446 281 l 226 281 l 138 0 l 14 0 l 339 986 m 446 397 l 446 874 l 417 874 l 263 397 l 446 397 z "},"B":{"ha":833,"x_min":125,"x_max":772,"o":"m 125 986 l 403 986 q 647 915 563 986 q 731 717 731 844 q 682 580 731 636 q 554 508 633 524 q 715 433 657 494 q 772 275 772 371 q 683 74 772 147 q 438 0 593 0 l 125 0 l 125 986 m 439 114 q 594 156 540 114 q 647 275 647 199 q 594 400 647 356 q 439 444 540 444 l 244 444 l 244 114 l 439 114 m 408 558 q 606 714 606 558 q 408 872 606 872 l 244 872 l 244 558 l 408 558 z "},"C":{"ha":833,"x_min":53,"x_max":772,"o":"m 419 -22 q 149 115 244 -22 q 53 492 53 253 q 149 871 53 733 q 419 1008 244 1008 q 642 923 550 1008 q 764 685 733 838 l 636 676 q 556 836 613 781 q 419 892 499 892 q 240 788 303 892 q 178 492 178 685 q 240 197 178 300 q 419 94 303 94 q 566 156 507 94 q 646 332 625 217 l 772 325 q 651 70 744 163 q 419 -22 558 -22 z "},"Ć":{"ha":833,"x_min":53,"x_max":772,"o":"m 419 -22 q 149 115 244 -22 q 53 492 53 253 q 149 871 53 733 q 419 1008 244 1008 q 642 923 550 1008 q 764 685 733 838 l 636 676 q 556 836 613 781 q 419 892 499 892 q 240 788 303 892 q 178 492 178 685 q 240 197 178 300 q 419 94 303 94 q 566 156 507 94 q 646 332 625 217 l 772 325 q 651 70 744 163 q 419 -22 558 -22 m 458 1264 l 589 1264 l 464 1086 l 367 1086 l 458 1264 z "},"Č":{"ha":833,"x_min":53,"x_max":772,"o":"m 419 -22 q 149 115 244 -22 q 53 492 53 253 q 149 871 53 733 q 419 1008 244 1008 q 642 923 550 1008 q 764 685 733 838 l 636 676 q 556 836 613 781 q 419 892 499 892 q 240 788 303 892 q 178 492 178 685 q 240 197 178 300 q 419 94 303 94 q 566 156 507 94 q 646 332 625 217 l 772 325 q 651 70 744 163 q 419 -22 558 -22 m 322 1264 l 419 1139 l 517 1264 l 614 1264 l 485 1079 l 354 1079 l 225 1264 l 322 1264 z "},"Ç":{"ha":833,"x_min":53,"x_max":772,"o":"m 428 -306 q 344 -284 381 -306 q 265 -212 308 -262 l 322 -160 q 369 -211 347 -192 q 422 -231 392 -231 q 470 -215 453 -231 q 488 -171 488 -199 q 468 -129 488 -144 q 415 -114 449 -114 q 382 -118 401 -114 l 343 -67 l 385 -21 q 140 128 226 -7 q 53 492 53 264 q 149 871 53 733 q 419 1008 244 1008 q 642 923 550 1008 q 764 685 733 838 l 636 676 q 556 836 613 781 q 419 892 499 892 q 240 788 303 892 q 178 492 178 685 q 240 197 178 300 q 419 94 303 94 q 566 156 507 94 q 646 332 625 217 l 772 325 q 665 83 747 174 q 458 -21 582 -8 l 429 -51 q 526 -86 489 -51 q 564 -176 564 -121 q 524 -268 564 -231 q 428 -306 485 -306 z "},"Ĉ":{"ha":833,"x_min":53,"x_max":772,"o":"m 419 -22 q 149 115 244 -22 q 53 492 53 253 q 149 871 53 733 q 419 1008 244 1008 q 642 923 550 1008 q 764 685 733 838 l 636 676 q 556 836 613 781 q 419 892 499 892 q 240 788 303 892 q 178 492 178 685 q 240 197 178 300 q 419 94 303 94 q 566 156 507 94 q 646 332 625 217 l 772 325 q 651 70 744 163 q 419 -22 558 -22 m 353 1265 l 483 1265 l 613 1081 l 515 1081 l 418 1206 l 321 1081 l 224 1081 l 353 1265 z "},"Ċ":{"ha":833,"x_min":53,"x_max":772,"o":"m 419 -22 q 149 115 244 -22 q 53 492 53 253 q 149 871 53 733 q 419 1008 244 1008 q 642 923 550 1008 q 764 685 733 838 l 636 676 q 556 836 613 781 q 419 892 499 892 q 240 788 303 892 q 178 492 178 685 q 240 197 178 300 q 419 94 303 94 q 566 156 507 94 q 646 332 625 217 l 772 325 q 651 70 744 163 q 419 -22 558 -22 m 357 1238 l 479 1238 l 479 1101 l 357 1101 l 357 1238 z "},"D":{"ha":833,"x_min":125,"x_max":781,"o":"m 125 986 l 358 986 q 581 928 486 986 q 728 759 676 871 q 781 492 781 647 q 728 225 781 336 q 581 57 676 114 q 358 0 486 0 l 125 0 l 125 986 m 351 114 q 576 210 497 114 q 656 492 656 307 q 576 775 656 678 q 351 872 497 872 l 244 872 l 244 114 l 351 114 z "},"Ď":{"ha":833,"x_min":125,"x_max":781,"o":"m 125 986 l 358 986 q 581 928 486 986 q 728 759 676 871 q 781 492 781 647 q 728 225 781 336 q 581 57 676 114 q 358 0 486 0 l 125 0 l 125 986 m 351 114 q 576 210 497 114 q 656 492 656 307 q 576 775 656 678 q 351 872 497 872 l 244 872 l 244 114 l 351 114 m 321 1264 l 418 1139 l 515 1264 l 613 1264 l 483 1079 l 353 1079 l 224 1264 l 321 1264 z "},"Đ":{"ha":833,"x_min":-14,"x_max":835,"o":"m -14 544 l 179 544 l 179 986 l 413 986 q 635 928 540 986 q 783 759 731 871 q 835 492 835 647 q 783 225 835 336 q 635 57 731 114 q 413 0 540 0 l 179 0 l 179 442 l -14 442 l -14 544 m 406 114 q 631 210 551 114 q 710 492 710 307 q 631 775 710 678 q 406 872 551 872 l 299 872 l 299 544 l 431 544 l 431 442 l 299 442 l 299 114 l 406 114 z "},"Ð":{"ha":833,"x_min":-14,"x_max":835,"o":"m -14 544 l 179 544 l 179 986 l 413 986 q 635 928 540 986 q 783 759 731 871 q 835 492 835 647 q 783 225 835 336 q 635 57 731 114 q 413 0 540 0 l 179 0 l 179 442 l -14 442 l -14 544 m 406 114 q 631 210 551 114 q 710 492 710 307 q 631 775 710 678 q 406 872 551 872 l 299 872 l 299 544 l 431 544 l 431 442 l 299 442 l 299 114 l 406 114 z "},"E":{"ha":833,"x_min":125,"x_max":736,"o":"m 125 986 l 725 986 l 725 872 l 244 872 l 244 553 l 708 553 l 708 439 l 244 439 l 244 114 l 736 114 l 736 0 l 125 0 l 125 986 z "},"É":{"ha":833,"x_min":125,"x_max":736,"o":"m 125 986 l 725 986 l 725 872 l 244 872 l 244 553 l 708 553 l 708 439 l 244 439 l 244 114 l 736 114 l 736 0 l 125 0 l 125 986 m 481 1265 l 611 1265 l 486 1088 l 389 1088 l 481 1265 z "},"Ě":{"ha":833,"x_min":125,"x_max":736,"o":"m 125 986 l 725 986 l 725 872 l 244 872 l 244 553 l 708 553 l 708 439 l 244 439 l 244 114 l 736 114 l 736 0 l 125 0 l 125 986 m 344 1265 l 442 1140 l 539 1265 l 636 1265 l 507 1081 l 376 1081 l 247 1265 l 344 1265 z "},"Ê":{"ha":833,"x_min":125,"x_max":736,"o":"m 125 986 l 725 986 l 725 872 l 244 872 l 244 553 l 708 553 l 708 439 l 244 439 l 244 114 l 736 114 l 736 0 l 125 0 l 125 986 m 375 1267 l 506 1267 l 635 1082 l 538 1082 l 440 1207 l 343 1082 l 246 1082 l 375 1267 z "},"Ë":{"ha":833,"x_min":125,"x_max":736,"o":"m 125 986 l 725 986 l 725 872 l 244 872 l 244 553 l 708 553 l 708 439 l 244 439 l 244 114 l 736 114 l 736 0 l 125 0 l 125 986 z "},"Ė":{"ha":833,"x_min":125,"x_max":736,"o":"m 125 986 l 725 986 l 725 872 l 244 872 l 244 553 l 708 553 l 708 439 l 244 439 l 244 114 l 736 114 l 736 0 l 125 0 l 125 986 m 379 1239 l 501 1239 l 501 1103 l 379 1103 l 379 1239 z "},"È":{"ha":833,"x_min":125,"x_max":736,"o":"m 125 986 l 725 986 l 725 872 l 244 872 l 244 553 l 708 553 l 708 439 l 244 439 l 244 114 l 736 114 l 736 0 l 125 0 l 125 986 m 275 1265 l 406 1265 l 497 1088 l 400 1088 l 275 1265 z "},"Ē":{"ha":833,"x_min":125,"x_max":736,"o":"m 125 986 l 725 986 l 725 872 l 244 872 l 244 553 l 708 553 l 708 439 l 244 439 l 244 114 l 736 114 l 736 0 l 125 0 l 125 986 m 254 1219 l 626 1219 l 626 1119 l 254 1119 l 254 1219 z "},"Ę":{"ha":833,"x_min":125,"x_max":789,"o":"m 125 986 l 725 986 l 725 872 l 244 872 l 244 553 l 708 553 l 708 439 l 244 439 l 244 114 l 736 114 l 736 1 l 742 0 l 693 -62 q 659 -117 668 -96 q 650 -156 650 -137 q 692 -197 650 -197 q 726 -192 708 -197 q 756 -178 744 -187 l 789 -256 q 740 -282 772 -272 q 669 -292 708 -292 q 578 -262 611 -292 q 544 -181 544 -232 q 601 -42 544 -112 l 635 0 l 125 0 l 125 986 z "},"Ẽ":{"ha":833,"x_min":125,"x_max":736,"o":"m 125 986 l 725 986 l 725 872 l 244 872 l 244 553 l 708 553 l 708 439 l 244 439 l 244 114 l 736 114 l 736 0 l 125 0 l 125 986 m 525 1099 q 428 1140 479 1099 q 388 1169 401 1163 q 356 1176 375 1176 q 294 1089 299 1176 l 222 1089 q 260 1209 224 1167 q 356 1251 297 1251 q 410 1240 386 1251 q 461 1207 435 1229 q 499 1181 483 1189 q 531 1174 514 1174 q 571 1194 557 1174 q 589 1265 585 1214 l 661 1265 q 621 1142 657 1186 q 525 1099 585 1099 z "},"Ə":{"ha":833,"x_min":39,"x_max":794,"o":"m 406 -22 q 218 40 300 -22 q 90 215 136 103 q 40 472 43 328 l 39 542 l 668 542 q 626 724 661 644 q 535 847 592 803 q 406 892 478 892 q 274 837 332 892 q 192 696 215 782 l 65 707 q 198 927 107 846 q 406 1008 289 1008 q 609 943 521 1008 q 746 760 697 878 q 794 493 794 643 q 744 232 794 350 q 604 46 693 114 q 406 -22 515 -22 m 406 94 q 576 180 510 94 q 667 425 643 265 l 168 425 q 248 180 188 265 q 406 94 308 94 z "},"F":{"ha":833,"x_min":139,"x_max":750,"o":"m 139 986 l 750 986 l 750 872 l 258 872 l 258 539 l 725 539 l 725 425 l 258 425 l 258 0 l 139 0 l 139 986 z "},"G":{"ha":833,"x_min":53,"x_max":769,"o":"m 414 -22 q 226 37 308 -22 q 99 211 144 96 q 53 492 53 326 q 97 767 53 650 q 223 946 140 883 q 419 1008 306 1008 q 642 922 551 1008 q 765 692 732 836 l 640 683 q 560 835 615 779 q 419 892 504 892 q 241 788 304 892 q 178 492 178 683 q 242 198 178 301 q 425 94 307 94 q 544 132 492 94 q 626 235 596 169 q 658 383 656 301 l 422 383 l 422 497 l 769 497 l 769 0 l 658 0 l 656 126 q 414 -22 578 -22 z "},"Ğ":{"ha":833,"x_min":53,"x_max":769,"o":"m 414 -22 q 226 37 308 -22 q 99 211 144 96 q 53 492 53 326 q 97 767 53 650 q 223 946 140 883 q 419 1008 306 1008 q 642 922 551 1008 q 765 692 732 836 l 640 683 q 560 835 615 779 q 419 892 504 892 q 241 788 304 892 q 178 492 178 683 q 242 198 178 301 q 425 94 307 94 q 544 132 492 94 q 626 235 596 169 q 658 383 656 301 l 422 383 l 422 497 l 769 497 l 769 0 l 658 0 l 656 126 q 414 -22 578 -22 m 415 1088 q 278 1135 331 1088 q 221 1265 225 1183 l 301 1265 q 415 1174 314 1174 q 529 1265 517 1174 l 610 1265 q 553 1135 606 1183 q 415 1088 500 1088 z "},"Ǧ":{"ha":833,"x_min":53,"x_max":769,"o":"m 414 -22 q 226 37 308 -22 q 99 211 144 96 q 53 492 53 326 q 97 767 53 650 q 223 946 140 883 q 419 1008 306 1008 q 642 922 551 1008 q 765 692 732 836 l 640 683 q 560 835 615 779 q 419 892 504 892 q 241 788 304 892 q 178 492 178 683 q 242 198 178 301 q 425 94 307 94 q 544 132 492 94 q 626 235 596 169 q 658 383 656 301 l 422 383 l 422 497 l 769 497 l 769 0 l 658 0 l 656 126 q 414 -22 578 -22 m 318 1264 l 415 1139 l 513 1264 l 610 1264 l 481 1079 l 350 1079 l 221 1264 l 318 1264 z "},"Ĝ":{"ha":833,"x_min":53,"x_max":769,"o":"m 414 -22 q 226 37 308 -22 q 99 211 144 96 q 53 492 53 326 q 97 767 53 650 q 223 946 140 883 q 419 1008 306 1008 q 642 922 551 1008 q 765 692 732 836 l 640 683 q 560 835 615 779 q 419 892 504 892 q 241 788 304 892 q 178 492 178 683 q 242 198 178 301 q 425 94 307 94 q 544 132 492 94 q 626 235 596 169 q 658 383 656 301 l 422 383 l 422 497 l 769 497 l 769 0 l 658 0 l 656 126 q 414 -22 578 -22 m 349 1265 l 479 1265 l 608 1081 l 511 1081 l 414 1206 l 317 1081 l 219 1081 l 349 1265 z "},"Ģ":{"ha":833,"x_min":53,"x_max":769,"o":"m 414 -22 q 226 37 308 -22 q 99 211 144 96 q 53 492 53 326 q 97 767 53 650 q 223 946 140 883 q 419 1008 306 1008 q 642 922 551 1008 q 765 692 732 836 l 640 683 q 560 835 615 779 q 419 892 504 892 q 241 788 304 892 q 178 492 178 683 q 242 198 178 301 q 425 94 307 94 q 544 132 492 94 q 626 235 596 169 q 658 383 656 301 l 422 383 l 422 497 l 769 497 l 769 0 l 658 0 l 656 126 q 414 -22 578 -22 m 407 -172 l 360 -172 l 360 -47 l 499 -47 l 499 -167 l 410 -297 l 332 -297 l 407 -172 z "},"Ġ":{"ha":833,"x_min":53,"x_max":769,"o":"m 414 -22 q 226 37 308 -22 q 99 211 144 96 q 53 492 53 326 q 97 767 53 650 q 223 946 140 883 q 419 1008 306 1008 q 642 922 551 1008 q 765 692 732 836 l 640 683 q 560 835 615 779 q 419 892 504 892 q 241 788 304 892 q 178 492 178 683 q 242 198 178 301 q 425 94 307 94 q 544 132 492 94 q 626 235 596 169 q 658 383 656 301 l 422 383 l 422 497 l 769 497 l 769 0 l 658 0 l 656 126 q 414 -22 578 -22 m 353 1238 l 475 1238 l 475 1101 l 353 1101 l 353 1238 z "},"Ḡ":{"ha":833,"x_min":53,"x_max":769,"o":"m 414 -22 q 226 37 308 -22 q 99 211 144 96 q 53 492 53 326 q 97 767 53 650 q 223 946 140 883 q 419 1008 306 1008 q 642 922 551 1008 q 765 692 732 836 l 640 683 q 560 835 615 779 q 419 892 504 892 q 241 788 304 892 q 178 492 178 683 q 242 198 178 301 q 425 94 307 94 q 544 132 492 94 q 626 235 596 169 q 658 383 656 301 l 422 383 l 422 497 l 769 497 l 769 0 l 658 0 l 656 126 q 414 -22 578 -22 m 228 1218 l 600 1218 l 600 1118 l 228 1118 l 228 1218 z "},"Ǥ":{"ha":833,"x_min":28,"x_max":821,"o":"m 418 -22 q 217 46 306 -22 q 78 232 128 114 q 28 492 28 350 q 78 753 28 635 q 217 940 128 871 q 418 1008 306 1008 q 649 922 551 1008 q 779 697 746 835 l 654 689 q 566 835 629 779 q 418 892 503 892 q 276 838 336 892 q 184 692 215 783 q 153 492 153 600 q 184 293 153 383 q 276 149 215 203 q 418 94 336 94 q 536 123 485 94 q 619 204 588 151 l 418 204 l 418 307 l 656 307 q 661 382 661 339 l 418 382 l 418 499 l 788 499 l 788 438 q 776 307 788 371 l 821 307 l 821 204 l 747 204 q 618 37 703 96 q 418 -22 533 -22 z "},"H":{"ha":833,"x_min":94,"x_max":739,"o":"m 94 986 l 214 986 l 214 553 l 619 553 l 619 986 l 739 986 l 739 0 l 619 0 l 619 439 l 214 439 l 214 0 l 94 0 l 94 986 z "},"Ħ":{"ha":833,"x_min":33,"x_max":800,"o":"m 588 444 l 246 444 l 246 0 l 126 0 l 126 735 l 33 735 l 33 851 l 126 851 l 126 986 l 246 986 l 246 851 l 588 851 l 588 986 l 707 986 l 707 851 l 800 851 l 800 735 l 707 735 l 707 0 l 588 0 l 588 444 m 588 561 l 588 735 l 246 735 l 246 561 l 588 561 z "},"Ĥ":{"ha":833,"x_min":94,"x_max":739,"o":"m 94 986 l 214 986 l 214 553 l 619 553 l 619 986 l 739 986 l 739 0 l 619 0 l 619 439 l 214 439 l 214 0 l 94 0 l 94 986 m 350 1265 l 481 1265 l 610 1081 l 513 1081 l 415 1206 l 318 1081 l 221 1081 l 350 1265 z "},"I":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 114 l 357 114 l 357 872 l 111 872 l 111 986 l 722 986 l 722 872 l 476 872 l 476 114 l 722 114 l 722 0 l 111 0 l 111 114 z "},"Í":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 114 l 357 114 l 357 872 l 111 872 l 111 986 l 722 986 l 722 872 l 476 872 l 476 114 l 722 114 l 722 0 l 111 0 l 111 114 m 456 1264 l 586 1264 l 461 1086 l 364 1086 l 456 1264 z "},"Î":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 114 l 357 114 l 357 872 l 111 872 l 111 986 l 722 986 l 722 872 l 476 872 l 476 114 l 722 114 l 722 0 l 111 0 l 111 114 m 350 1265 l 481 1265 l 610 1081 l 513 1081 l 415 1206 l 318 1081 l 221 1081 l 350 1265 z "},"Ï":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 114 l 357 114 l 357 872 l 111 872 l 111 986 l 722 986 l 722 872 l 476 872 l 476 114 l 722 114 l 722 0 l 111 0 l 111 114 z "},"İ":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 114 l 357 114 l 357 872 l 111 872 l 111 986 l 722 986 l 722 872 l 476 872 l 476 114 l 722 114 l 722 0 l 111 0 l 111 114 m 354 1238 l 476 1238 l 476 1101 l 354 1101 l 354 1238 z "},"Ì":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 114 l 357 114 l 357 872 l 111 872 l 111 986 l 722 986 l 722 872 l 476 872 l 476 114 l 722 114 l 722 0 l 111 0 l 111 114 m 250 1264 l 381 1264 l 472 1086 l 375 1086 l 250 1264 z "},"Ī":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 114 l 357 114 l 357 872 l 111 872 l 111 986 l 722 986 l 722 872 l 476 872 l 476 114 l 722 114 l 722 0 l 111 0 l 111 114 m 229 1218 l 601 1218 l 601 1118 l 229 1118 l 229 1218 z "},"Į":{"ha":833,"x_min":111,"x_max":769,"o":"m 111 114 l 357 114 l 357 872 l 111 872 l 111 986 l 722 986 l 722 872 l 476 872 l 476 114 l 722 114 l 722 0 l 674 -62 q 640 -117 649 -96 q 631 -156 631 -137 q 672 -197 631 -197 q 707 -192 689 -197 q 736 -178 725 -187 l 769 -256 q 721 -282 753 -272 q 650 -292 689 -292 q 558 -262 592 -292 q 525 -181 525 -232 q 582 -42 525 -112 l 615 0 l 111 0 l 111 114 z "},"Ĩ":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 114 l 357 114 l 357 872 l 111 872 l 111 986 l 722 986 l 722 872 l 476 872 l 476 114 l 722 114 l 722 0 l 111 0 l 111 114 m 500 1097 q 403 1139 454 1097 q 363 1168 376 1161 q 331 1175 350 1175 q 269 1088 274 1175 l 197 1088 q 235 1208 199 1165 q 331 1250 272 1250 q 385 1239 361 1250 q 436 1206 410 1228 q 474 1180 458 1188 q 506 1172 489 1172 q 546 1192 532 1172 q 564 1264 560 1213 l 636 1264 q 596 1141 632 1185 q 500 1097 560 1097 z "},"J":{"ha":833,"x_min":56,"x_max":708,"o":"m 382 -22 q 147 71 235 -22 q 56 319 60 164 l 172 331 q 228 152 176 210 q 382 94 279 94 q 589 331 589 94 l 589 986 l 708 986 l 708 331 q 620 72 708 167 q 382 -22 532 -22 z "},"Ĵ":{"ha":833,"x_min":56,"x_max":839,"o":"m 382 -22 q 147 71 235 -22 q 56 319 60 164 l 172 331 q 228 152 176 210 q 382 94 279 94 q 589 331 589 94 l 589 986 l 708 986 l 708 331 q 620 72 708 167 q 382 -22 532 -22 m 579 1265 l 710 1265 l 839 1081 l 742 1081 l 644 1206 l 547 1081 l 450 1081 l 579 1265 z "},"K":{"ha":833,"x_min":83,"x_max":778,"o":"m 83 986 l 203 986 l 203 550 l 603 986 l 750 986 l 375 572 l 778 0 l 631 0 l 292 489 l 203 394 l 203 0 l 83 0 l 83 986 z "},"Ǩ":{"ha":833,"x_min":83,"x_max":778,"o":"m 83 986 l 203 986 l 203 550 l 603 986 l 750 986 l 375 572 l 778 0 l 631 0 l 292 489 l 203 394 l 203 0 l 83 0 l 83 986 m 288 1264 l 385 1139 l 482 1264 l 579 1264 l 450 1079 l 319 1079 l 190 1264 l 288 1264 z "},"Ķ":{"ha":833,"x_min":83,"x_max":778,"o":"m 83 986 l 203 986 l 203 550 l 603 986 l 750 986 l 375 572 l 778 0 l 631 0 l 292 489 l 203 394 l 203 0 l 83 0 l 83 986 m 386 -172 l 339 -172 l 339 -47 l 478 -47 l 478 -167 l 389 -297 l 311 -297 l 386 -172 z "},"L":{"ha":833,"x_min":139,"x_max":750,"o":"m 139 986 l 258 986 l 258 114 l 750 114 l 750 0 l 139 0 l 139 986 z "},"Ĺ":{"ha":833,"x_min":139,"x_max":750,"o":"m 139 986 l 258 986 l 258 114 l 750 114 l 750 0 l 139 0 l 139 986 m 238 1264 l 368 1264 l 243 1086 l 146 1086 l 238 1264 z "},"Ľ":{"ha":833,"x_min":139,"x_max":750,"o":"m 139 986 l 258 986 l 258 114 l 750 114 l 750 0 l 139 0 l 139 986 m 458 986 l 574 986 l 501 650 l 417 650 l 458 986 z "},"Ļ":{"ha":833,"x_min":139,"x_max":750,"o":"m 139 986 l 258 986 l 258 114 l 750 114 l 750 0 l 139 0 l 139 986 m 438 -172 l 390 -172 l 390 -47 l 529 -47 l 529 -167 l 440 -297 l 363 -297 l 438 -172 z "},"Ł":{"ha":833,"x_min":-36,"x_max":768,"o":"m -36 515 l 157 606 l 157 986 l 276 986 l 276 661 l 392 715 l 442 610 l 276 532 l 276 114 l 768 114 l 768 0 l 157 0 l 157 476 l 14 410 l -36 515 z "},"M":{"ha":833,"x_min":83,"x_max":750,"o":"m 203 826 l 203 0 l 83 0 l 83 986 l 264 986 l 417 300 l 569 986 l 750 986 l 750 0 l 631 0 l 631 826 l 469 111 l 364 111 l 203 826 z "},"N":{"ha":833,"x_min":97,"x_max":736,"o":"m 97 986 l 264 986 l 617 158 l 617 986 l 736 986 l 736 0 l 569 0 l 217 828 l 217 0 l 97 0 l 97 986 z "},"Ń":{"ha":833,"x_min":97,"x_max":736,"o":"m 97 986 l 264 986 l 617 158 l 617 986 l 736 986 l 736 0 l 569 0 l 217 828 l 217 0 l 97 0 l 97 986 m 456 1264 l 586 1264 l 461 1086 l 364 1086 l 456 1264 z "},"Ň":{"ha":833,"x_min":97,"x_max":736,"o":"m 97 986 l 264 986 l 617 158 l 617 986 l 736 986 l 736 0 l 569 0 l 217 828 l 217 0 l 97 0 l 97 986 m 319 1264 l 417 1139 l 514 1264 l 611 1264 l 482 1079 l 351 1079 l 222 1264 l 319 1264 z "},"Ņ":{"ha":833,"x_min":97,"x_max":736,"o":"m 97 986 l 264 986 l 617 158 l 617 986 l 736 986 l 736 0 l 569 0 l 217 828 l 217 0 l 97 0 l 97 986 m 408 -172 l 361 -172 l 361 -47 l 500 -47 l 500 -167 l 411 -297 l 333 -297 l 408 -172 z "},"Ñ":{"ha":833,"x_min":97,"x_max":736,"o":"m 97 986 l 264 986 l 617 158 l 617 986 l 736 986 l 736 0 l 569 0 l 217 828 l 217 0 l 97 0 l 97 986 m 500 1097 q 403 1139 454 1097 q 363 1168 376 1161 q 331 1175 350 1175 q 269 1088 274 1175 l 197 1088 q 235 1208 199 1165 q 331 1250 272 1250 q 385 1239 361 1250 q 436 1206 410 1228 q 474 1180 458 1188 q 506 1172 489 1172 q 546 1192 532 1172 q 564 1264 560 1213 l 636 1264 q 596 1141 632 1185 q 500 1097 560 1097 z "},"Ŋ":{"ha":833,"x_min":97,"x_max":736,"o":"m 374 -92 l 517 -92 q 604 -11 604 -92 l 604 0 l 569 0 l 217 828 l 217 0 l 97 0 l 97 986 l 264 986 l 617 158 l 617 986 l 736 986 l 736 0 l 725 0 l 725 -10 q 669 -154 725 -100 q 517 -208 613 -208 l 374 -208 l 374 -92 z "},"O":{"ha":833,"x_min":53,"x_max":781,"o":"m 417 -22 q 147 111 242 -22 q 53 492 53 244 q 147 874 53 740 q 417 1008 242 1008 q 686 874 592 1008 q 781 492 781 740 q 686 111 781 244 q 417 -22 592 -22 m 417 94 q 594 197 532 94 q 656 492 656 299 q 594 788 656 685 q 417 892 532 892 q 240 788 301 892 q 178 492 178 685 q 240 197 178 299 q 417 94 301 94 z "},"Ó":{"ha":833,"x_min":53,"x_max":781,"o":"m 417 -22 q 147 111 242 -22 q 53 492 53 244 q 147 874 53 740 q 417 1008 242 1008 q 686 874 592 1008 q 781 492 781 740 q 686 111 781 244 q 417 -22 592 -22 m 417 94 q 594 197 532 94 q 656 492 656 299 q 594 788 656 685 q 417 892 532 892 q 240 788 301 892 q 178 492 178 685 q 240 197 178 299 q 417 94 301 94 m 456 1264 l 586 1264 l 461 1086 l 364 1086 l 456 1264 z "},"Ô":{"ha":833,"x_min":53,"x_max":781,"o":"m 417 -22 q 147 111 242 -22 q 53 492 53 244 q 147 874 53 740 q 417 1008 242 1008 q 686 874 592 1008 q 781 492 781 740 q 686 111 781 244 q 417 -22 592 -22 m 417 94 q 594 197 532 94 q 656 492 656 299 q 594 788 656 685 q 417 892 532 892 q 240 788 301 892 q 178 492 178 685 q 240 197 178 299 q 417 94 301 94 m 350 1265 l 481 1265 l 610 1081 l 513 1081 l 415 1206 l 318 1081 l 221 1081 l 350 1265 z "},"Ö":{"ha":833,"x_min":53,"x_max":781,"o":"m 417 -22 q 147 111 242 -22 q 53 492 53 244 q 147 874 53 740 q 417 1008 242 1008 q 686 874 592 1008 q 781 492 781 740 q 686 111 781 244 q 417 -22 592 -22 m 417 94 q 594 197 532 94 q 656 492 656 299 q 594 788 656 685 q 417 892 532 892 q 240 788 301 892 q 178 492 178 685 q 240 197 178 299 q 417 94 301 94 z "},"Ò":{"ha":833,"x_min":53,"x_max":781,"o":"m 417 -22 q 147 111 242 -22 q 53 492 53 244 q 147 874 53 740 q 417 1008 242 1008 q 686 874 592 1008 q 781 492 781 740 q 686 111 781 244 q 417 -22 592 -22 m 417 94 q 594 197 532 94 q 656 492 656 299 q 594 788 656 685 q 417 892 532 892 q 240 788 301 892 q 178 492 178 685 q 240 197 178 299 q 417 94 301 94 m 250 1264 l 381 1264 l 472 1086 l 375 1086 l 250 1264 z "},"Ő":{"ha":833,"x_min":53,"x_max":781,"o":"m 417 -22 q 147 111 242 -22 q 53 492 53 244 q 147 874 53 740 q 417 1008 242 1008 q 686 874 592 1008 q 781 492 781 740 q 686 111 781 244 q 417 -22 592 -22 m 417 94 q 594 197 532 94 q 656 492 656 299 q 594 788 656 685 q 417 892 532 892 q 240 788 301 892 q 178 492 178 685 q 240 197 178 299 q 417 94 301 94 z "},"Ō":{"ha":833,"x_min":53,"x_max":781,"o":"m 417 -22 q 147 111 242 -22 q 53 492 53 244 q 147 874 53 740 q 417 1008 242 1008 q 686 874 592 1008 q 781 492 781 740 q 686 111 781 244 q 417 -22 592 -22 m 417 94 q 594 197 532 94 q 656 492 656 299 q 594 788 656 685 q 417 892 532 892 q 240 788 301 892 q 178 492 178 685 q 240 197 178 299 q 417 94 301 94 m 229 1218 l 601 1218 l 601 1118 l 229 1118 l 229 1218 z "},"Ø":{"ha":833,"x_min":53,"x_max":781,"o":"m 139 124 q 53 492 53 256 q 147 874 53 740 q 417 1008 242 1008 q 622 943 538 1008 l 661 1008 l 775 1008 l 692 867 q 781 492 781 733 q 686 111 781 244 q 417 -22 592 -22 q 210 46 294 -22 l 169 -22 l 53 -22 l 139 124 m 417 94 q 594 197 532 94 q 656 492 656 299 q 617 740 656 646 l 272 153 q 417 94 329 94 m 178 492 q 214 250 178 346 l 558 835 q 417 892 501 892 q 240 788 301 892 q 178 492 178 685 z "},"Õ":{"ha":833,"x_min":53,"x_max":781,"o":"m 417 -22 q 147 111 242 -22 q 53 492 53 244 q 147 874 53 740 q 417 1008 242 1008 q 686 874 592 1008 q 781 492 781 740 q 686 111 781 244 q 417 -22 592 -22 m 417 94 q 594 197 532 94 q 656 492 656 299 q 594 788 656 685 q 417 892 532 892 q 240 788 301 892 q 178 492 178 685 q 240 197 178 299 q 417 94 301 94 m 500 1097 q 403 1139 454 1097 q 363 1168 376 1161 q 331 1175 350 1175 q 269 1088 274 1175 l 197 1088 q 235 1208 199 1165 q 331 1250 272 1250 q 385 1239 361 1250 q 436 1206 410 1228 q 474 1180 458 1188 q 506 1172 489 1172 q 546 1192 532 1172 q 564 1264 560 1213 l 636 1264 q 596 1141 632 1185 q 500 1097 560 1097 z "},"Œ":{"ha":833,"x_min":10,"x_max":822,"o":"m 294 -22 q 142 40 206 -22 q 44 218 78 103 q 10 492 10 333 q 44 767 10 651 q 142 946 78 883 q 294 1008 206 1008 q 458 935 389 1008 l 458 986 l 815 986 l 815 869 l 578 869 l 578 551 l 806 551 l 806 438 l 578 438 l 578 117 l 822 117 l 822 0 l 458 0 l 458 51 q 294 -22 389 -22 m 294 94 q 417 197 379 94 q 456 492 456 299 q 417 789 456 686 q 294 892 379 892 q 171 789 210 892 q 132 492 132 686 q 171 197 132 300 q 294 94 210 94 z "},"P":{"ha":833,"x_min":125,"x_max":764,"o":"m 125 986 l 425 986 q 672 907 581 986 q 764 688 764 828 q 672 468 764 547 q 425 389 581 389 l 244 389 l 244 0 l 125 0 l 125 986 m 411 503 q 639 688 639 503 q 411 872 639 872 l 244 872 l 244 503 l 411 503 z "},"Þ":{"ha":833,"x_min":125,"x_max":786,"o":"m 125 986 l 244 986 l 244 808 l 436 808 q 694 730 601 808 q 786 511 786 651 q 694 288 786 368 q 436 207 601 207 l 244 207 l 244 0 l 125 0 l 125 986 m 436 324 q 661 511 661 324 q 436 692 661 692 l 244 692 l 244 324 l 436 324 z "},"Q":{"ha":833,"x_min":53,"x_max":781,"o":"m 531 -6 q 417 -22 479 -22 q 147 111 242 -22 q 53 492 53 244 q 147 874 53 740 q 417 1008 242 1008 q 686 874 592 1008 q 781 492 781 740 q 743 224 781 336 q 632 51 706 113 l 717 -97 l 583 -97 l 531 -6 m 417 94 q 469 100 447 94 l 361 289 l 494 289 l 568 161 q 656 492 656 261 q 594 788 656 685 q 417 892 532 892 q 240 788 301 892 q 178 492 178 685 q 240 197 178 299 q 417 94 301 94 z "},"R":{"ha":833,"x_min":97,"x_max":742,"o":"m 97 986 l 397 986 q 643 908 553 986 q 733 692 733 829 q 688 547 733 610 q 574 458 642 483 q 676 403 643 444 q 717 283 710 363 l 742 0 l 621 0 l 597 268 q 550 369 590 338 q 422 400 510 400 l 217 400 l 217 0 l 97 0 l 97 986 m 397 514 q 553 560 499 514 q 608 692 608 607 q 553 825 608 778 q 397 872 499 872 l 217 872 l 217 514 l 397 514 z "},"Ŕ":{"ha":833,"x_min":97,"x_max":742,"o":"m 97 986 l 397 986 q 643 908 553 986 q 733 692 733 829 q 688 547 733 610 q 574 458 642 483 q 676 403 643 444 q 717 283 710 363 l 742 0 l 621 0 l 597 268 q 550 369 590 338 q 422 400 510 400 l 217 400 l 217 0 l 97 0 l 97 986 m 397 514 q 553 560 499 514 q 608 692 608 607 q 553 825 608 778 q 397 872 499 872 l 217 872 l 217 514 l 397 514 m 436 1264 l 567 1264 l 442 1086 l 344 1086 l 436 1264 z "},"Ř":{"ha":833,"x_min":97,"x_max":742,"o":"m 97 986 l 397 986 q 643 908 553 986 q 733 692 733 829 q 688 547 733 610 q 574 458 642 483 q 676 403 643 444 q 717 283 710 363 l 742 0 l 621 0 l 597 268 q 550 369 590 338 q 422 400 510 400 l 217 400 l 217 0 l 97 0 l 97 986 m 397 514 q 553 560 499 514 q 608 692 608 607 q 553 825 608 778 q 397 872 499 872 l 217 872 l 217 514 l 397 514 m 300 1264 l 397 1139 l 494 1264 l 592 1264 l 463 1079 l 332 1079 l 203 1264 l 300 1264 z "},"Ŗ":{"ha":833,"x_min":97,"x_max":742,"o":"m 97 986 l 397 986 q 643 908 553 986 q 733 692 733 829 q 688 547 733 610 q 574 458 642 483 q 676 403 643 444 q 717 283 710 363 l 742 0 l 621 0 l 597 268 q 550 369 590 338 q 422 400 510 400 l 217 400 l 217 0 l 97 0 l 97 986 m 397 514 q 553 560 499 514 q 608 692 608 607 q 553 825 608 778 q 397 872 499 872 l 217 872 l 217 514 l 397 514 m 414 -172 l 367 -172 l 367 -47 l 506 -47 l 506 -167 l 417 -297 l 339 -297 l 414 -172 z "},"S":{"ha":833,"x_min":61,"x_max":772,"o":"m 432 -22 q 248 20 329 -22 q 118 139 167 63 q 61 315 69 215 l 186 324 q 265 154 200 214 q 435 94 331 94 q 592 133 538 94 q 647 244 647 172 q 625 328 647 294 q 544 393 603 363 q 371 456 485 424 q 203 521 265 486 q 113 605 142 556 q 85 728 85 654 q 124 874 85 810 q 238 973 164 938 q 413 1008 313 1008 q 649 922 560 1008 q 754 694 738 835 l 629 686 q 561 836 617 781 q 410 892 506 892 q 264 848 318 892 q 210 733 210 804 q 230 657 210 686 q 299 606 250 628 q 446 556 349 583 q 638 480 568 521 q 740 382 708 439 q 772 242 772 325 q 729 105 772 165 q 609 11 686 44 q 432 -22 532 -22 z "},"Ś":{"ha":833,"x_min":61,"x_max":772,"o":"m 432 -22 q 248 20 329 -22 q 118 139 167 63 q 61 315 69 215 l 186 324 q 265 154 200 214 q 435 94 331 94 q 592 133 538 94 q 647 244 647 172 q 625 328 647 294 q 544 393 603 363 q 371 456 485 424 q 203 521 265 486 q 113 605 142 556 q 85 728 85 654 q 124 874 85 810 q 238 973 164 938 q 413 1008 313 1008 q 649 922 560 1008 q 754 694 738 835 l 629 686 q 561 836 617 781 q 410 892 506 892 q 264 848 318 892 q 210 733 210 804 q 230 657 210 686 q 299 606 250 628 q 446 556 349 583 q 638 480 568 521 q 740 382 708 439 q 772 242 772 325 q 729 105 772 165 q 609 11 686 44 q 432 -22 532 -22 m 454 1264 l 585 1264 l 460 1086 l 363 1086 l 454 1264 z "},"Š":{"ha":833,"x_min":61,"x_max":772,"o":"m 432 -22 q 248 20 329 -22 q 118 139 167 63 q 61 315 69 215 l 186 324 q 265 154 200 214 q 435 94 331 94 q 592 133 538 94 q 647 244 647 172 q 625 328 647 294 q 544 393 603 363 q 371 456 485 424 q 203 521 265 486 q 113 605 142 556 q 85 728 85 654 q 124 874 85 810 q 238 973 164 938 q 413 1008 313 1008 q 649 922 560 1008 q 754 694 738 835 l 629 686 q 561 836 617 781 q 410 892 506 892 q 264 848 318 892 q 210 733 210 804 q 230 657 210 686 q 299 606 250 628 q 446 556 349 583 q 638 480 568 521 q 740 382 708 439 q 772 242 772 325 q 729 105 772 165 q 609 11 686 44 q 432 -22 532 -22 m 318 1264 l 415 1139 l 513 1264 l 610 1264 l 481 1079 l 350 1079 l 221 1264 l 318 1264 z "},"Ş":{"ha":833,"x_min":61,"x_max":772,"o":"m 407 -306 q 324 -284 360 -306 q 244 -212 288 -262 l 301 -160 q 349 -211 326 -192 q 401 -231 371 -231 q 449 -215 432 -231 q 467 -171 467 -199 q 447 -129 467 -144 q 394 -114 428 -114 q 361 -118 381 -114 l 322 -67 l 367 -18 q 155 91 238 1 q 61 315 72 181 l 186 324 q 265 154 200 214 q 435 94 331 94 q 592 133 538 94 q 647 244 647 172 q 625 328 647 294 q 544 393 603 363 q 371 456 485 424 q 203 521 265 486 q 113 605 142 556 q 85 728 85 654 q 124 874 85 810 q 238 973 164 938 q 413 1008 313 1008 q 649 922 560 1008 q 754 694 738 835 l 629 686 q 561 836 617 781 q 410 892 506 892 q 264 848 318 892 q 210 733 210 804 q 230 657 210 686 q 299 606 250 628 q 446 556 349 583 q 638 480 568 521 q 740 382 708 439 q 772 242 772 325 q 730 106 772 165 q 610 12 688 46 q 435 -22 533 -22 l 408 -51 q 506 -86 468 -51 q 543 -176 543 -121 q 503 -268 543 -231 q 407 -306 464 -306 z "},"Ŝ":{"ha":833,"x_min":61,"x_max":772,"o":"m 432 -22 q 248 20 329 -22 q 118 139 167 63 q 61 315 69 215 l 186 324 q 265 154 200 214 q 435 94 331 94 q 592 133 538 94 q 647 244 647 172 q 625 328 647 294 q 544 393 603 363 q 371 456 485 424 q 203 521 265 486 q 113 605 142 556 q 85 728 85 654 q 124 874 85 810 q 238 973 164 938 q 413 1008 313 1008 q 649 922 560 1008 q 754 694 738 835 l 629 686 q 561 836 617 781 q 410 892 506 892 q 264 848 318 892 q 210 733 210 804 q 230 657 210 686 q 299 606 250 628 q 446 556 349 583 q 638 480 568 521 q 740 382 708 439 q 772 242 772 325 q 729 105 772 165 q 609 11 686 44 q 432 -22 532 -22 m 349 1265 l 479 1265 l 608 1081 l 511 1081 l 414 1206 l 317 1081 l 219 1081 l 349 1265 z "},"Ș":{"ha":833,"x_min":61,"x_max":772,"o":"m 432 -22 q 248 20 329 -22 q 118 139 167 63 q 61 315 69 215 l 186 324 q 265 154 200 214 q 435 94 331 94 q 592 133 538 94 q 647 244 647 172 q 625 328 647 294 q 544 393 603 363 q 371 456 485 424 q 203 521 265 486 q 113 605 142 556 q 85 728 85 654 q 124 874 85 810 q 238 973 164 938 q 413 1008 313 1008 q 649 922 560 1008 q 754 694 738 835 l 629 686 q 561 836 617 781 q 410 892 506 892 q 264 848 318 892 q 210 733 210 804 q 230 657 210 686 q 299 606 250 628 q 446 556 349 583 q 638 480 568 521 q 740 382 708 439 q 772 242 772 325 q 729 105 772 165 q 609 11 686 44 q 432 -22 532 -22 m 408 -172 l 361 -172 l 361 -47 l 500 -47 l 500 -167 l 411 -297 l 333 -297 l 408 -172 z "},"ẞ":{"ha":833,"x_min":50,"x_max":799,"o":"m 50 696 q 125 908 50 831 q 328 986 200 986 l 732 986 l 732 858 l 426 589 q 621 556 536 590 q 752 457 706 522 q 799 307 799 392 q 756 147 799 217 q 633 39 713 78 q 449 0 554 0 l 263 0 l 263 117 l 449 117 q 614 167 553 117 q 675 301 675 217 q 606 441 675 390 q 418 490 538 492 l 276 489 l 276 607 l 575 869 l 328 869 q 210 825 251 869 q 169 696 169 781 l 169 0 l 50 0 l 50 696 z "},"T":{"ha":833,"x_min":56,"x_max":778,"o":"m 357 872 l 56 872 l 56 986 l 778 986 l 778 872 l 476 872 l 476 0 l 357 0 l 357 872 z "},"Ŧ":{"ha":833,"x_min":56,"x_max":778,"o":"m 357 432 l 139 432 l 139 535 l 357 535 l 357 872 l 56 872 l 56 986 l 778 986 l 778 872 l 476 872 l 476 535 l 694 535 l 694 432 l 476 432 l 476 0 l 357 0 l 357 432 z "},"Ť":{"ha":833,"x_min":56,"x_max":778,"o":"m 357 872 l 56 872 l 56 986 l 778 986 l 778 872 l 476 872 l 476 0 l 357 0 l 357 872 m 319 1264 l 417 1139 l 514 1264 l 611 1264 l 482 1079 l 351 1079 l 222 1264 l 319 1264 z "},"Ţ":{"ha":833,"x_min":56,"x_max":778,"o":"m 357 872 l 56 872 l 56 986 l 778 986 l 778 872 l 476 872 l 476 0 l 456 0 l 408 -51 q 506 -86 468 -51 q 543 -176 543 -121 q 503 -268 543 -231 q 407 -306 464 -306 q 324 -284 360 -306 q 244 -212 288 -262 l 301 -160 q 349 -211 326 -192 q 401 -231 371 -231 q 449 -215 432 -231 q 467 -171 467 -199 q 447 -129 467 -144 q 394 -114 428 -114 q 361 -118 381 -114 l 322 -67 l 383 0 l 357 0 l 357 872 z "},"Ț":{"ha":833,"x_min":56,"x_max":778,"o":"m 357 872 l 56 872 l 56 986 l 778 986 l 778 872 l 476 872 l 476 0 l 357 0 l 357 872 m 408 -172 l 361 -172 l 361 -47 l 500 -47 l 500 -167 l 411 -297 l 333 -297 l 408 -172 z "},"U":{"ha":833,"x_min":83,"x_max":750,"o":"m 417 -22 q 172 76 261 -22 q 83 344 83 175 l 83 986 l 203 986 l 203 344 q 258 159 203 224 q 417 94 313 94 q 576 159 521 94 q 631 344 631 224 l 631 986 l 750 986 l 750 344 q 661 76 750 175 q 417 -22 572 -22 z "},"Ú":{"ha":833,"x_min":83,"x_max":750,"o":"m 417 -22 q 172 76 261 -22 q 83 344 83 175 l 83 986 l 203 986 l 203 344 q 258 159 203 224 q 417 94 313 94 q 576 159 521 94 q 631 344 631 224 l 631 986 l 750 986 l 750 344 q 661 76 750 175 q 417 -22 572 -22 m 456 1264 l 586 1264 l 461 1086 l 364 1086 l 456 1264 z "},"Ŭ":{"ha":833,"x_min":83,"x_max":750,"o":"m 417 -22 q 172 76 261 -22 q 83 344 83 175 l 83 986 l 203 986 l 203 344 q 258 159 203 224 q 417 94 313 94 q 576 159 521 94 q 631 344 631 224 l 631 986 l 750 986 l 750 344 q 661 76 750 175 q 417 -22 572 -22 m 417 1088 q 279 1135 332 1088 q 222 1265 226 1183 l 303 1265 q 417 1174 315 1174 q 531 1265 518 1174 l 611 1265 q 554 1135 607 1183 q 417 1088 501 1088 z "},"Û":{"ha":833,"x_min":83,"x_max":750,"o":"m 417 -22 q 172 76 261 -22 q 83 344 83 175 l 83 986 l 203 986 l 203 344 q 258 159 203 224 q 417 94 313 94 q 576 159 521 94 q 631 344 631 224 l 631 986 l 750 986 l 750 344 q 661 76 750 175 q 417 -22 572 -22 m 350 1265 l 481 1265 l 610 1081 l 513 1081 l 415 1206 l 318 1081 l 221 1081 l 350 1265 z "},"Ü":{"ha":833,"x_min":83,"x_max":750,"o":"m 417 -22 q 172 76 261 -22 q 83 344 83 175 l 83 986 l 203 986 l 203 344 q 258 159 203 224 q 417 94 313 94 q 576 159 521 94 q 631 344 631 224 l 631 986 l 750 986 l 750 344 q 661 76 750 175 q 417 -22 572 -22 z "},"Ù":{"ha":833,"x_min":83,"x_max":750,"o":"m 417 -22 q 172 76 261 -22 q 83 344 83 175 l 83 986 l 203 986 l 203 344 q 258 159 203 224 q 417 94 313 94 q 576 159 521 94 q 631 344 631 224 l 631 986 l 750 986 l 750 344 q 661 76 750 175 q 417 -22 572 -22 m 250 1264 l 381 1264 l 472 1086 l 375 1086 l 250 1264 z "},"Ű":{"ha":833,"x_min":83,"x_max":750,"o":"m 417 -22 q 172 76 261 -22 q 83 344 83 175 l 83 986 l 203 986 l 203 344 q 258 159 203 224 q 417 94 313 94 q 576 159 521 94 q 631 344 631 224 l 631 986 l 750 986 l 750 344 q 661 76 750 175 q 417 -22 572 -22 z "},"Ū":{"ha":833,"x_min":83,"x_max":750,"o":"m 417 -22 q 172 76 261 -22 q 83 344 83 175 l 83 986 l 203 986 l 203 344 q 258 159 203 224 q 417 94 313 94 q 576 159 521 94 q 631 344 631 224 l 631 986 l 750 986 l 750 344 q 661 76 750 175 q 417 -22 572 -22 m 229 1218 l 601 1218 l 601 1118 l 229 1118 l 229 1218 z "},"Ų":{"ha":833,"x_min":83,"x_max":750,"o":"m 417 -22 q 172 76 261 -22 q 83 344 83 175 l 83 986 l 203 986 l 203 344 q 258 159 203 224 q 417 94 313 94 q 576 159 521 94 q 631 344 631 224 l 631 986 l 750 986 l 750 344 q 697 125 750 214 q 547 0 644 36 l 499 -62 q 465 -117 474 -96 q 456 -156 456 -137 q 497 -197 456 -197 q 532 -192 514 -197 q 561 -178 550 -187 l 594 -256 q 546 -282 578 -272 q 475 -292 514 -292 q 383 -262 417 -292 q 350 -181 350 -232 q 407 -42 350 -112 l 422 -22 l 417 -22 z "},"Ů":{"ha":833,"x_min":83,"x_max":750,"o":"m 417 -22 q 172 76 261 -22 q 83 344 83 175 l 83 986 l 203 986 l 203 344 q 258 159 203 224 q 417 94 313 94 q 576 159 521 94 q 631 344 631 224 l 631 986 l 750 986 l 750 344 q 661 76 750 175 q 417 -22 572 -22 m 417 1064 q 326 1101 363 1064 q 289 1192 289 1138 q 326 1283 289 1246 q 417 1319 363 1319 q 508 1283 471 1319 q 544 1192 544 1246 q 508 1101 544 1138 q 417 1064 471 1064 m 417 1133 q 458 1150 442 1133 q 475 1192 475 1167 q 458 1233 475 1217 q 417 1250 442 1250 q 375 1233 392 1250 q 358 1192 358 1217 q 375 1150 358 1167 q 417 1133 392 1133 z "},"Ũ":{"ha":833,"x_min":83,"x_max":750,"o":"m 417 -22 q 172 76 261 -22 q 83 344 83 175 l 83 986 l 203 986 l 203 344 q 258 159 203 224 q 417 94 313 94 q 576 159 521 94 q 631 344 631 224 l 631 986 l 750 986 l 750 344 q 661 76 750 175 q 417 -22 572 -22 m 500 1097 q 403 1139 454 1097 q 363 1168 376 1161 q 331 1175 350 1175 q 269 1088 274 1175 l 197 1088 q 235 1208 199 1165 q 331 1250 272 1250 q 385 1239 361 1250 q 436 1206 410 1228 q 474 1180 458 1188 q 506 1172 489 1172 q 546 1192 532 1172 q 564 1264 560 1213 l 636 1264 q 596 1141 632 1185 q 500 1097 560 1097 z "},"V":{"ha":833,"x_min":33,"x_max":800,"o":"m 33 986 l 161 986 l 417 133 l 672 986 l 800 986 l 494 0 l 339 0 l 33 986 z "},"W":{"ha":833,"x_min":56,"x_max":778,"o":"m 56 986 l 181 986 l 253 132 l 356 875 l 478 875 l 581 132 l 653 986 l 778 986 l 683 0 l 503 0 l 417 635 l 331 0 l 150 0 l 56 986 z "},"Ẃ":{"ha":833,"x_min":56,"x_max":778,"o":"m 56 986 l 181 986 l 253 132 l 356 875 l 478 875 l 581 132 l 653 986 l 778 986 l 683 0 l 503 0 l 417 635 l 331 0 l 150 0 l 56 986 m 456 1264 l 586 1264 l 461 1086 l 364 1086 l 456 1264 z "},"Ŵ":{"ha":833,"x_min":56,"x_max":778,"o":"m 56 986 l 181 986 l 253 132 l 356 875 l 478 875 l 581 132 l 653 986 l 778 986 l 683 0 l 503 0 l 417 635 l 331 0 l 150 0 l 56 986 m 350 1265 l 481 1265 l 610 1081 l 513 1081 l 415 1206 l 318 1081 l 221 1081 l 350 1265 z "},"Ẅ":{"ha":833,"x_min":56,"x_max":778,"o":"m 56 986 l 181 986 l 253 132 l 356 875 l 478 875 l 581 132 l 653 986 l 778 986 l 683 0 l 503 0 l 417 635 l 331 0 l 150 0 l 56 986 z "},"Ẁ":{"ha":833,"x_min":56,"x_max":778,"o":"m 56 986 l 181 986 l 253 132 l 356 875 l 478 875 l 581 132 l 653 986 l 778 986 l 683 0 l 503 0 l 417 635 l 331 0 l 150 0 l 56 986 m 250 1264 l 381 1264 l 472 1086 l 375 1086 l 250 1264 z "},"X":{"ha":833,"x_min":69,"x_max":764,"o":"m 342 496 l 75 986 l 214 986 l 418 588 l 622 986 l 758 986 l 492 494 l 764 0 l 625 0 l 415 404 l 206 0 l 69 0 l 342 496 z "},"Y":{"ha":833,"x_min":61,"x_max":772,"o":"m 358 403 l 61 986 l 197 986 l 417 542 l 636 986 l 772 986 l 478 403 l 478 0 l 358 0 l 358 403 z "},"Ý":{"ha":833,"x_min":61,"x_max":772,"o":"m 358 403 l 61 986 l 197 986 l 417 542 l 636 986 l 772 986 l 478 403 l 478 0 l 358 0 l 358 403 m 456 1264 l 586 1264 l 461 1086 l 364 1086 l 456 1264 z "},"Ŷ":{"ha":833,"x_min":61,"x_max":772,"o":"m 358 403 l 61 986 l 197 986 l 417 542 l 636 986 l 772 986 l 478 403 l 478 0 l 358 0 l 358 403 m 350 1265 l 481 1265 l 610 1081 l 513 1081 l 415 1206 l 318 1081 l 221 1081 l 350 1265 z "},"Ÿ":{"ha":833,"x_min":61,"x_max":772,"o":"m 358 403 l 61 986 l 197 986 l 417 542 l 636 986 l 772 986 l 478 403 l 478 0 l 358 0 l 358 403 z "},"Ỳ":{"ha":833,"x_min":61,"x_max":772,"o":"m 358 403 l 61 986 l 197 986 l 417 542 l 636 986 l 772 986 l 478 403 l 478 0 l 358 0 l 358 403 m 250 1264 l 381 1264 l 472 1086 l 375 1086 l 250 1264 z "},"Ỹ":{"ha":833,"x_min":61,"x_max":772,"o":"m 358 403 l 61 986 l 197 986 l 417 542 l 636 986 l 772 986 l 478 403 l 478 0 l 358 0 l 358 403 m 500 1097 q 403 1139 454 1097 q 363 1168 376 1161 q 331 1175 350 1175 q 269 1088 274 1175 l 197 1088 q 235 1208 199 1165 q 331 1250 272 1250 q 385 1239 361 1250 q 436 1206 410 1228 q 474 1180 458 1188 q 506 1172 489 1172 q 546 1192 532 1172 q 564 1264 560 1213 l 636 1264 q 596 1141 632 1185 q 500 1097 560 1097 z "},"Z":{"ha":833,"x_min":69,"x_max":764,"o":"m 69 119 l 619 872 l 94 872 l 94 986 l 753 986 l 753 867 l 203 114 l 764 114 l 764 0 l 69 0 l 69 119 z "},"Ź":{"ha":833,"x_min":69,"x_max":764,"o":"m 69 119 l 619 872 l 94 872 l 94 986 l 753 986 l 753 867 l 203 114 l 764 114 l 764 0 l 69 0 l 69 119 m 456 1264 l 586 1264 l 461 1086 l 364 1086 l 456 1264 z "},"Ž":{"ha":833,"x_min":69,"x_max":764,"o":"m 69 119 l 619 872 l 94 872 l 94 986 l 753 986 l 753 867 l 203 114 l 764 114 l 764 0 l 69 0 l 69 119 m 319 1264 l 417 1139 l 514 1264 l 611 1264 l 482 1079 l 351 1079 l 222 1264 l 319 1264 z "},"Ż":{"ha":833,"x_min":69,"x_max":764,"o":"m 69 119 l 619 872 l 94 872 l 94 986 l 753 986 l 753 867 l 203 114 l 764 114 l 764 0 l 69 0 l 69 119 m 354 1238 l 476 1238 l 476 1101 l 354 1101 l 354 1238 z "},"Ꞌ":{"ha":833,"x_min":356,"x_max":478,"o":"m 356 797 l 356 986 l 478 986 l 478 797 l 458 525 l 375 525 l 356 797 z "},"a":{"ha":833,"x_min":75,"x_max":764,"o":"m 319 -17 q 144 39 214 -17 q 75 183 75 94 q 131 329 75 276 q 308 406 186 382 l 556 456 q 514 595 556 549 q 392 642 472 642 q 273 608 317 642 q 213 510 229 575 l 89 519 q 190 690 110 626 q 392 753 269 753 q 599 673 525 753 q 672 453 672 593 l 672 147 q 683 113 672 122 q 717 103 693 103 l 764 103 l 764 0 q 703 -3 742 -3 q 606 23 642 -3 q 560 104 571 49 q 464 17 529 50 q 319 -17 399 -17 m 331 86 q 498 142 440 86 q 556 292 556 197 l 556 356 l 331 311 q 227 269 257 297 q 197 194 197 242 q 233 115 197 143 q 331 86 268 86 z "},"á":{"ha":833,"x_min":75,"x_max":764,"o":"m 319 -17 q 144 39 214 -17 q 75 183 75 94 q 131 329 75 276 q 308 406 186 382 l 556 456 q 514 595 556 549 q 392 642 472 642 q 273 608 317 642 q 213 510 229 575 l 89 519 q 190 690 110 626 q 392 753 269 753 q 599 673 525 753 q 672 453 672 593 l 672 147 q 683 113 672 122 q 717 103 693 103 l 764 103 l 764 0 q 703 -3 742 -3 q 606 23 642 -3 q 560 104 571 49 q 464 17 529 50 q 319 -17 399 -17 m 331 86 q 498 142 440 86 q 556 292 556 197 l 556 356 l 331 311 q 227 269 257 297 q 197 194 197 242 q 233 115 197 143 q 331 86 268 86 m 433 1014 l 564 1014 l 439 836 l 342 836 l 433 1014 z "},"ă":{"ha":833,"x_min":75,"x_max":764,"o":"m 319 -17 q 144 39 214 -17 q 75 183 75 94 q 131 329 75 276 q 308 406 186 382 l 556 456 q 514 595 556 549 q 392 642 472 642 q 273 608 317 642 q 213 510 229 575 l 89 519 q 190 690 110 626 q 392 753 269 753 q 599 673 525 753 q 672 453 672 593 l 672 147 q 683 113 672 122 q 717 103 693 103 l 764 103 l 764 0 q 703 -3 742 -3 q 606 23 642 -3 q 560 104 571 49 q 464 17 529 50 q 319 -17 399 -17 m 331 86 q 498 142 440 86 q 556 292 556 197 l 556 356 l 331 311 q 227 269 257 297 q 197 194 197 242 q 233 115 197 143 q 331 86 268 86 m 394 838 q 257 885 310 838 q 200 1015 204 933 l 281 1015 q 394 924 293 924 q 508 1015 496 924 l 589 1015 q 532 885 585 933 q 394 838 479 838 z "},"ǎ":{"ha":833,"x_min":75,"x_max":764,"o":"m 319 -17 q 144 39 214 -17 q 75 183 75 94 q 131 329 75 276 q 308 406 186 382 l 556 456 q 514 595 556 549 q 392 642 472 642 q 273 608 317 642 q 213 510 229 575 l 89 519 q 190 690 110 626 q 392 753 269 753 q 599 673 525 753 q 672 453 672 593 l 672 147 q 683 113 672 122 q 717 103 693 103 l 764 103 l 764 0 q 703 -3 742 -3 q 606 23 642 -3 q 560 104 571 49 q 464 17 529 50 q 319 -17 399 -17 m 331 86 q 498 142 440 86 q 556 292 556 197 l 556 356 l 331 311 q 227 269 257 297 q 197 194 197 242 q 233 115 197 143 q 331 86 268 86 m 297 1014 l 394 889 l 492 1014 l 589 1014 l 460 829 l 329 829 l 200 1014 l 297 1014 z "},"â":{"ha":833,"x_min":75,"x_max":764,"o":"m 319 -17 q 144 39 214 -17 q 75 183 75 94 q 131 329 75 276 q 308 406 186 382 l 556 456 q 514 595 556 549 q 392 642 472 642 q 273 608 317 642 q 213 510 229 575 l 89 519 q 190 690 110 626 q 392 753 269 753 q 599 673 525 753 q 672 453 672 593 l 672 147 q 683 113 672 122 q 717 103 693 103 l 764 103 l 764 0 q 703 -3 742 -3 q 606 23 642 -3 q 560 104 571 49 q 464 17 529 50 q 319 -17 399 -17 m 331 86 q 498 142 440 86 q 556 292 556 197 l 556 356 l 331 311 q 227 269 257 297 q 197 194 197 242 q 233 115 197 143 q 331 86 268 86 m 328 1015 l 458 1015 l 588 831 l 490 831 l 393 956 l 296 831 l 199 831 l 328 1015 z "},"ä":{"ha":833,"x_min":75,"x_max":764,"o":"m 319 -17 q 144 39 214 -17 q 75 183 75 94 q 131 329 75 276 q 308 406 186 382 l 556 456 q 514 595 556 549 q 392 642 472 642 q 273 608 317 642 q 213 510 229 575 l 89 519 q 190 690 110 626 q 392 753 269 753 q 599 673 525 753 q 672 453 672 593 l 672 147 q 683 113 672 122 q 717 103 693 103 l 764 103 l 764 0 q 703 -3 742 -3 q 606 23 642 -3 q 560 104 571 49 q 464 17 529 50 q 319 -17 399 -17 m 331 86 q 498 142 440 86 q 556 292 556 197 l 556 356 l 331 311 q 227 269 257 297 q 197 194 197 242 q 233 115 197 143 q 331 86 268 86 z "},"à":{"ha":833,"x_min":75,"x_max":764,"o":"m 319 -17 q 144 39 214 -17 q 75 183 75 94 q 131 329 75 276 q 308 406 186 382 l 556 456 q 514 595 556 549 q 392 642 472 642 q 273 608 317 642 q 213 510 229 575 l 89 519 q 190 690 110 626 q 392 753 269 753 q 599 673 525 753 q 672 453 672 593 l 672 147 q 683 113 672 122 q 717 103 693 103 l 764 103 l 764 0 q 703 -3 742 -3 q 606 23 642 -3 q 560 104 571 49 q 464 17 529 50 q 319 -17 399 -17 m 331 86 q 498 142 440 86 q 556 292 556 197 l 556 356 l 331 311 q 227 269 257 297 q 197 194 197 242 q 233 115 197 143 q 331 86 268 86 m 228 1014 l 358 1014 l 450 836 l 353 836 l 228 1014 z "},"ā":{"ha":833,"x_min":75,"x_max":764,"o":"m 319 -17 q 144 39 214 -17 q 75 183 75 94 q 131 329 75 276 q 308 406 186 382 l 556 456 q 514 595 556 549 q 392 642 472 642 q 273 608 317 642 q 213 510 229 575 l 89 519 q 190 690 110 626 q 392 753 269 753 q 599 673 525 753 q 672 453 672 593 l 672 147 q 683 113 672 122 q 717 103 693 103 l 764 103 l 764 0 q 703 -3 742 -3 q 606 23 642 -3 q 560 104 571 49 q 464 17 529 50 q 319 -17 399 -17 m 331 86 q 498 142 440 86 q 556 292 556 197 l 556 356 l 331 311 q 227 269 257 297 q 197 194 197 242 q 233 115 197 143 q 331 86 268 86 m 207 968 l 579 968 l 579 868 l 207 868 l 207 968 z "},"ą":{"ha":833,"x_min":75,"x_max":811,"o":"m 319 -17 q 144 39 214 -17 q 75 183 75 94 q 131 329 75 276 q 308 406 186 382 l 556 456 q 514 595 556 549 q 392 642 472 642 q 273 608 317 642 q 213 510 229 575 l 89 519 q 190 690 110 626 q 392 753 269 753 q 599 673 525 753 q 672 453 672 593 l 672 147 q 683 113 672 122 q 717 103 693 103 l 764 103 l 764 0 l 715 -62 q 681 -117 690 -96 q 672 -156 672 -137 q 714 -197 672 -197 q 749 -192 731 -197 q 778 -178 767 -187 l 811 -256 q 763 -282 794 -272 q 692 -292 731 -292 q 600 -262 633 -292 q 567 -181 567 -232 q 624 -42 567 -112 l 658 1 q 560 104 578 15 q 464 17 529 50 q 319 -17 399 -17 m 331 86 q 498 142 440 86 q 556 292 556 197 l 556 356 l 331 311 q 227 269 257 297 q 197 194 197 242 q 233 115 197 143 q 331 86 268 86 z "},"å":{"ha":833,"x_min":75,"x_max":764,"o":"m 319 -17 q 144 39 214 -17 q 75 183 75 94 q 131 329 75 276 q 308 406 186 382 l 556 456 q 514 595 556 549 q 392 642 472 642 q 273 608 317 642 q 213 510 229 575 l 89 519 q 190 690 110 626 q 392 753 269 753 q 599 673 525 753 q 672 453 672 593 l 672 147 q 683 113 672 122 q 717 103 693 103 l 764 103 l 764 0 q 703 -3 742 -3 q 606 23 642 -3 q 560 104 571 49 q 464 17 529 50 q 319 -17 399 -17 m 331 86 q 498 142 440 86 q 556 292 556 197 l 556 356 l 331 311 q 227 269 257 297 q 197 194 197 242 q 233 115 197 143 q 331 86 268 86 m 394 814 q 303 851 340 814 q 267 942 267 888 q 303 1033 267 996 q 394 1069 340 1069 q 485 1033 449 1069 q 522 942 522 996 q 485 851 522 888 q 394 814 449 814 m 394 883 q 436 900 419 883 q 453 942 453 917 q 436 983 453 967 q 394 1000 419 1000 q 353 983 369 1000 q 336 942 336 967 q 353 900 336 917 q 394 883 369 883 z "},"ã":{"ha":833,"x_min":75,"x_max":764,"o":"m 319 -17 q 144 39 214 -17 q 75 183 75 94 q 131 329 75 276 q 308 406 186 382 l 556 456 q 514 595 556 549 q 392 642 472 642 q 273 608 317 642 q 213 510 229 575 l 89 519 q 190 690 110 626 q 392 753 269 753 q 599 673 525 753 q 672 453 672 593 l 672 147 q 683 113 672 122 q 717 103 693 103 l 764 103 l 764 0 q 703 -3 742 -3 q 606 23 642 -3 q 560 104 571 49 q 464 17 529 50 q 319 -17 399 -17 m 331 86 q 498 142 440 86 q 556 292 556 197 l 556 356 l 331 311 q 227 269 257 297 q 197 194 197 242 q 233 115 197 143 q 331 86 268 86 m 478 847 q 381 889 432 847 q 341 918 354 911 q 308 925 328 925 q 247 838 251 925 l 175 838 q 213 958 176 915 q 308 1000 250 1000 q 363 989 339 1000 q 414 956 388 978 q 451 930 436 938 q 483 922 467 922 q 524 942 510 922 q 542 1014 538 963 l 614 1014 q 574 891 610 935 q 478 847 538 847 z "},"æ":{"ha":833,"x_min":18,"x_max":817,"o":"m 590 -17 q 385 144 444 -17 q 319 27 367 68 q 204 -17 272 -14 q 72 43 125 -14 q 18 196 18 100 q 70 335 18 276 q 213 417 122 394 l 331 447 q 309 597 331 551 q 239 642 288 642 q 174 608 194 642 q 143 510 154 575 l 28 519 q 239 753 64 753 q 410 647 360 753 q 590 753 471 753 q 753 661 697 753 q 815 385 808 569 l 817 332 l 471 332 q 507 156 476 218 q 590 94 538 94 q 653 123 631 94 q 690 218 676 151 l 808 208 q 733 35 782 86 q 590 -17 683 -17 m 208 94 q 302 145 272 94 q 332 304 332 196 l 332 340 l 247 319 q 164 273 193 307 q 135 196 135 239 q 156 122 135 150 q 208 94 176 94 m 696 435 q 661 594 686 546 q 590 642 636 642 q 510 594 538 642 q 472 435 482 546 l 696 435 z "},"b":{"ha":833,"x_min":111,"x_max":764,"o":"m 449 -17 q 313 17 372 -17 q 221 111 253 50 l 217 0 l 111 0 l 111 986 l 228 986 l 228 633 q 317 718 257 683 q 449 753 376 753 q 616 706 544 753 q 726 572 688 658 q 764 368 764 485 q 726 165 764 251 q 616 31 688 78 q 449 -17 544 -17 m 442 94 q 588 168 535 94 q 642 368 642 242 q 589 569 642 496 q 444 642 536 642 q 285 569 343 642 q 228 368 228 496 q 285 168 228 242 q 442 94 342 94 z "},"c":{"ha":833,"x_min":97,"x_max":750,"o":"m 436 -17 q 258 31 335 -17 q 139 165 181 78 q 97 368 97 253 q 139 571 97 483 q 258 706 181 658 q 436 753 335 753 q 641 686 560 753 q 744 497 722 619 l 622 489 q 556 601 606 561 q 436 642 507 642 q 277 569 335 642 q 219 368 219 496 q 277 167 219 240 q 436 94 335 94 q 560 138 510 94 q 628 261 611 181 l 750 253 q 642 56 726 129 q 436 -17 558 -17 z "},"ć":{"ha":833,"x_min":97,"x_max":750,"o":"m 436 -17 q 258 31 335 -17 q 139 165 181 78 q 97 368 97 253 q 139 571 97 483 q 258 706 181 658 q 436 753 335 753 q 641 686 560 753 q 744 497 722 619 l 622 489 q 556 601 606 561 q 436 642 507 642 q 277 569 335 642 q 219 368 219 496 q 277 167 219 240 q 436 94 335 94 q 560 138 510 94 q 628 261 611 181 l 750 253 q 642 56 726 129 q 436 -17 558 -17 m 474 1014 l 604 1014 l 479 836 l 382 836 l 474 1014 z "},"č":{"ha":833,"x_min":97,"x_max":750,"o":"m 436 -17 q 258 31 335 -17 q 139 165 181 78 q 97 368 97 253 q 139 571 97 483 q 258 706 181 658 q 436 753 335 753 q 641 686 560 753 q 744 497 722 619 l 622 489 q 556 601 606 561 q 436 642 507 642 q 277 569 335 642 q 219 368 219 496 q 277 167 219 240 q 436 94 335 94 q 560 138 510 94 q 628 261 611 181 l 750 253 q 642 56 726 129 q 436 -17 558 -17 m 338 1014 l 435 889 l 532 1014 l 629 1014 l 500 829 l 369 829 l 240 1014 l 338 1014 z "},"ç":{"ha":833,"x_min":97,"x_max":750,"o":"m 425 -306 q 342 -284 378 -306 q 263 -212 306 -262 l 319 -160 q 367 -211 344 -192 q 419 -231 389 -231 q 467 -215 450 -231 q 485 -171 485 -199 q 465 -129 485 -144 q 413 -114 446 -114 q 379 -118 399 -114 l 340 -67 l 389 -14 q 176 105 254 3 q 97 368 97 207 q 139 571 97 483 q 258 706 181 658 q 436 753 335 753 q 641 686 560 753 q 744 497 722 619 l 622 489 q 556 601 606 561 q 436 642 507 642 q 277 569 335 642 q 219 368 219 496 q 277 167 219 240 q 436 94 335 94 q 560 138 510 94 q 628 261 611 181 l 750 253 q 650 63 728 135 q 458 -15 572 -10 l 426 -51 q 524 -86 486 -51 q 561 -176 561 -121 q 522 -268 561 -231 q 425 -306 482 -306 z "},"ĉ":{"ha":833,"x_min":97,"x_max":750,"o":"m 436 -17 q 258 31 335 -17 q 139 165 181 78 q 97 368 97 253 q 139 571 97 483 q 258 706 181 658 q 436 753 335 753 q 641 686 560 753 q 744 497 722 619 l 622 489 q 556 601 606 561 q 436 642 507 642 q 277 569 335 642 q 219 368 219 496 q 277 167 219 240 q 436 94 335 94 q 560 138 510 94 q 628 261 611 181 l 750 253 q 642 56 726 129 q 436 -17 558 -17 m 368 1015 l 499 1015 l 628 831 l 531 831 l 433 956 l 336 831 l 239 831 l 368 1015 z "},"ċ":{"ha":833,"x_min":97,"x_max":750,"o":"m 436 -17 q 258 31 335 -17 q 139 165 181 78 q 97 368 97 253 q 139 571 97 483 q 258 706 181 658 q 436 753 335 753 q 641 686 560 753 q 744 497 722 619 l 622 489 q 556 601 606 561 q 436 642 507 642 q 277 569 335 642 q 219 368 219 496 q 277 167 219 240 q 436 94 335 94 q 560 138 510 94 q 628 261 611 181 l 750 253 q 642 56 726 129 q 436 -17 558 -17 m 372 988 l 494 988 l 494 851 l 372 851 l 372 988 z "},"d":{"ha":833,"x_min":69,"x_max":722,"o":"m 385 -17 q 217 31 289 -17 q 108 165 146 78 q 69 368 69 251 q 108 572 69 485 q 217 706 146 658 q 385 753 289 753 q 517 718 457 753 q 606 633 576 683 l 606 986 l 722 986 l 722 0 l 617 0 l 613 111 q 521 17 581 50 q 385 -17 461 -17 m 392 94 q 549 168 492 94 q 606 368 606 242 q 548 569 606 496 q 389 642 490 642 q 244 569 297 642 q 192 368 192 496 q 245 168 192 242 q 392 94 299 94 z "},"ď":{"ha":833,"x_min":69,"x_max":907,"o":"m 385 -17 q 217 31 289 -17 q 108 165 146 78 q 69 368 69 251 q 108 572 69 485 q 217 706 146 658 q 385 753 289 753 q 517 718 457 753 q 606 633 576 683 l 606 986 l 722 986 l 722 0 l 617 0 l 613 111 q 521 17 581 50 q 385 -17 461 -17 m 392 94 q 549 168 492 94 q 606 368 606 242 q 548 569 606 496 q 389 642 490 642 q 244 569 297 642 q 192 368 192 496 q 245 168 192 242 q 392 94 299 94 m 792 986 l 907 986 l 835 650 l 750 650 l 792 986 z "},"đ":{"ha":833,"x_min":69,"x_max":807,"o":"m 456 914 l 606 914 l 606 986 l 722 986 l 722 914 l 807 914 l 807 811 l 722 811 l 722 0 l 617 0 l 613 111 q 521 17 581 50 q 385 -17 461 -17 q 217 31 289 -17 q 108 165 146 78 q 69 368 69 251 q 108 572 69 485 q 217 706 146 658 q 385 753 289 753 q 517 718 457 753 q 606 633 576 683 l 606 811 l 456 811 l 456 914 m 392 94 q 549 168 492 94 q 606 368 606 242 q 548 569 606 496 q 389 642 490 642 q 244 569 297 642 q 192 368 192 496 q 245 168 192 242 q 392 94 299 94 z "},"ð":{"ha":833,"x_min":81,"x_max":776,"o":"m 318 819 l 424 857 q 249 900 350 892 l 276 1003 q 550 901 440 986 l 743 969 l 776 881 l 625 828 q 754 354 754 664 q 713 155 754 239 q 595 27 671 71 q 418 -17 519 -17 q 240 26 317 -17 q 122 150 164 69 q 81 340 81 231 q 122 531 81 450 q 240 654 164 611 q 418 697 317 697 q 608 631 521 697 q 518 789 578 728 l 351 731 l 318 819 m 418 94 q 576 160 521 94 q 632 354 632 225 q 575 524 632 463 q 418 586 518 586 q 259 522 315 586 q 203 340 203 458 q 259 158 203 222 q 418 94 315 94 z "},"e":{"ha":833,"x_min":92,"x_max":750,"o":"m 429 -17 q 250 31 326 -17 q 133 165 174 78 q 92 368 92 251 q 133 571 92 483 q 249 706 174 658 q 424 753 324 753 q 594 708 519 753 q 709 575 668 663 q 750 367 750 488 l 750 332 l 214 332 q 276 155 219 215 q 429 94 332 94 q 547 127 500 94 q 613 218 593 160 l 738 208 q 626 45 708 107 q 429 -17 543 -17 m 622 435 q 561 590 614 538 q 424 642 508 642 q 283 589 338 642 q 214 435 228 536 l 622 435 z "},"é":{"ha":833,"x_min":92,"x_max":750,"o":"m 429 -17 q 250 31 326 -17 q 133 165 174 78 q 92 368 92 251 q 133 571 92 483 q 249 706 174 658 q 424 753 324 753 q 594 708 519 753 q 709 575 668 663 q 750 367 750 488 l 750 332 l 214 332 q 276 155 219 215 q 429 94 332 94 q 547 127 500 94 q 613 218 593 160 l 738 208 q 626 45 708 107 q 429 -17 543 -17 m 622 435 q 561 590 614 538 q 424 642 508 642 q 283 589 338 642 q 214 435 228 536 l 622 435 m 464 1011 l 594 1011 l 469 833 l 372 833 l 464 1011 z "},"ě":{"ha":833,"x_min":92,"x_max":750,"o":"m 429 -17 q 250 31 326 -17 q 133 165 174 78 q 92 368 92 251 q 133 571 92 483 q 249 706 174 658 q 424 753 324 753 q 594 708 519 753 q 709 575 668 663 q 750 367 750 488 l 750 332 l 214 332 q 276 155 219 215 q 429 94 332 94 q 547 127 500 94 q 613 218 593 160 l 738 208 q 626 45 708 107 q 429 -17 543 -17 m 622 435 q 561 590 614 538 q 424 642 508 642 q 283 589 338 642 q 214 435 228 536 l 622 435 m 328 1011 l 425 886 l 522 1011 l 619 1011 l 490 826 l 360 826 l 231 1011 l 328 1011 z "},"ê":{"ha":833,"x_min":92,"x_max":750,"o":"m 429 -17 q 250 31 326 -17 q 133 165 174 78 q 92 368 92 251 q 133 571 92 483 q 249 706 174 658 q 424 753 324 753 q 594 708 519 753 q 709 575 668 663 q 750 367 750 488 l 750 332 l 214 332 q 276 155 219 215 q 429 94 332 94 q 547 127 500 94 q 613 218 593 160 l 738 208 q 626 45 708 107 q 429 -17 543 -17 m 622 435 q 561 590 614 538 q 424 642 508 642 q 283 589 338 642 q 214 435 228 536 l 622 435 m 358 1013 l 489 1013 l 618 828 l 521 828 l 424 953 l 326 828 l 229 828 l 358 1013 z "},"ë":{"ha":833,"x_min":92,"x_max":750,"o":"m 429 -17 q 250 31 326 -17 q 133 165 174 78 q 92 368 92 251 q 133 571 92 483 q 249 706 174 658 q 424 753 324 753 q 594 708 519 753 q 709 575 668 663 q 750 367 750 488 l 750 332 l 214 332 q 276 155 219 215 q 429 94 332 94 q 547 127 500 94 q 613 218 593 160 l 738 208 q 626 45 708 107 q 429 -17 543 -17 m 622 435 q 561 590 614 538 q 424 642 508 642 q 283 589 338 642 q 214 435 228 536 l 622 435 z "},"ė":{"ha":833,"x_min":92,"x_max":750,"o":"m 429 -17 q 250 31 326 -17 q 133 165 174 78 q 92 368 92 251 q 133 571 92 483 q 249 706 174 658 q 424 753 324 753 q 594 708 519 753 q 709 575 668 663 q 750 367 750 488 l 750 332 l 214 332 q 276 155 219 215 q 429 94 332 94 q 547 127 500 94 q 613 218 593 160 l 738 208 q 626 45 708 107 q 429 -17 543 -17 m 622 435 q 561 590 614 538 q 424 642 508 642 q 283 589 338 642 q 214 435 228 536 l 622 435 m 363 985 l 485 985 l 485 849 l 363 849 l 363 985 z "},"è":{"ha":833,"x_min":92,"x_max":750,"o":"m 429 -17 q 250 31 326 -17 q 133 165 174 78 q 92 368 92 251 q 133 571 92 483 q 249 706 174 658 q 424 753 324 753 q 594 708 519 753 q 709 575 668 663 q 750 367 750 488 l 750 332 l 214 332 q 276 155 219 215 q 429 94 332 94 q 547 127 500 94 q 613 218 593 160 l 738 208 q 626 45 708 107 q 429 -17 543 -17 m 622 435 q 561 590 614 538 q 424 642 508 642 q 283 589 338 642 q 214 435 228 536 l 622 435 m 258 1011 l 389 1011 l 481 833 l 383 833 l 258 1011 z "},"ē":{"ha":833,"x_min":92,"x_max":750,"o":"m 429 -17 q 250 31 326 -17 q 133 165 174 78 q 92 368 92 251 q 133 571 92 483 q 249 706 174 658 q 424 753 324 753 q 594 708 519 753 q 709 575 668 663 q 750 367 750 488 l 750 332 l 214 332 q 276 155 219 215 q 429 94 332 94 q 547 127 500 94 q 613 218 593 160 l 738 208 q 626 45 708 107 q 429 -17 543 -17 m 622 435 q 561 590 614 538 q 424 642 508 642 q 283 589 338 642 q 214 435 228 536 l 622 435 m 238 965 l 610 965 l 610 865 l 238 865 l 238 965 z "},"ę":{"ha":833,"x_min":92,"x_max":750,"o":"m 429 -17 q 250 31 326 -17 q 133 165 174 78 q 92 368 92 251 q 133 571 92 483 q 249 706 174 658 q 424 753 324 753 q 594 708 519 753 q 709 575 668 663 q 750 367 750 488 l 750 332 l 214 332 q 276 155 219 215 q 429 94 332 94 q 547 127 500 94 q 613 218 593 160 l 738 208 q 608 33 703 94 l 560 -29 q 526 -83 535 -62 q 517 -122 517 -104 q 558 -164 517 -164 q 593 -159 575 -164 q 622 -144 611 -154 l 656 -222 q 607 -249 639 -239 q 536 -258 575 -258 q 444 -228 478 -258 q 411 -147 411 -199 q 463 -15 411 -83 l 429 -17 m 622 435 q 561 590 614 538 q 424 642 508 642 q 283 589 338 642 q 214 435 228 536 l 622 435 z "},"ẽ":{"ha":833,"x_min":92,"x_max":750,"o":"m 429 -17 q 250 31 326 -17 q 133 165 174 78 q 92 368 92 251 q 133 571 92 483 q 249 706 174 658 q 424 753 324 753 q 594 708 519 753 q 709 575 668 663 q 750 367 750 488 l 750 332 l 214 332 q 276 155 219 215 q 429 94 332 94 q 547 127 500 94 q 613 218 593 160 l 738 208 q 626 45 708 107 q 429 -17 543 -17 m 622 435 q 561 590 614 538 q 424 642 508 642 q 283 589 338 642 q 214 435 228 536 l 622 435 m 508 844 q 411 886 463 844 q 372 915 385 908 q 339 922 358 922 q 278 835 282 922 l 206 835 q 244 955 207 913 q 339 997 281 997 q 394 986 369 997 q 444 953 418 975 q 482 927 467 935 q 514 919 497 919 q 554 940 540 919 q 572 1011 568 960 l 644 1011 q 604 888 640 932 q 508 844 568 844 z "},"ə":{"ha":833,"x_min":83,"x_max":742,"o":"m 404 753 q 583 706 507 753 q 701 572 660 658 q 742 368 742 485 q 701 165 742 253 q 585 31 660 78 q 410 -17 510 -17 q 240 28 314 -17 q 124 161 165 74 q 83 369 83 249 l 83 404 l 619 404 q 558 581 614 521 q 404 642 501 642 q 287 609 333 642 q 221 518 240 576 l 96 528 q 208 691 125 629 q 404 753 290 753 m 211 301 q 272 147 219 199 q 410 94 325 94 q 551 147 496 94 q 619 301 606 200 l 211 301 z "},"f":{"ha":833,"x_min":97,"x_max":736,"o":"m 322 783 q 374 934 322 882 q 528 986 425 986 l 736 986 l 736 883 l 528 883 q 462 857 485 883 q 439 783 439 831 l 439 736 l 722 736 l 722 633 l 439 633 l 439 0 l 322 0 l 322 633 l 97 633 l 97 736 l 322 736 l 322 783 z "},"g":{"ha":833,"x_min":83,"x_max":736,"o":"m 413 -225 q 211 -167 294 -225 q 104 -11 128 -108 l 226 -3 q 288 -86 244 -58 q 413 -114 331 -114 q 567 -74 514 -114 q 619 47 619 -33 l 619 167 q 534 74 592 108 q 404 39 476 39 q 238 84 311 39 q 124 210 165 129 q 83 396 83 292 q 124 581 83 500 q 235 708 164 663 q 399 753 307 753 q 538 717 476 753 q 625 619 600 681 l 625 736 l 736 736 l 736 50 q 650 -152 736 -79 q 413 -225 564 -225 m 410 150 q 563 218 507 150 q 619 403 619 286 q 563 577 619 513 q 410 642 506 642 q 260 576 314 642 q 206 396 206 510 q 260 216 206 282 q 410 150 315 150 z "},"ğ":{"ha":833,"x_min":83,"x_max":736,"o":"m 413 -225 q 211 -167 294 -225 q 104 -11 128 -108 l 226 -3 q 288 -86 244 -58 q 413 -114 331 -114 q 567 -74 514 -114 q 619 47 619 -33 l 619 167 q 534 74 592 108 q 404 39 476 39 q 238 84 311 39 q 124 210 165 129 q 83 396 83 292 q 124 581 83 500 q 235 708 164 663 q 399 753 307 753 q 538 717 476 753 q 625 619 600 681 l 625 736 l 736 736 l 736 50 q 650 -152 736 -79 q 413 -225 564 -225 m 410 150 q 563 218 507 150 q 619 403 619 286 q 563 577 619 513 q 410 642 506 642 q 260 576 314 642 q 206 396 206 510 q 260 216 206 282 q 410 150 315 150 m 414 838 q 276 885 329 838 q 219 1015 224 933 l 300 1015 q 414 924 313 924 q 528 1015 515 924 l 608 1015 q 551 885 604 933 q 414 838 499 838 z "},"ǧ":{"ha":833,"x_min":83,"x_max":736,"o":"m 413 -225 q 211 -167 294 -225 q 104 -11 128 -108 l 226 -3 q 288 -86 244 -58 q 413 -114 331 -114 q 567 -74 514 -114 q 619 47 619 -33 l 619 167 q 534 74 592 108 q 404 39 476 39 q 238 84 311 39 q 124 210 165 129 q 83 396 83 292 q 124 581 83 500 q 235 708 164 663 q 399 753 307 753 q 538 717 476 753 q 625 619 600 681 l 625 736 l 736 736 l 736 50 q 650 -152 736 -79 q 413 -225 564 -225 m 410 150 q 563 218 507 150 q 619 403 619 286 q 563 577 619 513 q 410 642 506 642 q 260 576 314 642 q 206 396 206 510 q 260 216 206 282 q 410 150 315 150 m 317 1014 l 414 889 l 511 1014 l 608 1014 l 479 829 l 349 829 l 219 1014 l 317 1014 z "},"ĝ":{"ha":833,"x_min":83,"x_max":736,"o":"m 413 -225 q 211 -167 294 -225 q 104 -11 128 -108 l 226 -3 q 288 -86 244 -58 q 413 -114 331 -114 q 567 -74 514 -114 q 619 47 619 -33 l 619 167 q 534 74 592 108 q 404 39 476 39 q 238 84 311 39 q 124 210 165 129 q 83 396 83 292 q 124 581 83 500 q 235 708 164 663 q 399 753 307 753 q 538 717 476 753 q 625 619 600 681 l 625 736 l 736 736 l 736 50 q 650 -152 736 -79 q 413 -225 564 -225 m 410 150 q 563 218 507 150 q 619 403 619 286 q 563 577 619 513 q 410 642 506 642 q 260 576 314 642 q 206 396 206 510 q 260 216 206 282 q 410 150 315 150 m 347 1015 l 478 1015 l 607 831 l 510 831 l 413 956 l 315 831 l 218 831 l 347 1015 z "},"ģ":{"ha":833,"x_min":83,"x_max":736,"o":"m 413 -225 q 211 -167 294 -225 q 104 -11 128 -108 l 226 -3 q 288 -86 244 -58 q 413 -114 331 -114 q 567 -74 514 -114 q 619 47 619 -33 l 619 167 q 534 74 592 108 q 404 39 476 39 q 238 84 311 39 q 124 210 165 129 q 83 396 83 292 q 124 581 83 500 q 235 708 164 663 q 399 753 307 753 q 538 717 476 753 q 625 619 600 681 l 625 736 l 736 736 l 736 50 q 650 -152 736 -79 q 413 -225 564 -225 m 410 150 q 563 218 507 150 q 619 403 619 286 q 563 577 619 513 q 410 642 506 642 q 260 576 314 642 q 206 396 206 510 q 260 216 206 282 q 410 150 315 150 m 440 949 l 488 949 l 488 824 l 349 824 l 349 943 l 438 1074 l 515 1074 l 440 949 z "},"ġ":{"ha":833,"x_min":83,"x_max":736,"o":"m 413 -225 q 211 -167 294 -225 q 104 -11 128 -108 l 226 -3 q 288 -86 244 -58 q 413 -114 331 -114 q 567 -74 514 -114 q 619 47 619 -33 l 619 167 q 534 74 592 108 q 404 39 476 39 q 238 84 311 39 q 124 210 165 129 q 83 396 83 292 q 124 581 83 500 q 235 708 164 663 q 399 753 307 753 q 538 717 476 753 q 625 619 600 681 l 625 736 l 736 736 l 736 50 q 650 -152 736 -79 q 413 -225 564 -225 m 410 150 q 563 218 507 150 q 619 403 619 286 q 563 577 619 513 q 410 642 506 642 q 260 576 314 642 q 206 396 206 510 q 260 216 206 282 q 410 150 315 150 m 351 988 l 474 988 l 474 851 l 351 851 l 351 988 z "},"ḡ":{"ha":833,"x_min":83,"x_max":736,"o":"m 413 -225 q 211 -167 294 -225 q 104 -11 128 -108 l 226 -3 q 288 -86 244 -58 q 413 -114 331 -114 q 567 -74 514 -114 q 619 47 619 -33 l 619 167 q 534 74 592 108 q 404 39 476 39 q 238 84 311 39 q 124 210 165 129 q 83 396 83 292 q 124 581 83 500 q 235 708 164 663 q 399 753 307 753 q 538 717 476 753 q 625 619 600 681 l 625 736 l 736 736 l 736 50 q 650 -152 736 -79 q 413 -225 564 -225 m 410 150 q 563 218 507 150 q 619 403 619 286 q 563 577 619 513 q 410 642 506 642 q 260 576 314 642 q 206 396 206 510 q 260 216 206 282 q 410 150 315 150 m 226 968 l 599 968 l 599 868 l 226 868 l 226 968 z "},"ǥ":{"ha":833,"x_min":83,"x_max":808,"o":"m 410 443 l 617 443 q 551 589 607 536 q 410 642 496 642 q 260 576 314 642 q 206 396 206 510 q 260 216 206 282 q 410 150 315 150 q 546 201 492 150 q 615 340 600 251 l 410 340 l 410 443 m 413 -225 q 211 -167 294 -225 q 104 -11 128 -108 l 226 -3 q 288 -86 244 -58 q 413 -114 331 -114 q 567 -74 514 -114 q 619 47 619 -33 l 619 167 q 534 74 592 108 q 404 39 476 39 q 238 84 311 39 q 124 210 165 129 q 83 396 83 292 q 124 581 83 500 q 235 708 164 663 q 399 753 307 753 q 538 717 476 753 q 625 619 600 681 l 625 736 l 736 736 l 736 443 l 808 443 l 808 340 l 736 340 l 736 50 q 650 -152 736 -79 q 413 -225 564 -225 z "},"h":{"ha":833,"x_min":139,"x_max":722,"o":"m 139 986 l 256 986 l 256 621 q 341 719 283 686 q 476 753 399 753 q 658 676 593 753 q 722 474 722 600 l 722 0 l 606 0 l 606 440 q 451 650 606 650 q 308 594 361 650 q 256 439 256 539 l 256 0 l 139 0 l 139 986 z "},"ħ":{"ha":833,"x_min":35,"x_max":722,"o":"m 35 914 l 139 914 l 139 986 l 256 986 l 256 914 l 388 914 l 388 811 l 256 811 l 256 621 q 341 719 283 686 q 476 753 399 753 q 658 676 593 753 q 722 474 722 600 l 722 0 l 606 0 l 606 440 q 451 650 606 650 q 308 594 361 650 q 256 439 256 539 l 256 0 l 139 0 l 139 811 l 35 811 l 35 914 z "},"ĥ":{"ha":833,"x_min":3,"x_max":722,"o":"m 139 986 l 256 986 l 256 621 q 341 719 283 686 q 476 753 399 753 q 658 676 593 753 q 722 474 722 600 l 722 0 l 606 0 l 606 440 q 451 650 606 650 q 308 594 361 650 q 256 439 256 539 l 256 0 l 139 0 l 139 986 m 132 1265 l 263 1265 l 392 1081 l 294 1081 l 197 1206 l 100 1081 l 3 1081 l 132 1265 z "},"i":{"ha":833,"x_min":111,"x_max":778,"o":"m 111 103 l 407 103 l 407 633 l 125 633 l 125 736 l 524 736 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 m 396 988 l 518 988 l 518 851 l 396 851 l 396 988 z "},"ı":{"ha":833,"x_min":111,"x_max":778,"o":"m 111 103 l 407 103 l 407 633 l 125 633 l 125 736 l 524 736 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 z "},"í":{"ha":833,"x_min":111,"x_max":778,"o":"m 111 103 l 407 103 l 407 633 l 125 633 l 125 736 l 524 736 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 m 497 1014 l 628 1014 l 503 836 l 406 836 l 497 1014 z "},"î":{"ha":833,"x_min":111,"x_max":778,"o":"m 111 103 l 407 103 l 407 633 l 125 633 l 125 736 l 524 736 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 m 392 1015 l 522 1015 l 651 831 l 554 831 l 457 956 l 360 831 l 263 831 l 392 1015 z "},"ï":{"ha":833,"x_min":111,"x_max":778,"o":"m 111 103 l 407 103 l 407 633 l 125 633 l 125 736 l 524 736 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 z "},"ì":{"ha":833,"x_min":111,"x_max":778,"o":"m 111 103 l 407 103 l 407 633 l 125 633 l 125 736 l 524 736 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 m 292 1014 l 422 1014 l 514 836 l 417 836 l 292 1014 z "},"ī":{"ha":833,"x_min":111,"x_max":778,"o":"m 111 103 l 407 103 l 407 633 l 125 633 l 125 736 l 524 736 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 m 271 968 l 643 968 l 643 868 l 271 868 l 271 968 z "},"į":{"ha":833,"x_min":111,"x_max":825,"o":"m 111 103 l 407 103 l 407 633 l 125 633 l 125 736 l 524 736 l 524 103 l 778 103 l 778 0 l 729 -62 q 695 -117 704 -96 q 686 -156 686 -137 q 728 -197 686 -197 q 763 -192 744 -197 q 792 -178 781 -187 l 825 -256 q 776 -282 808 -272 q 706 -292 744 -292 q 614 -262 647 -292 q 581 -181 581 -232 q 638 -42 581 -112 l 671 0 l 111 0 l 111 103 m 396 988 l 518 988 l 518 851 l 396 851 l 396 988 z "},"ĩ":{"ha":833,"x_min":111,"x_max":778,"o":"m 111 103 l 407 103 l 407 633 l 125 633 l 125 736 l 524 736 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 m 542 847 q 444 889 496 847 q 405 918 418 911 q 372 925 392 925 q 311 838 315 925 l 239 838 q 277 958 240 915 q 372 1000 314 1000 q 427 989 403 1000 q 478 956 451 978 q 515 930 500 938 q 547 922 531 922 q 588 942 574 922 q 606 1014 601 963 l 678 1014 q 638 891 674 935 q 542 847 601 847 z "},"j":{"ha":833,"x_min":153,"x_max":611,"o":"m 153 -106 l 413 -106 q 472 -76 450 -106 q 494 0 494 -46 l 494 633 l 167 633 l 167 736 l 611 736 l 611 0 q 563 -152 611 -96 q 425 -208 515 -208 l 153 -208 l 153 -106 m 489 988 l 611 988 l 611 851 l 489 851 l 489 988 z "},"ȷ":{"ha":833,"x_min":153,"x_max":611,"o":"m 153 -106 l 413 -106 q 472 -76 450 -106 q 494 0 494 -46 l 494 633 l 167 633 l 167 736 l 611 736 l 611 0 q 563 -152 611 -96 q 425 -208 515 -208 l 153 -208 l 153 -106 z "},"ĵ":{"ha":833,"x_min":153,"x_max":744,"o":"m 153 -106 l 413 -106 q 472 -76 450 -106 q 494 0 494 -46 l 494 633 l 167 633 l 167 736 l 611 736 l 611 0 q 563 -152 611 -96 q 425 -208 515 -208 l 153 -208 l 153 -106 m 485 1015 l 615 1015 l 744 831 l 647 831 l 550 956 l 453 831 l 356 831 l 485 1015 z "},"k":{"ha":833,"x_min":125,"x_max":769,"o":"m 125 986 l 242 986 l 242 340 l 600 736 l 756 736 l 465 424 l 769 0 l 628 0 l 386 344 l 242 192 l 242 0 l 125 0 l 125 986 z "},"ǩ":{"ha":833,"x_min":-10,"x_max":769,"o":"m 125 986 l 242 986 l 242 340 l 600 736 l 756 736 l 465 424 l 769 0 l 628 0 l 386 344 l 242 192 l 242 0 l 125 0 l 125 986 m 88 1264 l 185 1139 l 282 1264 l 379 1264 l 250 1079 l 119 1079 l -10 1264 l 88 1264 z "},"ķ":{"ha":833,"x_min":125,"x_max":769,"o":"m 125 986 l 242 986 l 242 340 l 600 736 l 756 736 l 465 424 l 769 0 l 628 0 l 386 344 l 242 192 l 242 0 l 125 0 l 125 986 m 421 -172 l 374 -172 l 374 -47 l 513 -47 l 513 -167 l 424 -297 l 346 -297 l 421 -172 z "},"l":{"ha":833,"x_min":111,"x_max":778,"o":"m 407 789 q 382 858 407 833 q 313 883 357 883 l 139 883 l 139 986 l 318 986 q 472 934 419 986 q 524 781 524 882 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 l 407 103 l 407 789 z "},"ĺ":{"ha":833,"x_min":111,"x_max":778,"o":"m 407 789 q 382 858 407 833 q 313 883 357 883 l 139 883 l 139 986 l 318 986 q 472 934 419 986 q 524 781 524 882 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 l 407 103 l 407 789 m 456 1264 l 586 1264 l 461 1086 l 364 1086 l 456 1264 z "},"ľ":{"ha":833,"x_min":111,"x_max":778,"o":"m 407 789 q 382 858 407 833 q 313 883 357 883 l 139 883 l 139 986 l 318 986 q 472 934 419 986 q 524 781 524 882 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 l 407 103 l 407 789 m 625 986 l 740 986 l 668 650 l 583 650 l 625 986 z "},"ļ":{"ha":833,"x_min":111,"x_max":778,"o":"m 407 789 q 382 858 407 833 q 313 883 357 883 l 139 883 l 139 986 l 318 986 q 472 934 419 986 q 524 781 524 882 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 l 407 103 l 407 789 m 408 -172 l 361 -172 l 361 -47 l 500 -47 l 500 -167 l 411 -297 l 333 -297 l 408 -172 z "},"ł":{"ha":833,"x_min":139,"x_max":806,"o":"m 315 515 l 435 571 l 435 789 q 410 858 435 833 q 340 883 385 883 l 167 883 l 167 986 l 346 986 q 499 934 447 986 q 551 781 551 882 l 551 624 l 629 660 l 672 567 l 551 511 l 551 103 l 806 103 l 806 0 l 139 0 l 139 103 l 435 103 l 435 457 l 358 422 l 315 515 z "},"m":{"ha":833,"x_min":56,"x_max":778,"o":"m 56 736 l 163 736 l 165 628 q 222 720 185 688 q 311 753 260 753 q 450 608 425 753 q 510 715 468 676 q 608 753 551 753 q 738 690 697 753 q 778 486 778 628 l 778 0 l 661 0 l 661 472 q 640 609 661 568 q 575 650 619 650 q 502 605 529 650 q 475 469 475 560 l 475 0 l 358 0 l 358 472 q 338 608 358 567 q 272 650 318 650 q 199 605 226 650 q 172 469 172 560 l 172 0 l 56 0 l 56 736 z "},"n":{"ha":833,"x_min":133,"x_max":717,"o":"m 133 736 l 240 736 l 243 604 q 331 715 271 678 q 469 753 390 753 q 653 675 590 753 q 717 474 717 597 l 717 0 l 600 0 l 600 440 q 563 597 600 544 q 444 650 526 650 q 303 595 357 650 q 250 440 250 540 l 250 0 l 133 0 l 133 736 z "},"ń":{"ha":833,"x_min":133,"x_max":717,"o":"m 133 736 l 240 736 l 243 604 q 331 715 271 678 q 469 753 390 753 q 653 675 590 753 q 717 474 717 597 l 717 0 l 600 0 l 600 440 q 563 597 600 544 q 444 650 526 650 q 303 595 357 650 q 250 440 250 540 l 250 0 l 133 0 l 133 736 m 464 1014 l 594 1014 l 469 836 l 372 836 l 464 1014 z "},"ň":{"ha":833,"x_min":133,"x_max":717,"o":"m 133 736 l 240 736 l 243 604 q 331 715 271 678 q 469 753 390 753 q 653 675 590 753 q 717 474 717 597 l 717 0 l 600 0 l 600 440 q 563 597 600 544 q 444 650 526 650 q 303 595 357 650 q 250 440 250 540 l 250 0 l 133 0 l 133 736 m 328 1014 l 425 889 l 522 1014 l 619 1014 l 490 829 l 360 829 l 231 1014 l 328 1014 z "},"ņ":{"ha":833,"x_min":133,"x_max":717,"o":"m 133 736 l 240 736 l 243 604 q 331 715 271 678 q 469 753 390 753 q 653 675 590 753 q 717 474 717 597 l 717 0 l 600 0 l 600 440 q 563 597 600 544 q 444 650 526 650 q 303 595 357 650 q 250 440 250 540 l 250 0 l 133 0 l 133 736 m 417 -172 l 369 -172 l 369 -47 l 508 -47 l 508 -167 l 419 -297 l 342 -297 l 417 -172 z "},"ñ":{"ha":833,"x_min":133,"x_max":717,"o":"m 133 736 l 240 736 l 243 604 q 331 715 271 678 q 469 753 390 753 q 653 675 590 753 q 717 474 717 597 l 717 0 l 600 0 l 600 440 q 563 597 600 544 q 444 650 526 650 q 303 595 357 650 q 250 440 250 540 l 250 0 l 133 0 l 133 736 m 508 847 q 411 889 463 847 q 372 918 385 911 q 339 925 358 925 q 278 838 282 925 l 206 838 q 244 958 207 915 q 339 1000 281 1000 q 394 989 369 1000 q 444 956 418 978 q 482 930 467 938 q 514 922 497 922 q 554 942 540 922 q 572 1014 568 963 l 644 1014 q 604 891 640 935 q 508 847 568 847 z "},"ŋ":{"ha":833,"x_min":133,"x_max":717,"o":"m 396 -106 l 511 -106 q 576 -80 553 -106 q 600 -11 600 -54 l 600 440 q 563 597 600 544 q 444 650 526 650 q 303 595 357 650 q 250 440 250 540 l 250 0 l 133 0 l 133 736 l 240 736 l 243 604 q 331 715 271 678 q 469 753 390 753 q 653 675 590 753 q 717 474 717 597 l 717 -10 q 660 -155 717 -101 q 508 -208 604 -208 l 396 -208 l 396 -106 z "},"o":{"ha":833,"x_min":81,"x_max":753,"o":"m 417 -17 q 240 31 315 -17 q 122 165 164 78 q 81 368 81 251 q 122 572 81 485 q 240 706 164 658 q 417 753 315 753 q 593 706 517 753 q 711 572 669 658 q 753 368 753 485 q 711 165 753 251 q 593 31 669 78 q 417 -17 517 -17 m 417 94 q 574 167 518 94 q 631 368 631 240 q 574 569 631 496 q 417 642 518 642 q 259 569 315 642 q 203 368 203 497 q 259 167 203 239 q 417 94 315 94 z "},"ó":{"ha":833,"x_min":81,"x_max":753,"o":"m 417 -17 q 240 31 315 -17 q 122 165 164 78 q 81 368 81 251 q 122 572 81 485 q 240 706 164 658 q 417 753 315 753 q 593 706 517 753 q 711 572 669 658 q 753 368 753 485 q 711 165 753 251 q 593 31 669 78 q 417 -17 517 -17 m 417 94 q 574 167 518 94 q 631 368 631 240 q 574 569 631 496 q 417 642 518 642 q 259 569 315 642 q 203 368 203 497 q 259 167 203 239 q 417 94 315 94 m 456 1014 l 586 1014 l 461 836 l 364 836 l 456 1014 z "},"ô":{"ha":833,"x_min":81,"x_max":753,"o":"m 417 -17 q 240 31 315 -17 q 122 165 164 78 q 81 368 81 251 q 122 572 81 485 q 240 706 164 658 q 417 753 315 753 q 593 706 517 753 q 711 572 669 658 q 753 368 753 485 q 711 165 753 251 q 593 31 669 78 q 417 -17 517 -17 m 417 94 q 574 167 518 94 q 631 368 631 240 q 574 569 631 496 q 417 642 518 642 q 259 569 315 642 q 203 368 203 497 q 259 167 203 239 q 417 94 315 94 m 350 1015 l 481 1015 l 610 831 l 513 831 l 415 956 l 318 831 l 221 831 l 350 1015 z "},"ö":{"ha":833,"x_min":81,"x_max":753,"o":"m 417 -17 q 240 31 315 -17 q 122 165 164 78 q 81 368 81 251 q 122 572 81 485 q 240 706 164 658 q 417 753 315 753 q 593 706 517 753 q 711 572 669 658 q 753 368 753 485 q 711 165 753 251 q 593 31 669 78 q 417 -17 517 -17 m 417 94 q 574 167 518 94 q 631 368 631 240 q 574 569 631 496 q 417 642 518 642 q 259 569 315 642 q 203 368 203 497 q 259 167 203 239 q 417 94 315 94 z "},"ò":{"ha":833,"x_min":81,"x_max":753,"o":"m 417 -17 q 240 31 315 -17 q 122 165 164 78 q 81 368 81 251 q 122 572 81 485 q 240 706 164 658 q 417 753 315 753 q 593 706 517 753 q 711 572 669 658 q 753 368 753 485 q 711 165 753 251 q 593 31 669 78 q 417 -17 517 -17 m 417 94 q 574 167 518 94 q 631 368 631 240 q 574 569 631 496 q 417 642 518 642 q 259 569 315 642 q 203 368 203 497 q 259 167 203 239 q 417 94 315 94 m 250 1014 l 381 1014 l 472 836 l 375 836 l 250 1014 z "},"ő":{"ha":833,"x_min":81,"x_max":753,"o":"m 417 -17 q 240 31 315 -17 q 122 165 164 78 q 81 368 81 251 q 122 572 81 485 q 240 706 164 658 q 417 753 315 753 q 593 706 517 753 q 711 572 669 658 q 753 368 753 485 q 711 165 753 251 q 593 31 669 78 q 417 -17 517 -17 m 417 94 q 574 167 518 94 q 631 368 631 240 q 574 569 631 496 q 417 642 518 642 q 259 569 315 642 q 203 368 203 497 q 259 167 203 239 q 417 94 315 94 z "},"ō":{"ha":833,"x_min":81,"x_max":753,"o":"m 417 -17 q 240 31 315 -17 q 122 165 164 78 q 81 368 81 251 q 122 572 81 485 q 240 706 164 658 q 417 753 315 753 q 593 706 517 753 q 711 572 669 658 q 753 368 753 485 q 711 165 753 251 q 593 31 669 78 q 417 -17 517 -17 m 417 94 q 574 167 518 94 q 631 368 631 240 q 574 569 631 496 q 417 642 518 642 q 259 569 315 642 q 203 368 203 497 q 259 167 203 239 q 417 94 315 94 m 229 968 l 601 968 l 601 868 l 229 868 l 229 968 z "},"ø":{"ha":833,"x_min":49,"x_max":783,"o":"m 151 113 q 81 368 81 214 q 122 572 81 485 q 240 706 164 658 q 417 753 315 753 q 614 692 529 753 l 663 753 l 783 753 l 681 624 q 753 368 753 521 q 711 165 753 251 q 593 31 669 78 q 417 -17 517 -17 q 218 44 303 -17 l 169 -17 l 49 -17 l 151 113 m 417 94 q 574 167 518 94 q 631 368 631 240 q 601 524 631 461 l 290 135 q 417 94 342 94 m 203 368 q 232 213 203 275 l 542 601 q 417 642 490 642 q 259 569 315 642 q 203 368 203 497 z "},"õ":{"ha":833,"x_min":81,"x_max":753,"o":"m 417 -17 q 240 31 315 -17 q 122 165 164 78 q 81 368 81 251 q 122 572 81 485 q 240 706 164 658 q 417 753 315 753 q 593 706 517 753 q 711 572 669 658 q 753 368 753 485 q 711 165 753 251 q 593 31 669 78 q 417 -17 517 -17 m 417 94 q 574 167 518 94 q 631 368 631 240 q 574 569 631 496 q 417 642 518 642 q 259 569 315 642 q 203 368 203 497 q 259 167 203 239 q 417 94 315 94 m 500 847 q 403 889 454 847 q 363 918 376 911 q 331 925 350 925 q 269 838 274 925 l 197 838 q 235 958 199 915 q 331 1000 272 1000 q 385 989 361 1000 q 436 956 410 978 q 474 930 458 938 q 506 922 489 922 q 546 942 532 922 q 564 1014 560 963 l 636 1014 q 596 891 632 935 q 500 847 560 847 z "},"œ":{"ha":833,"x_min":14,"x_max":821,"o":"m 594 -17 q 414 90 475 -17 q 243 -17 351 -17 q 122 31 174 -17 q 42 165 69 78 q 14 368 14 251 q 42 572 14 485 q 122 706 69 658 q 243 753 174 753 q 414 646 351 753 q 594 753 475 753 q 757 661 701 753 q 819 385 813 569 l 821 332 l 475 332 q 511 156 481 218 q 594 94 542 94 q 658 123 635 94 q 694 218 681 151 l 813 208 q 737 35 786 86 q 594 -17 688 -17 m 243 94 q 326 168 296 94 q 357 368 357 242 q 326 568 357 494 q 243 642 296 642 q 161 567 192 642 q 131 368 131 493 q 161 169 131 243 q 243 94 192 94 m 700 435 q 588 642 686 642 q 476 435 493 642 l 700 435 z "},"p":{"ha":833,"x_min":111,"x_max":764,"o":"m 111 736 l 219 736 l 221 618 q 313 718 254 683 q 447 753 372 753 q 624 701 553 753 q 729 561 694 649 q 764 368 764 474 q 729 175 764 263 q 624 35 694 88 q 447 -17 553 -17 q 316 14 375 -17 q 228 97 257 44 l 228 -208 l 111 -208 l 111 736 m 436 94 q 587 167 532 94 q 642 368 642 239 q 587 569 642 497 q 436 642 532 642 q 283 572 339 642 q 228 368 228 501 q 283 165 228 235 q 436 94 338 94 z "},"þ":{"ha":833,"x_min":111,"x_max":764,"o":"m 111 986 l 219 986 l 222 618 q 313 718 254 683 q 447 753 372 753 q 624 701 553 753 q 729 561 694 649 q 764 368 764 474 q 729 175 764 263 q 624 35 694 88 q 447 -17 553 -17 q 316 14 375 -17 q 228 97 257 44 l 228 -208 l 111 -208 l 111 986 m 436 94 q 587 167 532 94 q 642 368 642 239 q 587 569 642 497 q 436 642 532 642 q 283 572 339 642 q 228 368 228 501 q 283 165 228 235 q 436 94 338 94 z "},"q":{"ha":833,"x_min":69,"x_max":722,"o":"m 606 -208 l 606 97 q 517 14 576 44 q 386 -17 458 -17 q 210 35 281 -17 q 104 175 139 88 q 69 368 69 263 q 104 561 69 474 q 210 701 139 649 q 386 753 281 753 q 520 718 461 753 q 613 618 579 683 l 614 736 l 722 736 l 722 -208 l 606 -208 m 397 94 q 551 165 496 94 q 606 368 606 235 q 550 572 606 501 q 397 642 494 642 q 247 569 301 642 q 192 368 192 497 q 247 167 192 239 q 397 94 301 94 z "},"r":{"ha":833,"x_min":125,"x_max":750,"o":"m 125 103 l 317 103 l 317 633 l 125 633 l 125 736 l 414 736 l 422 594 q 599 736 460 736 l 750 736 l 750 631 l 600 631 q 476 583 519 631 q 433 444 433 535 l 433 103 l 667 103 l 667 0 l 125 0 l 125 103 z "},"ŕ":{"ha":833,"x_min":125,"x_max":750,"o":"m 125 103 l 317 103 l 317 633 l 125 633 l 125 736 l 414 736 l 422 594 q 599 736 460 736 l 750 736 l 750 631 l 600 631 q 476 583 519 631 q 433 444 433 535 l 433 103 l 667 103 l 667 0 l 125 0 l 125 103 m 510 1014 l 640 1014 l 515 836 l 418 836 l 510 1014 z "},"ř":{"ha":833,"x_min":125,"x_max":750,"o":"m 125 103 l 317 103 l 317 633 l 125 633 l 125 736 l 414 736 l 422 594 q 599 736 460 736 l 750 736 l 750 631 l 600 631 q 476 583 519 631 q 433 444 433 535 l 433 103 l 667 103 l 667 0 l 125 0 l 125 103 m 374 1014 l 471 889 l 568 1014 l 665 1014 l 536 829 l 406 829 l 276 1014 l 374 1014 z "},"ŗ":{"ha":833,"x_min":125,"x_max":750,"o":"m 125 103 l 317 103 l 317 633 l 125 633 l 125 736 l 414 736 l 422 594 q 599 736 460 736 l 750 736 l 750 631 l 600 631 q 476 583 519 631 q 433 444 433 535 l 433 103 l 667 103 l 667 0 l 125 0 l 125 103 m 365 -172 l 318 -172 l 318 -47 l 457 -47 l 457 -167 l 368 -297 l 290 -297 l 365 -172 z "},"s":{"ha":833,"x_min":111,"x_max":722,"o":"m 431 -17 q 267 16 338 -17 q 157 105 197 49 q 111 232 117 161 l 233 240 q 294 133 244 171 q 431 94 344 94 q 558 119 514 94 q 603 192 603 143 q 588 245 603 225 q 531 280 572 265 q 408 308 490 294 q 242 356 303 326 q 156 426 182 385 q 131 531 131 468 q 204 691 131 629 q 410 753 278 753 q 615 686 539 753 q 708 517 690 619 l 586 508 q 527 606 574 569 q 408 642 481 642 q 290 611 331 642 q 250 531 250 581 q 268 472 250 493 q 324 437 286 450 q 433 411 363 424 q 610 365 547 393 q 697 297 672 338 q 722 192 722 256 q 640 40 722 96 q 431 -17 558 -17 z "},"ś":{"ha":833,"x_min":111,"x_max":722,"o":"m 431 -17 q 267 16 338 -17 q 157 105 197 49 q 111 232 117 161 l 233 240 q 294 133 244 171 q 431 94 344 94 q 558 119 514 94 q 603 192 603 143 q 588 245 603 225 q 531 280 572 265 q 408 308 490 294 q 242 356 303 326 q 156 426 182 385 q 131 531 131 468 q 204 691 131 629 q 410 753 278 753 q 615 686 539 753 q 708 517 690 619 l 586 508 q 527 606 574 569 q 408 642 481 642 q 290 611 331 642 q 250 531 250 581 q 268 472 250 493 q 324 437 286 450 q 433 411 363 424 q 610 365 547 393 q 697 297 672 338 q 722 192 722 256 q 640 40 722 96 q 431 -17 558 -17 m 458 1014 l 589 1014 l 464 836 l 367 836 l 458 1014 z "},"š":{"ha":833,"x_min":111,"x_max":722,"o":"m 431 -17 q 267 16 338 -17 q 157 105 197 49 q 111 232 117 161 l 233 240 q 294 133 244 171 q 431 94 344 94 q 558 119 514 94 q 603 192 603 143 q 588 245 603 225 q 531 280 572 265 q 408 308 490 294 q 242 356 303 326 q 156 426 182 385 q 131 531 131 468 q 204 691 131 629 q 410 753 278 753 q 615 686 539 753 q 708 517 690 619 l 586 508 q 527 606 574 569 q 408 642 481 642 q 290 611 331 642 q 250 531 250 581 q 268 472 250 493 q 324 437 286 450 q 433 411 363 424 q 610 365 547 393 q 697 297 672 338 q 722 192 722 256 q 640 40 722 96 q 431 -17 558 -17 m 322 1014 l 419 889 l 517 1014 l 614 1014 l 485 829 l 354 829 l 225 1014 l 322 1014 z "},"ş":{"ha":833,"x_min":111,"x_max":722,"o":"m 410 -306 q 326 -284 363 -306 q 247 -212 290 -262 l 304 -160 q 351 -211 329 -192 q 404 -231 374 -231 q 452 -215 435 -231 q 469 -171 469 -199 q 450 -129 469 -144 q 397 -114 431 -114 q 364 -118 383 -114 l 325 -67 l 374 -14 q 188 68 258 0 q 111 232 118 136 l 233 240 q 294 133 244 171 q 431 94 344 94 q 558 119 514 94 q 603 192 603 143 q 588 245 603 225 q 531 280 572 265 q 408 308 490 294 q 242 356 303 326 q 156 426 182 385 q 131 531 131 468 q 204 691 131 629 q 410 753 278 753 q 615 686 539 753 q 708 517 690 619 l 586 508 q 527 606 574 569 q 408 642 481 642 q 290 611 331 642 q 250 531 250 581 q 268 472 250 493 q 324 437 286 450 q 433 411 363 424 q 610 365 547 393 q 697 297 672 338 q 722 192 722 256 q 644 42 722 97 q 443 -17 567 -14 l 411 -51 q 508 -86 471 -51 q 546 -176 546 -121 q 506 -268 546 -231 q 410 -306 467 -306 z "},"ŝ":{"ha":833,"x_min":111,"x_max":722,"o":"m 431 -17 q 267 16 338 -17 q 157 105 197 49 q 111 232 117 161 l 233 240 q 294 133 244 171 q 431 94 344 94 q 558 119 514 94 q 603 192 603 143 q 588 245 603 225 q 531 280 572 265 q 408 308 490 294 q 242 356 303 326 q 156 426 182 385 q 131 531 131 468 q 204 691 131 629 q 410 753 278 753 q 615 686 539 753 q 708 517 690 619 l 586 508 q 527 606 574 569 q 408 642 481 642 q 290 611 331 642 q 250 531 250 581 q 268 472 250 493 q 324 437 286 450 q 433 411 363 424 q 610 365 547 393 q 697 297 672 338 q 722 192 722 256 q 640 40 722 96 q 431 -17 558 -17 m 353 1015 l 483 1015 l 613 831 l 515 831 l 418 956 l 321 831 l 224 831 l 353 1015 z "},"ș":{"ha":833,"x_min":111,"x_max":722,"o":"m 431 -17 q 267 16 338 -17 q 157 105 197 49 q 111 232 117 161 l 233 240 q 294 133 244 171 q 431 94 344 94 q 558 119 514 94 q 603 192 603 143 q 588 245 603 225 q 531 280 572 265 q 408 308 490 294 q 242 356 303 326 q 156 426 182 385 q 131 531 131 468 q 204 691 131 629 q 410 753 278 753 q 615 686 539 753 q 708 517 690 619 l 586 508 q 527 606 574 569 q 408 642 481 642 q 290 611 331 642 q 250 531 250 581 q 268 472 250 493 q 324 437 286 450 q 433 411 363 424 q 610 365 547 393 q 697 297 672 338 q 722 192 722 256 q 640 40 722 96 q 431 -17 558 -17 m 411 -172 l 364 -172 l 364 -47 l 503 -47 l 503 -167 l 414 -297 l 336 -297 l 411 -172 z "},"ß":{"ha":833,"x_min":106,"x_max":756,"o":"m 106 697 q 144 861 106 792 q 250 967 182 931 q 403 1003 318 1003 q 555 972 489 1003 q 658 883 621 940 q 694 751 694 826 q 656 617 694 675 q 546 535 617 558 q 698 442 640 510 q 756 276 756 374 q 708 126 756 189 q 581 32 660 64 q 407 0 501 0 l 318 0 l 318 111 l 407 111 q 567 157 501 111 q 633 286 633 203 q 565 425 633 378 q 393 469 496 472 l 332 468 l 332 581 l 393 579 q 524 623 474 578 q 575 740 575 668 q 527 849 575 807 q 403 892 479 892 q 272 842 321 892 q 224 697 224 792 l 224 0 l 106 0 l 106 697 z "},"t":{"ha":833,"x_min":69,"x_max":722,"o":"m 525 0 q 365 50 418 0 q 313 203 313 100 l 313 633 l 69 633 l 69 736 l 313 736 l 313 931 l 429 931 l 429 736 l 722 736 l 722 633 l 429 633 l 429 203 q 453 127 429 151 q 525 103 476 103 l 722 103 l 722 0 l 525 0 z "},"ŧ":{"ha":833,"x_min":69,"x_max":722,"o":"m 92 443 l 313 443 l 313 633 l 69 633 l 69 736 l 313 736 l 313 931 l 429 931 l 429 736 l 722 736 l 722 633 l 429 633 l 429 443 l 710 443 l 710 340 l 429 340 l 429 203 q 453 127 429 151 q 525 103 476 103 l 722 103 l 722 0 l 525 0 q 365 50 418 0 q 313 203 313 100 l 313 340 l 92 340 l 92 443 z "},"ť":{"ha":833,"x_min":69,"x_max":722,"o":"m 525 0 q 365 50 418 0 q 313 203 313 100 l 313 633 l 69 633 l 69 736 l 313 736 l 313 931 l 429 931 l 429 736 l 722 736 l 722 633 l 429 633 l 429 203 q 453 127 429 151 q 525 103 476 103 l 722 103 l 722 0 l 525 0 m 550 1106 l 665 1106 l 593 769 l 508 769 l 550 1106 z "},"ţ":{"ha":833,"x_min":69,"x_max":722,"o":"m 478 -51 q 575 -86 538 -51 q 613 -176 613 -121 q 573 -268 613 -231 q 476 -306 533 -306 q 393 -284 429 -306 q 314 -212 357 -262 l 371 -160 q 418 -211 396 -192 q 471 -231 440 -231 q 519 -215 501 -231 q 536 -171 536 -199 q 517 -129 536 -144 q 464 -114 497 -114 q 431 -118 450 -114 l 392 -67 l 458 6 q 349 68 385 19 q 313 203 313 117 l 313 633 l 69 633 l 69 736 l 313 736 l 313 931 l 429 931 l 429 736 l 722 736 l 722 633 l 429 633 l 429 203 q 453 127 429 151 q 525 103 476 103 l 722 103 l 722 0 l 525 0 l 478 -51 z "},"ț":{"ha":833,"x_min":69,"x_max":722,"o":"m 525 0 q 365 50 418 0 q 313 203 313 100 l 313 633 l 69 633 l 69 736 l 313 736 l 313 931 l 429 931 l 429 736 l 722 736 l 722 633 l 429 633 l 429 203 q 453 127 429 151 q 525 103 476 103 l 722 103 l 722 0 l 525 0 m 478 -172 l 431 -172 l 431 -47 l 569 -47 l 569 -167 l 481 -297 l 403 -297 l 478 -172 z "},"u":{"ha":833,"x_min":125,"x_max":700,"o":"m 365 -17 q 190 58 256 -17 q 125 263 125 132 l 125 736 l 242 736 l 242 294 q 280 137 242 188 q 397 86 318 86 q 533 142 483 86 q 583 297 583 199 l 583 736 l 700 736 l 700 0 l 589 0 l 589 119 q 501 19 558 54 q 365 -17 444 -17 z "},"ú":{"ha":833,"x_min":125,"x_max":700,"o":"m 365 -17 q 190 58 256 -17 q 125 263 125 132 l 125 736 l 242 736 l 242 294 q 280 137 242 188 q 397 86 318 86 q 533 142 483 86 q 583 297 583 199 l 583 736 l 700 736 l 700 0 l 589 0 l 589 119 q 501 19 558 54 q 365 -17 444 -17 m 446 1014 l 576 1014 l 451 836 l 354 836 l 446 1014 z "},"ŭ":{"ha":833,"x_min":125,"x_max":700,"o":"m 365 -17 q 190 58 256 -17 q 125 263 125 132 l 125 736 l 242 736 l 242 294 q 280 137 242 188 q 397 86 318 86 q 533 142 483 86 q 583 297 583 199 l 583 736 l 700 736 l 700 0 l 589 0 l 589 119 q 501 19 558 54 q 365 -17 444 -17 m 407 838 q 269 885 322 838 q 213 1015 217 933 l 293 1015 q 407 924 306 924 q 521 1015 508 924 l 601 1015 q 544 885 597 933 q 407 838 492 838 z "},"û":{"ha":833,"x_min":125,"x_max":700,"o":"m 365 -17 q 190 58 256 -17 q 125 263 125 132 l 125 736 l 242 736 l 242 294 q 280 137 242 188 q 397 86 318 86 q 533 142 483 86 q 583 297 583 199 l 583 736 l 700 736 l 700 0 l 589 0 l 589 119 q 501 19 558 54 q 365 -17 444 -17 m 340 1015 l 471 1015 l 600 831 l 503 831 l 406 956 l 308 831 l 211 831 l 340 1015 z "},"ü":{"ha":833,"x_min":125,"x_max":700,"o":"m 365 -17 q 190 58 256 -17 q 125 263 125 132 l 125 736 l 242 736 l 242 294 q 280 137 242 188 q 397 86 318 86 q 533 142 483 86 q 583 297 583 199 l 583 736 l 700 736 l 700 0 l 589 0 l 589 119 q 501 19 558 54 q 365 -17 444 -17 z "},"ù":{"ha":833,"x_min":125,"x_max":700,"o":"m 365 -17 q 190 58 256 -17 q 125 263 125 132 l 125 736 l 242 736 l 242 294 q 280 137 242 188 q 397 86 318 86 q 533 142 483 86 q 583 297 583 199 l 583 736 l 700 736 l 700 0 l 589 0 l 589 119 q 501 19 558 54 q 365 -17 444 -17 m 240 1014 l 371 1014 l 463 836 l 365 836 l 240 1014 z "},"ű":{"ha":833,"x_min":125,"x_max":700,"o":"m 365 -17 q 190 58 256 -17 q 125 263 125 132 l 125 736 l 242 736 l 242 294 q 280 137 242 188 q 397 86 318 86 q 533 142 483 86 q 583 297 583 199 l 583 736 l 700 736 l 700 0 l 589 0 l 589 119 q 501 19 558 54 q 365 -17 444 -17 z "},"ū":{"ha":833,"x_min":125,"x_max":700,"o":"m 365 -17 q 190 58 256 -17 q 125 263 125 132 l 125 736 l 242 736 l 242 294 q 280 137 242 188 q 397 86 318 86 q 533 142 483 86 q 583 297 583 199 l 583 736 l 700 736 l 700 0 l 589 0 l 589 119 q 501 19 558 54 q 365 -17 444 -17 m 219 968 l 592 968 l 592 868 l 219 868 l 219 968 z "},"ų":{"ha":833,"x_min":125,"x_max":747,"o":"m 365 -17 q 190 58 256 -17 q 125 263 125 132 l 125 736 l 242 736 l 242 294 q 280 137 242 188 q 397 86 318 86 q 533 142 483 86 q 583 297 583 199 l 583 736 l 700 736 l 700 0 l 651 -62 q 617 -117 626 -96 q 608 -156 608 -137 q 650 -197 608 -197 q 685 -192 667 -197 q 714 -178 703 -187 l 747 -256 q 699 -282 731 -272 q 628 -292 667 -292 q 536 -262 569 -292 q 503 -181 503 -232 q 560 -42 503 -112 l 593 0 l 589 0 l 589 119 q 501 19 558 54 q 365 -17 444 -17 z "},"ů":{"ha":833,"x_min":125,"x_max":700,"o":"m 365 -17 q 190 58 256 -17 q 125 263 125 132 l 125 736 l 242 736 l 242 294 q 280 137 242 188 q 397 86 318 86 q 533 142 483 86 q 583 297 583 199 l 583 736 l 700 736 l 700 0 l 589 0 l 589 119 q 501 19 558 54 q 365 -17 444 -17 m 407 814 q 316 851 353 814 q 279 942 279 888 q 316 1033 279 996 q 407 1069 353 1069 q 498 1033 461 1069 q 535 942 535 996 q 498 851 535 888 q 407 814 461 814 m 407 883 q 449 900 432 883 q 465 942 465 917 q 449 983 465 967 q 407 1000 432 1000 q 365 983 382 1000 q 349 942 349 967 q 365 900 349 917 q 407 883 382 883 z "},"ũ":{"ha":833,"x_min":125,"x_max":700,"o":"m 365 -17 q 190 58 256 -17 q 125 263 125 132 l 125 736 l 242 736 l 242 294 q 280 137 242 188 q 397 86 318 86 q 533 142 483 86 q 583 297 583 199 l 583 736 l 700 736 l 700 0 l 589 0 l 589 119 q 501 19 558 54 q 365 -17 444 -17 m 490 847 q 393 889 444 847 q 353 918 367 911 q 321 925 340 925 q 260 838 264 925 l 188 838 q 226 958 189 915 q 321 1000 263 1000 q 376 989 351 1000 q 426 956 400 978 q 464 930 449 938 q 496 922 479 922 q 536 942 522 922 q 554 1014 550 963 l 626 1014 q 586 891 622 935 q 490 847 550 847 z "},"v":{"ha":833,"x_min":75,"x_max":758,"o":"m 75 736 l 203 736 l 417 119 l 631 736 l 758 736 l 490 0 l 343 0 l 75 736 z "},"w":{"ha":833,"x_min":28,"x_max":806,"o":"m 28 736 l 156 736 l 249 122 l 354 633 l 479 633 l 585 122 l 678 736 l 806 736 l 661 0 l 514 0 l 417 458 l 319 0 l 174 0 l 28 736 z "},"ẃ":{"ha":833,"x_min":28,"x_max":806,"o":"m 28 736 l 156 736 l 249 122 l 354 633 l 479 633 l 585 122 l 678 736 l 806 736 l 661 0 l 514 0 l 417 458 l 319 0 l 174 0 l 28 736 m 456 1014 l 586 1014 l 461 836 l 364 836 l 456 1014 z "},"ŵ":{"ha":833,"x_min":28,"x_max":806,"o":"m 28 736 l 156 736 l 249 122 l 354 633 l 479 633 l 585 122 l 678 736 l 806 736 l 661 0 l 514 0 l 417 458 l 319 0 l 174 0 l 28 736 m 350 1015 l 481 1015 l 610 831 l 513 831 l 415 956 l 318 831 l 221 831 l 350 1015 z "},"ẅ":{"ha":833,"x_min":28,"x_max":806,"o":"m 28 736 l 156 736 l 249 122 l 354 633 l 479 633 l 585 122 l 678 736 l 806 736 l 661 0 l 514 0 l 417 458 l 319 0 l 174 0 l 28 736 z "},"ẁ":{"ha":833,"x_min":28,"x_max":806,"o":"m 28 736 l 156 736 l 249 122 l 354 633 l 479 633 l 585 122 l 678 736 l 806 736 l 661 0 l 514 0 l 417 458 l 319 0 l 174 0 l 28 736 m 250 1014 l 381 1014 l 472 836 l 375 836 l 250 1014 z "},"x":{"ha":833,"x_min":75,"x_max":758,"o":"m 346 378 l 89 736 l 225 736 l 418 458 l 607 736 l 746 736 l 490 375 l 758 0 l 622 0 l 418 297 l 214 0 l 75 0 l 346 378 z "},"y":{"ha":833,"x_min":75,"x_max":764,"o":"m 192 -106 l 276 -106 q 333 -94 314 -106 q 363 -56 351 -82 l 389 14 l 349 14 l 75 736 l 203 736 l 426 122 l 636 736 l 764 736 l 465 -93 q 403 -181 444 -153 q 288 -208 361 -208 l 192 -208 l 192 -106 z "},"ý":{"ha":833,"x_min":75,"x_max":764,"o":"m 192 -106 l 276 -106 q 333 -94 314 -106 q 363 -56 351 -82 l 389 14 l 349 14 l 75 736 l 203 736 l 426 122 l 636 736 l 764 736 l 465 -93 q 403 -181 444 -153 q 288 -208 361 -208 l 192 -208 l 192 -106 m 460 1014 l 590 1014 l 465 836 l 368 836 l 460 1014 z "},"ŷ":{"ha":833,"x_min":75,"x_max":764,"o":"m 192 -106 l 276 -106 q 333 -94 314 -106 q 363 -56 351 -82 l 389 14 l 349 14 l 75 736 l 203 736 l 426 122 l 636 736 l 764 736 l 465 -93 q 403 -181 444 -153 q 288 -208 361 -208 l 192 -208 l 192 -106 m 354 1015 l 485 1015 l 614 831 l 517 831 l 419 956 l 322 831 l 225 831 l 354 1015 z "},"ÿ":{"ha":833,"x_min":75,"x_max":764,"o":"m 192 -106 l 276 -106 q 333 -94 314 -106 q 363 -56 351 -82 l 389 14 l 349 14 l 75 736 l 203 736 l 426 122 l 636 736 l 764 736 l 465 -93 q 403 -181 444 -153 q 288 -208 361 -208 l 192 -208 l 192 -106 z "},"ỳ":{"ha":833,"x_min":75,"x_max":764,"o":"m 192 -106 l 276 -106 q 333 -94 314 -106 q 363 -56 351 -82 l 389 14 l 349 14 l 75 736 l 203 736 l 426 122 l 636 736 l 764 736 l 465 -93 q 403 -181 444 -153 q 288 -208 361 -208 l 192 -208 l 192 -106 m 254 1014 l 385 1014 l 476 836 l 379 836 l 254 1014 z "},"ỹ":{"ha":833,"x_min":75,"x_max":764,"o":"m 192 -106 l 276 -106 q 333 -94 314 -106 q 363 -56 351 -82 l 389 14 l 349 14 l 75 736 l 203 736 l 426 122 l 636 736 l 764 736 l 465 -93 q 403 -181 444 -153 q 288 -208 361 -208 l 192 -208 l 192 -106 m 504 847 q 407 889 458 847 q 367 918 381 911 q 335 925 354 925 q 274 838 278 925 l 201 838 q 240 958 203 915 q 335 1000 276 1000 q 390 989 365 1000 q 440 956 414 978 q 478 930 463 938 q 510 922 493 922 q 550 942 536 922 q 568 1014 564 963 l 640 1014 q 600 891 636 935 q 504 847 564 847 z "},"z":{"ha":833,"x_min":114,"x_max":719,"o":"m 114 117 l 581 633 l 128 633 l 128 736 l 708 736 l 708 619 l 242 103 l 719 103 l 719 0 l 114 0 l 114 117 z "},"ź":{"ha":833,"x_min":114,"x_max":719,"o":"m 114 117 l 581 633 l 128 633 l 128 736 l 708 736 l 708 619 l 242 103 l 719 103 l 719 0 l 114 0 l 114 117 m 457 1014 l 588 1014 l 463 836 l 365 836 l 457 1014 z "},"ž":{"ha":833,"x_min":114,"x_max":719,"o":"m 114 117 l 581 633 l 128 633 l 128 736 l 708 736 l 708 619 l 242 103 l 719 103 l 719 0 l 114 0 l 114 117 m 321 1014 l 418 889 l 515 1014 l 613 1014 l 483 829 l 353 829 l 224 1014 l 321 1014 z "},"ż":{"ha":833,"x_min":114,"x_max":719,"o":"m 114 117 l 581 633 l 128 633 l 128 736 l 708 736 l 708 619 l 242 103 l 719 103 l 719 0 l 114 0 l 114 117 m 356 988 l 478 988 l 478 851 l 356 851 l 356 988 z "},"ꞌ":{"ha":833,"x_min":356,"x_max":478,"o":"m 356 797 l 356 986 l 478 986 l 478 797 l 458 525 l 375 525 l 356 797 z "},"ª":{"ha":833,"x_min":199,"x_max":635,"o":"m 354 513 q 244 549 289 513 q 199 643 199 585 q 235 731 199 696 q 339 779 272 765 l 483 808 q 399 906 483 906 q 306 838 322 906 l 207 844 q 270 953 219 913 q 399 993 321 993 q 533 943 486 993 q 581 803 581 893 l 581 636 q 588 613 581 619 q 610 606 594 606 l 635 606 l 635 522 q 593 519 621 519 q 494 590 511 519 q 440 534 478 556 q 354 513 403 513 m 367 594 q 452 623 421 594 q 483 700 483 651 l 483 733 l 364 710 q 316 689 332 703 q 300 650 300 675 q 317 609 300 624 q 367 594 335 594 z "},"º":{"ha":833,"x_min":201,"x_max":631,"o":"m 415 513 q 260 578 319 513 q 201 751 201 643 q 260 924 201 858 q 415 989 319 989 q 571 924 511 989 q 631 751 631 858 q 571 578 631 643 q 415 513 511 513 m 415 601 q 499 642 469 601 q 529 751 529 682 q 499 860 529 821 q 415 900 469 900 q 333 860 363 900 q 303 751 303 821 q 333 642 303 682 q 415 601 363 601 z "},"А":{"ha":833,"x_min":36,"x_max":797,"o":"m 336 986 l 497 986 l 797 0 l 675 0 l 594 275 l 239 275 l 158 0 l 36 0 l 336 986 m 561 389 l 417 878 l 272 389 l 561 389 z "},"Б":{"ha":833,"x_min":111,"x_max":750,"o":"m 726 986 l 726 869 l 231 869 l 231 597 l 411 597 q 658 518 567 597 q 750 299 750 439 q 658 79 750 158 q 411 0 567 0 l 111 0 l 111 986 l 726 986 m 231 483 l 231 114 l 397 114 q 625 299 625 114 q 397 483 625 483 l 231 483 z "},"В":{"ha":833,"x_min":125,"x_max":772,"o":"m 125 986 l 403 986 q 647 915 563 986 q 731 717 731 844 q 682 580 731 636 q 554 508 633 524 q 715 433 657 494 q 772 275 772 371 q 683 74 772 147 q 438 0 593 0 l 125 0 l 125 986 m 439 114 q 594 156 540 114 q 647 275 647 199 q 594 400 647 356 q 439 444 540 444 l 244 444 l 244 114 l 439 114 m 408 558 q 606 714 606 558 q 408 872 606 872 l 244 872 l 244 558 l 408 558 z "},"Г":{"ha":833,"x_min":139,"x_max":767,"o":"m 139 986 l 767 986 l 767 869 l 258 869 l 258 0 l 139 0 l 139 986 z "},"Ѓ":{"ha":833,"x_min":139,"x_max":767,"o":"m 139 986 l 767 986 l 767 869 l 258 869 l 258 0 l 139 0 l 139 986 m 492 1264 l 622 1264 l 497 1086 l 400 1086 l 492 1264 z "},"Ґ":{"ha":833,"x_min":139,"x_max":781,"o":"m 258 0 l 139 0 l 139 986 l 661 986 l 661 1201 l 781 1201 l 781 869 l 258 869 l 258 0 z "},"Ғ":{"ha":833,"x_min":26,"x_max":767,"o":"m 26 514 l 139 514 l 139 986 l 767 986 l 767 869 l 258 869 l 258 514 l 590 514 l 590 397 l 258 397 l 258 0 l 139 0 l 139 397 l 26 397 l 26 514 z "},"Д":{"ha":833,"x_min":17,"x_max":806,"o":"m 17 117 l 74 117 q 153 160 125 117 q 192 300 181 204 l 264 986 l 683 986 l 683 117 l 806 117 l 806 -208 l 686 -208 l 686 0 l 136 0 l 136 -208 l 17 -208 l 17 117 m 564 117 l 564 869 l 374 869 l 311 286 q 256 117 299 176 l 564 117 z "},"Е":{"ha":833,"x_min":125,"x_max":736,"o":"m 125 986 l 725 986 l 725 872 l 244 872 l 244 553 l 708 553 l 708 439 l 244 439 l 244 114 l 736 114 l 736 0 l 125 0 l 125 986 z "},"Ѐ":{"ha":833,"x_min":125,"x_max":736,"o":"m 125 986 l 725 986 l 725 872 l 244 872 l 244 553 l 708 553 l 708 439 l 244 439 l 244 114 l 736 114 l 736 0 l 125 0 l 125 986 m 275 1265 l 406 1265 l 497 1088 l 400 1088 l 275 1265 z "},"Ё":{"ha":833,"x_min":125,"x_max":736,"o":"m 125 986 l 725 986 l 725 872 l 244 872 l 244 553 l 708 553 l 708 439 l 244 439 l 244 114 l 736 114 l 736 0 l 125 0 l 125 986 z "},"Ж":{"ha":833,"x_min":10,"x_max":824,"o":"m 242 493 l 24 986 l 153 986 l 357 525 l 357 986 l 476 986 l 476 525 l 681 986 l 810 986 l 592 493 l 824 0 l 694 0 l 476 464 l 476 0 l 357 0 l 357 464 l 139 0 l 10 0 l 242 493 z "},"З":{"ha":833,"x_min":46,"x_max":785,"o":"m 417 -22 q 232 13 315 -22 q 99 111 149 49 q 46 254 50 174 l 169 263 q 245 140 176 186 q 417 94 314 94 q 594 145 526 94 q 661 281 661 196 q 628 385 661 343 q 542 448 594 428 q 436 467 490 468 l 333 465 l 333 583 l 436 582 q 524 598 481 581 q 597 653 568 615 q 626 751 626 692 q 568 853 626 815 q 417 892 510 892 q 274 856 332 892 q 207 763 217 821 l 82 771 q 138 893 90 839 q 258 978 185 947 q 417 1008 331 1008 q 588 976 511 1008 q 708 885 664 943 q 751 751 751 826 q 571 533 751 586 q 729 435 674 503 q 785 271 785 367 q 737 119 785 186 q 605 15 689 53 q 417 -22 521 -22 z "},"И":{"ha":833,"x_min":97,"x_max":736,"o":"m 617 0 l 617 828 l 264 0 l 97 0 l 97 986 l 217 986 l 217 158 l 569 986 l 736 986 l 736 0 l 617 0 z "},"Й":{"ha":833,"x_min":97,"x_max":736,"o":"m 617 0 l 617 828 l 264 0 l 97 0 l 97 986 l 217 986 l 217 158 l 569 986 l 736 986 l 736 0 l 617 0 m 417 1088 q 279 1135 332 1088 q 222 1265 226 1183 l 303 1265 q 417 1174 315 1174 q 531 1265 518 1174 l 611 1265 q 554 1135 607 1183 q 417 1088 501 1088 z "},"К":{"ha":833,"x_min":81,"x_max":785,"o":"m 200 482 l 200 0 l 81 0 l 81 986 l 200 986 l 200 504 l 614 986 l 771 986 l 344 493 l 785 0 l 628 0 l 200 482 z "},"Ќ":{"ha":833,"x_min":81,"x_max":785,"o":"m 200 482 l 200 0 l 81 0 l 81 986 l 200 986 l 200 504 l 614 986 l 771 986 l 344 493 l 785 0 l 628 0 l 200 482 m 447 1264 l 578 1264 l 453 1086 l 356 1086 l 447 1264 z "},"Л":{"ha":833,"x_min":28,"x_max":760,"o":"m 28 117 l 108 117 q 165 144 144 117 q 190 218 186 171 l 254 986 l 760 986 l 760 0 l 640 0 l 640 869 l 367 869 l 311 204 q 126 0 294 0 l 28 0 l 28 117 z "},"М":{"ha":833,"x_min":83,"x_max":750,"o":"m 203 826 l 203 0 l 83 0 l 83 986 l 264 986 l 417 300 l 569 986 l 750 986 l 750 0 l 631 0 l 631 826 l 469 111 l 364 111 l 203 826 z "},"Н":{"ha":833,"x_min":94,"x_max":739,"o":"m 94 986 l 214 986 l 214 553 l 619 553 l 619 986 l 739 986 l 739 0 l 619 0 l 619 439 l 214 439 l 214 0 l 94 0 l 94 986 z "},"О":{"ha":833,"x_min":53,"x_max":781,"o":"m 417 -22 q 147 111 242 -22 q 53 492 53 244 q 147 874 53 740 q 417 1008 242 1008 q 686 874 592 1008 q 781 492 781 740 q 686 111 781 244 q 417 -22 592 -22 m 417 94 q 594 197 532 94 q 656 492 656 299 q 594 788 656 685 q 417 892 532 892 q 240 788 301 892 q 178 492 178 685 q 240 197 178 299 q 417 94 301 94 z "},"П":{"ha":833,"x_min":97,"x_max":736,"o":"m 97 986 l 736 986 l 736 0 l 617 0 l 617 869 l 217 869 l 217 0 l 97 0 l 97 986 z "},"Р":{"ha":833,"x_min":125,"x_max":764,"o":"m 125 986 l 425 986 q 672 907 581 986 q 764 688 764 828 q 672 468 764 547 q 425 389 581 389 l 244 389 l 244 0 l 125 0 l 125 986 m 411 503 q 639 688 639 503 q 411 872 639 872 l 244 872 l 244 503 l 411 503 z "},"С":{"ha":833,"x_min":53,"x_max":772,"o":"m 419 -22 q 149 115 244 -22 q 53 492 53 253 q 149 871 53 733 q 419 1008 244 1008 q 642 923 550 1008 q 764 685 733 838 l 636 676 q 556 836 613 781 q 419 892 499 892 q 240 788 303 892 q 178 492 178 685 q 240 197 178 300 q 419 94 303 94 q 566 156 507 94 q 646 332 625 217 l 772 325 q 651 70 744 163 q 419 -22 558 -22 z "},"Т":{"ha":833,"x_min":56,"x_max":778,"o":"m 357 872 l 56 872 l 56 986 l 778 986 l 778 872 l 476 872 l 476 0 l 357 0 l 357 872 z "},"У":{"ha":833,"x_min":72,"x_max":769,"o":"m 196 117 l 281 117 q 326 128 308 117 q 353 168 343 140 l 388 264 l 340 264 l 72 986 l 204 986 l 425 375 l 639 986 l 769 986 l 456 115 q 394 28 435 56 q 292 0 354 0 l 196 0 l 196 117 z "},"Ў":{"ha":833,"x_min":72,"x_max":769,"o":"m 196 117 l 281 117 q 326 128 308 117 q 353 168 343 140 l 388 264 l 340 264 l 72 986 l 204 986 l 425 375 l 639 986 l 769 986 l 456 115 q 394 28 435 56 q 292 0 354 0 l 196 0 l 196 117 z "},"Ф":{"ha":833,"x_min":35,"x_max":799,"o":"m 356 106 q 120 226 206 124 q 35 493 35 329 q 120 760 35 657 q 356 881 206 863 l 356 986 l 475 986 l 475 882 q 713 760 626 863 q 799 493 799 657 q 713 226 799 329 q 475 104 626 124 l 475 0 l 356 0 l 356 106 m 160 493 q 212 313 160 383 q 356 224 264 242 l 356 763 q 212 674 264 744 q 160 493 160 603 m 475 224 q 621 312 568 240 q 674 493 674 383 q 621 674 674 603 q 475 763 568 746 l 475 224 z "},"Х":{"ha":833,"x_min":69,"x_max":764,"o":"m 342 496 l 75 986 l 214 986 l 418 588 l 622 986 l 758 986 l 492 494 l 764 0 l 625 0 l 415 404 l 206 0 l 69 0 l 342 496 z "},"Ч":{"ha":833,"x_min":69,"x_max":736,"o":"m 617 469 q 513 369 585 407 q 356 331 442 331 q 206 369 271 331 q 106 481 142 408 q 69 650 69 554 l 69 986 l 189 986 l 189 650 q 235 503 189 558 q 356 447 282 447 q 489 474 429 447 q 583 547 549 500 q 617 651 617 593 l 617 986 l 736 986 l 736 0 l 617 0 l 617 469 z "},"Ц":{"ha":833,"x_min":83,"x_max":813,"o":"m 203 986 l 203 117 l 603 117 l 603 986 l 722 986 l 722 117 l 813 117 l 813 -208 l 693 -208 l 693 0 l 83 0 l 83 986 l 203 986 z "},"Ш":{"ha":833,"x_min":76,"x_max":757,"o":"m 76 986 l 196 986 l 196 117 l 357 117 l 357 986 l 476 986 l 476 117 l 638 117 l 638 986 l 757 986 l 757 0 l 76 0 l 76 986 z "},"Щ":{"ha":833,"x_min":49,"x_max":813,"o":"m 49 986 l 168 986 l 168 117 l 329 117 l 329 986 l 449 986 l 449 117 l 610 117 l 610 986 l 729 986 l 729 117 l 813 117 l 813 -208 l 693 -208 l 693 0 l 49 0 l 49 986 z "},"Џ":{"ha":833,"x_min":97,"x_max":736,"o":"m 357 0 l 97 0 l 97 986 l 217 986 l 217 117 l 617 117 l 617 986 l 736 986 l 736 0 l 476 0 l 476 -208 l 357 -208 l 357 0 z "},"Ь":{"ha":833,"x_min":125,"x_max":764,"o":"m 244 986 l 244 597 l 425 597 q 672 518 581 597 q 764 299 764 439 q 672 79 764 158 q 425 0 581 0 l 125 0 l 125 986 l 244 986 m 244 483 l 244 114 l 411 114 q 639 299 639 114 q 411 483 639 483 l 244 483 z "},"Ы":{"ha":833,"x_min":49,"x_max":785,"o":"m 49 986 l 169 986 l 169 603 l 236 603 q 492 522 400 603 q 585 299 585 442 q 492 79 585 158 q 236 0 400 0 l 49 0 l 49 986 m 236 117 q 460 299 460 117 q 236 486 460 486 l 169 486 l 169 117 l 236 117 m 665 986 l 785 986 l 785 0 l 665 0 l 665 986 z "},"Ъ":{"ha":833,"x_min":42,"x_max":788,"o":"m 42 986 l 269 986 l 269 869 l 268 869 l 268 597 l 449 597 q 696 518 604 597 q 788 299 788 439 q 696 79 788 158 q 449 0 604 0 l 149 0 l 149 869 l 42 869 l 42 986 m 268 483 l 268 114 l 435 114 q 663 299 663 114 q 435 483 663 483 l 268 483 z "},"Љ":{"ha":833,"x_min":7,"x_max":826,"o":"m 7 117 l 46 117 q 104 144 83 117 q 128 218 125 171 l 164 986 l 507 986 l 508 603 l 547 603 q 753 522 681 603 q 826 299 826 442 q 753 78 826 157 q 547 0 681 0 l 388 0 l 388 869 l 276 869 l 249 204 q 199 54 244 108 q 64 0 153 0 l 7 0 l 7 117 m 535 117 q 652 162 615 117 q 689 299 689 207 q 652 440 689 393 q 535 486 615 486 l 508 486 l 508 117 l 535 117 z "},"Њ":{"ha":833,"x_min":28,"x_max":806,"o":"m 311 436 l 147 436 l 147 0 l 28 0 l 28 986 l 147 986 l 147 553 l 311 553 l 311 986 l 432 986 l 432 603 l 485 603 q 726 524 646 603 q 806 299 806 444 q 726 77 806 154 q 485 0 646 0 l 311 0 l 311 436 m 472 117 q 624 160 581 117 q 668 299 668 204 q 624 441 668 396 q 472 486 581 486 l 432 486 l 432 117 l 472 117 z "},"Ѕ":{"ha":833,"x_min":61,"x_max":772,"o":"m 432 -22 q 248 20 329 -22 q 118 139 167 63 q 61 315 69 215 l 186 324 q 265 154 200 214 q 435 94 331 94 q 592 133 538 94 q 647 244 647 172 q 625 328 647 294 q 544 393 603 363 q 371 456 485 424 q 203 521 265 486 q 113 605 142 556 q 85 728 85 654 q 124 874 85 810 q 238 973 164 938 q 413 1008 313 1008 q 649 922 560 1008 q 754 694 738 835 l 629 686 q 561 836 617 781 q 410 892 506 892 q 264 848 318 892 q 210 733 210 804 q 230 657 210 686 q 299 606 250 628 q 446 556 349 583 q 638 480 568 521 q 740 382 708 439 q 772 242 772 325 q 729 105 772 165 q 609 11 686 44 q 432 -22 532 -22 z "},"Є":{"ha":833,"x_min":53,"x_max":771,"o":"m 417 -22 q 147 111 242 -22 q 53 492 53 244 q 147 874 53 740 q 417 1008 242 1008 q 653 915 563 1008 q 771 642 743 821 l 643 642 q 564 827 622 763 q 417 892 506 892 q 250 803 311 892 q 179 550 189 715 l 479 550 l 479 433 l 179 433 q 250 182 189 269 q 417 94 311 94 q 564 159 506 94 q 644 344 622 224 l 771 344 q 653 72 744 167 q 417 -22 563 -22 z "},"Э":{"ha":833,"x_min":63,"x_max":781,"o":"m 417 -22 q 180 72 271 -22 q 63 344 89 167 l 189 344 q 269 159 211 224 q 417 94 328 94 q 583 182 522 94 q 654 433 644 269 l 354 433 l 354 550 l 654 550 q 583 803 644 715 q 417 892 522 892 q 269 827 328 892 q 190 642 211 763 l 63 642 q 181 915 90 821 q 417 1008 271 1008 q 686 874 592 1008 q 781 492 781 740 q 686 111 781 244 q 417 -22 592 -22 z "},"І":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 114 l 357 114 l 357 872 l 111 872 l 111 986 l 722 986 l 722 872 l 476 872 l 476 114 l 722 114 l 722 0 l 111 0 l 111 114 z "},"Ї":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 114 l 357 114 l 357 872 l 111 872 l 111 986 l 722 986 l 722 872 l 476 872 l 476 114 l 722 114 l 722 0 l 111 0 l 111 114 z "},"Ј":{"ha":833,"x_min":56,"x_max":708,"o":"m 382 -22 q 147 71 235 -22 q 56 319 60 164 l 172 331 q 228 152 176 210 q 382 94 279 94 q 589 331 589 94 l 589 986 l 708 986 l 708 331 q 620 72 708 167 q 382 -22 532 -22 z "},"Ћ":{"ha":833,"x_min":7,"x_max":792,"o":"m 167 869 l 7 869 l 7 986 l 489 986 l 489 869 l 286 869 l 286 524 q 373 619 314 583 q 506 656 432 656 q 655 617 590 656 q 756 505 719 578 q 792 336 792 432 l 792 0 l 672 0 l 672 336 q 622 482 672 425 q 492 539 571 539 q 344 482 401 539 q 286 335 286 425 l 286 0 l 167 0 l 167 869 z "},"Ю":{"ha":833,"x_min":28,"x_max":806,"o":"m 536 -22 q 340 88 404 -22 q 269 435 275 197 l 147 435 l 147 0 l 28 0 l 28 986 l 147 986 l 147 551 l 269 551 q 340 899 275 789 q 536 1008 404 1008 q 741 885 676 1008 q 806 492 806 761 q 741 101 806 224 q 536 -22 676 -22 m 536 94 q 620 134 589 94 q 666 260 651 174 q 681 492 681 347 q 666 724 681 638 q 620 851 651 811 q 536 892 589 892 q 453 852 485 892 q 408 725 422 813 q 394 492 394 638 q 408 260 394 346 q 453 134 422 174 q 536 94 485 94 z "},"Я":{"ha":833,"x_min":64,"x_max":735,"o":"m 615 0 l 615 397 l 438 397 l 421 397 l 196 0 l 64 0 l 299 415 q 142 513 197 442 q 88 688 88 585 q 180 907 88 828 q 438 986 272 986 l 735 986 l 735 0 l 615 0 m 615 514 l 615 869 l 438 869 q 271 823 329 869 q 213 690 213 776 q 271 560 213 606 q 438 514 329 514 l 615 514 z "},"Ђ":{"ha":833,"x_min":7,"x_max":819,"o":"m 167 869 l 7 869 l 7 986 l 489 986 l 489 869 l 286 869 l 286 525 q 369 619 313 583 q 496 656 426 656 q 663 615 589 656 q 778 500 736 574 q 819 332 819 426 q 771 156 819 231 q 633 40 722 81 q 422 0 543 0 l 422 117 q 622 174 550 117 q 694 332 694 232 q 640 483 694 428 q 496 539 586 539 q 344 482 403 539 q 286 335 286 425 l 286 0 l 167 0 l 167 869 z "},"Ѣ":{"ha":833,"x_min":21,"x_max":797,"o":"m 21 853 l 158 853 l 158 986 l 278 986 l 278 853 l 506 853 l 506 736 l 278 736 l 278 597 l 458 597 q 706 518 614 597 q 797 299 797 439 q 706 79 797 158 q 458 0 614 0 l 158 0 l 158 736 l 21 736 l 21 853 m 278 483 l 278 114 l 444 114 q 672 299 672 114 q 444 483 672 483 l 278 483 z "},"Ѫ":{"ha":833,"x_min":1,"x_max":832,"o":"m 146 401 q 208 503 169 469 q 292 540 246 538 l 89 869 l 89 986 l 744 986 l 744 869 l 542 540 q 686 401 638 533 l 832 0 l 704 0 l 582 344 q 549 406 565 389 q 506 424 532 424 l 476 424 l 476 0 l 357 0 l 357 424 l 328 424 q 283 406 301 424 q 250 344 265 389 l 129 0 l 1 0 l 146 401 m 417 563 l 606 869 l 228 869 l 417 563 z "},"Ѳ":{"ha":833,"x_min":53,"x_max":781,"o":"m 417 -22 q 147 111 242 -22 q 53 492 53 244 q 147 874 53 740 q 417 1008 242 1008 q 686 874 592 1008 q 781 492 781 740 q 686 111 781 244 q 417 -22 592 -22 m 417 94 q 575 169 515 94 q 650 388 635 244 q 622 386 640 386 q 544 399 589 386 q 399 449 499 411 q 297 476 350 468 q 203 481 243 485 l 178 476 q 242 192 179 290 q 417 94 304 94 m 189 599 q 224 600 200 600 q 308 592 264 600 q 389 569 353 583 q 534 517 493 529 q 607 504 575 504 q 642 507 617 504 l 656 508 q 591 793 653 694 q 417 892 529 892 q 258 817 318 892 q 183 599 199 742 l 189 599 z "},"Ѵ":{"ha":833,"x_min":19,"x_max":826,"o":"m 19 986 l 146 986 l 390 182 l 579 807 q 653 938 604 890 q 763 986 703 986 l 826 986 l 826 869 l 807 869 q 735 842 761 869 q 686 742 708 814 l 458 0 l 324 0 l 19 986 z "},"Җ":{"ha":833,"x_min":10,"x_max":819,"o":"m 700 0 l 667 0 l 457 465 l 457 0 l 343 0 l 343 465 l 133 0 l 10 0 l 232 493 l 22 986 l 147 986 l 343 522 l 343 986 l 457 986 l 457 522 l 653 986 l 778 986 l 568 493 l 746 97 l 819 97 l 819 -208 l 700 -208 l 700 0 z "},"Қ":{"ha":833,"x_min":67,"x_max":799,"o":"m 679 0 l 614 0 l 186 482 l 186 0 l 67 0 l 67 986 l 186 986 l 186 504 l 600 986 l 757 986 l 331 493 l 683 97 l 799 97 l 799 -208 l 679 -208 l 679 0 z "},"Ң":{"ha":833,"x_min":74,"x_max":813,"o":"m 693 0 l 599 0 l 599 439 l 193 439 l 193 0 l 74 0 l 74 986 l 193 986 l 193 553 l 599 553 l 599 986 l 718 986 l 718 97 l 813 97 l 813 -208 l 693 -208 l 693 0 z "},"Ү":{"ha":833,"x_min":61,"x_max":772,"o":"m 358 403 l 61 986 l 197 986 l 417 542 l 636 986 l 772 986 l 478 403 l 478 0 l 358 0 l 358 403 z "},"Ұ":{"ha":833,"x_min":60,"x_max":771,"o":"m 142 435 l 340 435 l 60 986 l 196 986 l 415 542 l 635 986 l 771 986 l 493 435 l 731 435 l 731 318 l 476 318 l 476 0 l 357 0 l 357 318 l 142 318 l 142 435 z "},"Ҳ":{"ha":833,"x_min":69,"x_max":806,"o":"m 686 0 l 625 0 l 415 404 l 206 0 l 69 0 l 342 496 l 75 986 l 214 986 l 418 588 l 622 986 l 758 986 l 492 494 l 710 97 l 806 97 l 806 -208 l 686 -208 l 686 0 z "},"Ҷ":{"ha":833,"x_min":49,"x_max":813,"o":"m 693 0 l 596 0 l 596 469 q 492 369 564 407 q 335 331 421 331 q 185 369 250 331 q 85 481 121 408 q 49 650 49 554 l 49 986 l 168 986 l 168 650 q 215 503 168 558 q 335 447 261 447 q 468 474 408 447 q 562 547 528 500 q 596 651 596 593 l 596 986 l 715 986 l 715 97 l 813 97 l 813 -208 l 693 -208 l 693 0 z "},"Һ":{"ha":833,"x_min":97,"x_max":764,"o":"m 217 517 q 320 617 249 579 q 478 656 392 656 q 627 617 563 656 q 728 505 692 578 q 764 336 764 432 l 764 0 l 644 0 l 644 336 q 598 483 644 428 q 478 539 551 539 q 344 513 404 539 q 251 440 285 486 q 217 335 217 393 l 217 0 l 97 0 l 97 986 l 217 986 l 217 517 z "},"Ӏ":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 114 l 357 114 l 357 872 l 111 872 l 111 986 l 722 986 l 722 872 l 476 872 l 476 114 l 722 114 l 722 0 l 111 0 l 111 114 z "},"Ә":{"ha":833,"x_min":39,"x_max":794,"o":"m 406 -22 q 218 40 300 -22 q 90 215 136 103 q 40 472 43 328 l 39 542 l 668 542 q 626 724 661 644 q 535 847 592 803 q 406 892 478 892 q 274 837 332 892 q 192 696 215 782 l 65 707 q 198 927 107 846 q 406 1008 289 1008 q 609 943 521 1008 q 746 760 697 878 q 794 493 794 643 q 744 232 794 350 q 604 46 693 114 q 406 -22 515 -22 m 406 94 q 576 180 510 94 q 667 425 643 265 l 168 425 q 248 180 188 265 q 406 94 308 94 z "},"Ӣ":{"ha":833,"x_min":97,"x_max":736,"o":"m 617 0 l 617 828 l 264 0 l 97 0 l 97 986 l 217 986 l 217 158 l 569 986 l 736 986 l 736 0 l 617 0 m 229 1218 l 601 1218 l 601 1118 l 229 1118 l 229 1218 z "},"Ө":{"ha":833,"x_min":53,"x_max":781,"o":"m 417 -22 q 147 111 242 -22 q 53 492 53 244 q 147 874 53 740 q 417 1008 242 1008 q 686 874 592 1008 q 781 492 781 740 q 686 111 781 244 q 417 -22 592 -22 m 417 94 q 584 181 524 94 q 654 435 644 268 l 179 435 q 249 181 189 268 q 417 94 310 94 m 654 551 q 583 804 644 717 q 417 892 522 892 q 250 804 311 892 q 179 551 189 717 l 654 551 z "},"Ӯ":{"ha":833,"x_min":72,"x_max":769,"o":"m 196 117 l 281 117 q 326 128 308 117 q 353 168 343 140 l 388 264 l 340 264 l 72 986 l 204 986 l 425 375 l 639 986 l 769 986 l 456 115 q 394 28 435 56 q 292 0 354 0 l 196 0 l 196 117 m 235 1218 l 607 1218 l 607 1118 l 235 1118 l 235 1218 z "},"а":{"ha":833,"x_min":75,"x_max":764,"o":"m 319 -17 q 144 39 214 -17 q 75 183 75 94 q 131 329 75 276 q 308 406 186 382 l 556 456 q 514 595 556 549 q 392 642 472 642 q 273 608 317 642 q 213 510 229 575 l 89 519 q 190 690 110 626 q 392 753 269 753 q 599 673 525 753 q 672 453 672 593 l 672 147 q 683 113 672 122 q 717 103 693 103 l 764 103 l 764 0 q 703 -3 742 -3 q 606 23 642 -3 q 560 104 571 49 q 464 17 529 50 q 319 -17 399 -17 m 331 86 q 498 142 440 86 q 556 292 556 197 l 556 356 l 331 311 q 227 269 257 297 q 197 194 197 242 q 233 115 197 143 q 331 86 268 86 z "},"б":{"ha":833,"x_min":81,"x_max":751,"o":"m 417 -22 q 294 3 350 -22 q 186 85 238 29 q 107 228 133 138 q 81 435 81 318 q 105 657 81 568 q 181 803 129 746 q 321 901 233 861 q 417 966 392 935 q 443 1056 443 997 l 563 1057 q 528 917 563 974 q 410 817 493 861 l 385 804 q 288 744 322 771 q 230 667 254 718 q 201 531 206 617 q 299 636 239 600 q 438 672 358 672 q 599 628 528 672 q 711 506 671 585 q 751 328 751 428 q 708 143 751 222 q 588 21 664 64 q 417 -22 513 -22 m 417 89 q 569 156 510 89 q 629 328 629 222 q 572 496 629 431 q 424 561 514 561 q 268 494 328 561 q 208 321 208 428 q 266 153 208 218 q 417 89 324 89 z "},"в":{"ha":833,"x_min":125,"x_max":739,"o":"m 125 736 l 431 736 q 633 681 558 736 q 708 532 708 625 q 667 431 708 471 q 551 381 625 392 q 689 325 639 371 q 739 208 739 279 q 667 56 739 113 q 471 0 594 0 l 125 0 l 125 736 m 471 103 q 575 131 536 103 q 614 208 614 160 q 575 292 614 261 q 471 322 536 322 l 242 322 l 242 103 l 471 103 m 431 425 q 542 454 500 425 q 583 532 583 483 q 542 606 583 579 q 431 633 500 633 l 242 633 l 242 425 l 431 425 z "},"г":{"ha":833,"x_min":207,"x_max":722,"o":"m 207 736 l 722 736 l 722 633 l 324 633 l 324 0 l 207 0 l 207 736 z "},"ѓ":{"ha":833,"x_min":207,"x_max":722,"o":"m 207 736 l 722 736 l 722 633 l 324 633 l 324 0 l 207 0 l 207 736 m 504 1014 l 635 1014 l 510 836 l 413 836 l 504 1014 z "},"ґ":{"ha":833,"x_min":207,"x_max":722,"o":"m 324 0 l 207 0 l 207 736 l 603 736 l 603 951 l 722 951 l 722 633 l 324 633 l 324 0 z "},"ғ":{"ha":833,"x_min":57,"x_max":722,"o":"m 57 425 l 207 425 l 207 736 l 722 736 l 722 633 l 324 633 l 324 425 l 482 425 l 482 322 l 324 322 l 324 0 l 207 0 l 207 322 l 57 322 l 57 425 z "},"д":{"ha":833,"x_min":28,"x_max":806,"o":"m 93 103 q 161 135 136 103 q 196 236 186 167 l 263 736 l 686 736 l 686 103 l 806 103 l 806 -208 l 689 -208 l 689 0 l 144 0 l 144 -208 l 28 -208 l 28 103 l 93 103 m 569 103 l 569 633 l 368 633 l 310 225 q 265 103 300 150 l 569 103 z "},"е":{"ha":833,"x_min":92,"x_max":750,"o":"m 429 -17 q 250 31 326 -17 q 133 165 174 78 q 92 368 92 251 q 133 571 92 483 q 249 706 174 658 q 424 753 324 753 q 594 708 519 753 q 709 575 668 663 q 750 367 750 488 l 750 332 l 214 332 q 276 155 219 215 q 429 94 332 94 q 547 127 500 94 q 613 218 593 160 l 738 208 q 626 45 708 107 q 429 -17 543 -17 m 622 435 q 561 590 614 538 q 424 642 508 642 q 283 589 338 642 q 214 435 228 536 l 622 435 z "},"ѐ":{"ha":833,"x_min":92,"x_max":750,"o":"m 429 -17 q 250 31 326 -17 q 133 165 174 78 q 92 368 92 251 q 133 571 92 483 q 249 706 174 658 q 424 753 324 753 q 594 708 519 753 q 709 575 668 663 q 750 367 750 488 l 750 332 l 214 332 q 276 155 219 215 q 429 94 332 94 q 547 127 500 94 q 613 218 593 160 l 738 208 q 626 45 708 107 q 429 -17 543 -17 m 622 435 q 561 590 614 538 q 424 642 508 642 q 283 589 338 642 q 214 435 228 536 l 622 435 m 258 1011 l 389 1011 l 481 833 l 383 833 l 258 1011 z "},"ё":{"ha":833,"x_min":92,"x_max":750,"o":"m 429 -17 q 250 31 326 -17 q 133 165 174 78 q 92 368 92 251 q 133 571 92 483 q 249 706 174 658 q 424 753 324 753 q 594 708 519 753 q 709 575 668 663 q 750 367 750 488 l 750 332 l 214 332 q 276 155 219 215 q 429 94 332 94 q 547 127 500 94 q 613 218 593 160 l 738 208 q 626 45 708 107 q 429 -17 543 -17 m 622 435 q 561 590 614 538 q 424 642 508 642 q 283 589 338 642 q 214 435 228 536 l 622 435 z "},"ж":{"ha":833,"x_min":15,"x_max":818,"o":"m 233 368 l 24 736 l 157 736 l 358 383 l 358 736 l 475 736 l 475 383 l 676 736 l 810 736 l 600 368 l 818 0 l 685 0 l 475 354 l 475 0 l 358 0 l 358 354 l 149 0 l 15 0 l 233 368 z "},"з":{"ha":833,"x_min":111,"x_max":715,"o":"m 414 -17 q 198 43 279 -17 q 111 206 117 103 l 233 211 q 286 126 238 158 q 414 94 335 94 q 544 125 496 94 q 593 207 593 156 q 548 301 593 267 q 428 335 503 336 l 351 333 l 351 444 l 428 443 q 530 470 492 442 q 568 546 568 499 q 526 616 568 590 q 414 642 485 642 q 310 618 350 642 q 264 554 269 594 l 138 561 q 222 701 147 650 q 414 753 297 753 q 616 697 542 753 q 690 546 690 642 q 651 455 690 494 q 543 396 613 415 q 669 320 624 372 q 715 200 715 268 q 634 42 715 100 q 414 -17 553 -17 z "},"и":{"ha":833,"x_min":131,"x_max":704,"o":"m 131 736 l 247 736 l 247 178 l 567 736 l 704 736 l 704 0 l 588 0 l 588 547 l 275 0 l 131 0 l 131 736 z "},"й":{"ha":833,"x_min":131,"x_max":704,"o":"m 131 736 l 247 736 l 247 178 l 567 736 l 704 736 l 704 0 l 588 0 l 588 547 l 275 0 l 131 0 l 131 736 m 418 838 q 281 885 333 838 q 224 1015 228 933 l 304 1015 q 418 924 317 924 q 532 1015 519 924 l 613 1015 q 556 885 608 933 q 418 838 503 838 z "},"ѝ":{"ha":833,"x_min":131,"x_max":704,"o":"m 131 736 l 247 736 l 247 178 l 567 736 l 704 736 l 704 0 l 588 0 l 588 547 l 275 0 l 131 0 l 131 736 m 251 1014 l 382 1014 l 474 836 l 376 836 l 251 1014 z "},"к":{"ha":833,"x_min":126,"x_max":772,"o":"m 246 358 l 246 0 l 126 0 l 126 736 l 246 736 l 246 378 l 592 736 l 756 736 l 393 368 l 772 0 l 606 0 l 246 358 z "},"ќ":{"ha":833,"x_min":126,"x_max":772,"o":"m 246 358 l 246 0 l 126 0 l 126 736 l 246 736 l 246 378 l 592 736 l 756 736 l 393 368 l 772 0 l 606 0 l 246 358 m 457 1014 l 588 1014 l 463 836 l 365 836 l 457 1014 z "},"л":{"ha":833,"x_min":35,"x_max":736,"o":"m 35 117 l 103 117 q 183 217 172 117 l 239 736 l 736 736 l 736 0 l 619 0 l 619 633 l 346 633 l 300 204 q 244 51 289 101 q 119 0 200 0 l 35 0 l 35 117 z "},"м":{"ha":833,"x_min":50,"x_max":783,"o":"m 64 736 l 218 736 l 425 143 l 631 736 l 783 736 l 783 0 l 667 0 l 667 479 l 497 0 l 351 0 l 178 486 l 167 0 l 50 0 l 64 736 z "},"н":{"ha":833,"x_min":126,"x_max":707,"o":"m 126 736 l 243 736 l 243 418 l 590 418 l 590 736 l 707 736 l 707 0 l 590 0 l 590 315 l 243 315 l 243 0 l 126 0 l 126 736 z "},"о":{"ha":833,"x_min":81,"x_max":753,"o":"m 417 -17 q 240 31 315 -17 q 122 165 164 78 q 81 368 81 251 q 122 572 81 485 q 240 706 164 658 q 417 753 315 753 q 593 706 517 753 q 711 572 669 658 q 753 368 753 485 q 711 165 753 251 q 593 31 669 78 q 417 -17 517 -17 m 417 94 q 574 167 518 94 q 631 368 631 240 q 574 569 631 496 q 417 642 518 642 q 259 569 315 642 q 203 368 203 497 q 259 167 203 239 q 417 94 315 94 z "},"п":{"ha":833,"x_min":125,"x_max":708,"o":"m 125 736 l 708 736 l 708 0 l 592 0 l 592 633 l 242 633 l 242 0 l 125 0 l 125 736 z "},"р":{"ha":833,"x_min":111,"x_max":764,"o":"m 111 736 l 219 736 l 221 618 q 313 718 254 683 q 447 753 372 753 q 624 701 553 753 q 729 561 694 649 q 764 368 764 474 q 729 175 764 263 q 624 35 694 88 q 447 -17 553 -17 q 316 14 375 -17 q 228 97 257 44 l 228 -208 l 111 -208 l 111 736 m 436 94 q 587 167 532 94 q 642 368 642 239 q 587 569 642 497 q 436 642 532 642 q 283 572 339 642 q 228 368 228 501 q 283 165 228 235 q 436 94 338 94 z "},"с":{"ha":833,"x_min":97,"x_max":750,"o":"m 436 -17 q 258 31 335 -17 q 139 165 181 78 q 97 368 97 253 q 139 571 97 483 q 258 706 181 658 q 436 753 335 753 q 641 686 560 753 q 744 497 722 619 l 622 489 q 556 601 606 561 q 436 642 507 642 q 277 569 335 642 q 219 368 219 496 q 277 167 219 240 q 436 94 335 94 q 560 138 510 94 q 628 261 611 181 l 750 253 q 642 56 726 129 q 436 -17 558 -17 z "},"т":{"ha":833,"x_min":93,"x_max":742,"o":"m 358 633 l 93 633 l 93 736 l 742 736 l 742 633 l 476 633 l 476 0 l 358 0 l 358 633 z "},"у":{"ha":833,"x_min":75,"x_max":764,"o":"m 192 -106 l 276 -106 q 333 -94 314 -106 q 363 -56 351 -82 l 389 14 l 349 14 l 75 736 l 203 736 l 426 122 l 636 736 l 764 736 l 465 -93 q 403 -181 444 -153 q 288 -208 361 -208 l 192 -208 l 192 -106 z "},"ў":{"ha":833,"x_min":75,"x_max":764,"o":"m 192 -106 l 276 -106 q 333 -94 314 -106 q 363 -56 351 -82 l 389 14 l 349 14 l 75 736 l 203 736 l 426 122 l 636 736 l 764 736 l 465 -93 q 403 -181 444 -153 q 288 -208 361 -208 l 192 -208 l 192 -106 z "},"ф":{"ha":833,"x_min":32,"x_max":800,"o":"m 358 38 q 188 94 261 49 q 73 210 114 139 q 32 372 32 282 q 73 534 32 463 q 188 651 114 606 q 358 707 261 696 l 358 986 l 476 986 l 476 706 q 646 649 572 694 q 760 533 719 604 q 800 372 800 461 q 760 212 800 283 q 646 95 719 140 q 476 39 572 50 l 476 -208 l 358 -208 l 358 38 m 157 372 q 210 226 157 283 q 358 156 264 169 l 358 589 q 210 518 264 575 q 157 372 157 461 m 476 156 q 622 228 569 171 q 675 372 675 285 q 622 517 675 460 q 476 589 569 574 l 476 156 z "},"х":{"ha":833,"x_min":75,"x_max":758,"o":"m 346 378 l 89 736 l 225 736 l 418 458 l 607 736 l 746 736 l 490 375 l 758 0 l 622 0 l 418 297 l 214 0 l 75 0 l 346 378 z "},"ч":{"ha":833,"x_min":96,"x_max":708,"o":"m 592 357 q 507 257 565 294 q 375 219 449 219 q 172 306 247 219 q 96 539 96 392 l 96 736 l 213 736 l 213 539 q 257 387 213 443 q 375 331 301 331 q 533 387 475 331 q 592 540 592 443 l 592 736 l 708 736 l 708 0 l 592 0 l 592 357 z "},"ц":{"ha":833,"x_min":125,"x_max":811,"o":"m 242 736 l 242 103 l 592 103 l 592 736 l 708 736 l 708 103 l 811 103 l 811 -208 l 694 -208 l 694 0 l 125 0 l 125 736 l 242 736 z "},"ш":{"ha":833,"x_min":78,"x_max":754,"o":"m 78 736 l 194 736 l 194 103 l 357 103 l 357 736 l 475 736 l 475 103 l 638 103 l 638 736 l 754 736 l 754 0 l 78 0 l 78 736 z "},"щ":{"ha":833,"x_min":78,"x_max":819,"o":"m 78 736 l 194 736 l 194 103 l 357 103 l 357 736 l 475 736 l 475 103 l 638 103 l 638 736 l 754 736 l 754 103 l 819 103 l 819 -208 l 703 -208 l 703 0 l 78 0 l 78 736 z "},"џ":{"ha":833,"x_min":125,"x_max":708,"o":"m 358 0 l 125 0 l 125 736 l 242 736 l 242 103 l 592 103 l 592 736 l 708 736 l 708 0 l 475 0 l 475 -208 l 358 -208 l 358 0 z "},"ь":{"ha":833,"x_min":113,"x_max":746,"o":"m 113 736 l 229 736 l 229 465 l 424 465 q 663 405 579 465 q 746 232 746 344 q 663 60 746 121 q 424 0 579 0 l 113 0 l 113 736 m 424 104 q 624 232 624 104 q 424 363 624 363 l 229 363 l 229 104 l 424 104 z "},"ы":{"ha":833,"x_min":49,"x_max":785,"o":"m 49 736 l 165 736 l 165 465 l 275 465 q 514 405 431 465 q 597 232 597 344 q 514 60 597 121 q 275 0 431 0 l 49 0 l 49 736 m 275 104 q 475 232 475 104 q 275 363 475 363 l 165 363 l 165 104 l 275 104 m 668 736 l 785 736 l 785 0 l 668 0 l 668 736 z "},"ъ":{"ha":833,"x_min":14,"x_max":767,"o":"m 14 736 l 250 736 l 250 465 l 444 465 q 683 405 600 465 q 767 232 767 344 q 683 60 767 121 q 444 0 600 0 l 133 0 l 133 633 l 14 633 l 14 736 m 444 104 q 644 232 644 104 q 444 363 644 363 l 250 363 l 250 104 l 444 104 z "},"љ":{"ha":833,"x_min":7,"x_max":826,"o":"m 7 117 l 19 117 q 100 217 89 117 l 156 736 l 496 736 l 496 465 l 574 465 q 761 405 696 465 q 826 232 826 344 q 761 60 826 121 q 574 0 696 0 l 378 0 l 378 633 l 263 633 l 217 204 q 161 51 206 101 q 36 0 117 0 l 7 0 l 7 117 m 574 104 q 670 138 636 104 q 704 232 704 171 q 670 328 704 294 q 574 363 636 363 l 496 363 l 496 104 l 574 104 z "},"њ":{"ha":833,"x_min":28,"x_max":813,"o":"m 364 321 l 144 321 l 144 0 l 28 0 l 28 736 l 144 736 l 144 424 l 364 424 l 364 736 l 481 736 l 481 465 l 560 465 q 747 405 682 465 q 813 232 813 344 q 747 60 813 121 q 560 0 682 0 l 364 0 l 364 321 m 560 104 q 656 138 622 104 q 690 232 690 171 q 656 328 690 294 q 560 363 622 363 l 481 363 l 481 104 l 560 104 z "},"ѕ":{"ha":833,"x_min":111,"x_max":722,"o":"m 431 -17 q 267 16 338 -17 q 157 105 197 49 q 111 232 117 161 l 233 240 q 294 133 244 171 q 431 94 344 94 q 558 119 514 94 q 603 192 603 143 q 588 245 603 225 q 531 280 572 265 q 408 308 490 294 q 242 356 303 326 q 156 426 182 385 q 131 531 131 468 q 204 691 131 629 q 410 753 278 753 q 615 686 539 753 q 708 517 690 619 l 586 508 q 527 606 574 569 q 408 642 481 642 q 290 611 331 642 q 250 531 250 581 q 268 472 250 493 q 324 437 286 450 q 433 411 363 424 q 610 365 547 393 q 697 297 672 338 q 722 192 722 256 q 640 40 722 96 q 431 -17 558 -17 z "},"є":{"ha":833,"x_min":85,"x_max":747,"o":"m 422 -17 q 244 31 321 -17 q 126 165 168 78 q 85 368 85 251 q 126 572 85 485 q 244 706 168 658 q 422 753 321 753 q 633 680 547 753 q 747 479 719 607 l 622 479 q 549 599 601 557 q 422 642 497 642 q 276 583 331 642 q 210 419 221 525 l 456 419 l 456 317 l 210 317 q 276 153 221 211 q 422 94 331 94 q 549 136 497 94 q 621 253 600 178 l 746 253 q 633 55 718 126 q 422 -17 547 -17 z "},"э":{"ha":833,"x_min":86,"x_max":749,"o":"m 411 -17 q 201 55 286 -17 q 88 253 115 126 l 213 253 q 285 136 233 178 q 411 94 336 94 q 558 153 503 94 q 624 317 613 211 l 378 317 l 378 419 l 624 419 q 558 583 613 525 q 411 642 503 642 q 284 599 336 642 q 211 479 232 557 l 86 479 q 200 680 114 607 q 411 753 286 753 q 589 706 513 753 q 707 572 665 658 q 749 368 749 485 q 707 165 749 251 q 589 31 665 78 q 411 -17 513 -17 z "},"і":{"ha":833,"x_min":111,"x_max":778,"o":"m 111 103 l 407 103 l 407 633 l 125 633 l 125 736 l 524 736 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 m 396 988 l 518 988 l 518 851 l 396 851 l 396 988 z "},"ї":{"ha":833,"x_min":111,"x_max":778,"o":"m 111 103 l 407 103 l 407 633 l 125 633 l 125 736 l 524 736 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 z "},"ј":{"ha":833,"x_min":153,"x_max":611,"o":"m 153 -106 l 413 -106 q 472 -76 450 -106 q 494 0 494 -46 l 494 633 l 167 633 l 167 736 l 611 736 l 611 0 q 563 -152 611 -96 q 425 -208 515 -208 l 153 -208 l 153 -106 m 489 988 l 611 988 l 611 851 l 489 851 l 489 988 z "},"ћ":{"ha":833,"x_min":35,"x_max":722,"o":"m 35 914 l 139 914 l 139 986 l 256 986 l 256 914 l 388 914 l 388 811 l 256 811 l 256 621 q 341 719 283 686 q 476 753 399 753 q 658 676 593 753 q 722 474 722 600 l 722 0 l 606 0 l 606 440 q 451 650 606 650 q 308 594 361 650 q 256 439 256 539 l 256 0 l 139 0 l 139 811 l 35 811 l 35 914 z "},"ю":{"ha":833,"x_min":28,"x_max":792,"o":"m 508 -17 q 310 69 382 -17 q 228 317 239 156 l 144 317 l 144 0 l 28 0 l 28 736 l 144 736 l 144 419 l 228 419 q 310 667 239 581 q 508 753 382 753 q 718 653 644 753 q 792 368 792 553 q 718 83 792 183 q 508 -17 644 -17 m 508 94 q 628 166 586 94 q 669 368 669 238 q 628 570 669 499 q 508 642 586 642 q 390 571 432 642 q 349 368 349 500 q 390 165 349 236 q 508 94 432 94 z "},"я":{"ha":833,"x_min":113,"x_max":708,"o":"m 292 306 q 167 389 211 335 q 124 514 124 443 q 200 678 124 621 q 418 736 276 736 l 708 736 l 708 0 l 592 0 l 592 285 l 417 285 l 249 0 l 113 0 l 292 306 m 592 388 l 592 635 l 418 635 q 292 603 336 635 q 249 511 249 571 q 292 419 249 451 q 418 388 336 388 l 592 388 z "},"ђ":{"ha":833,"x_min":35,"x_max":717,"o":"m 132 811 l 35 811 l 35 914 l 132 914 l 132 986 l 249 986 l 249 914 l 388 914 l 388 811 l 249 811 l 249 636 q 338 724 279 694 q 468 753 396 753 q 649 677 582 753 q 717 474 717 601 l 717 -4 q 669 -154 717 -100 q 531 -208 621 -208 l 460 -208 l 460 -106 l 517 -106 q 578 -78 556 -106 q 600 -4 600 -51 l 600 432 q 443 642 600 642 q 301 587 354 642 q 249 432 249 532 l 249 0 l 132 0 l 132 811 z "},"ѣ":{"ha":833,"x_min":14,"x_max":811,"o":"m 178 736 l 14 736 l 14 853 l 178 853 l 178 986 l 294 986 l 294 853 l 457 853 l 457 736 l 294 736 l 294 493 l 489 493 q 728 429 644 493 q 811 246 811 365 q 728 64 811 128 q 489 0 644 0 l 178 0 l 178 736 m 489 104 q 637 141 585 104 q 689 246 689 178 q 637 353 689 315 q 489 390 585 390 l 294 390 l 294 104 l 489 104 z "},"ѫ":{"ha":833,"x_min":19,"x_max":814,"o":"m 135 299 q 192 371 153 344 q 278 401 231 397 l 60 633 l 60 736 l 775 736 l 775 633 l 557 401 q 642 370 604 396 q 699 299 681 344 l 814 0 l 681 0 l 596 246 q 563 291 585 279 q 506 303 542 303 l 476 303 l 476 0 l 357 0 l 357 303 l 326 303 q 270 291 292 303 q 238 246 249 279 l 153 0 l 19 0 l 135 299 m 418 404 l 633 633 l 201 633 l 418 404 z "},"ѳ":{"ha":833,"x_min":81,"x_max":753,"o":"m 417 -17 q 240 31 315 -17 q 122 165 164 78 q 81 368 81 251 q 122 572 81 485 q 240 706 164 658 q 417 753 315 753 q 593 706 517 753 q 711 572 669 658 q 753 368 753 485 q 711 165 753 251 q 593 31 669 78 q 417 -17 517 -17 m 417 94 q 547 139 494 94 q 619 264 599 183 l 601 263 q 521 272 558 263 q 390 324 483 282 q 309 349 353 342 q 228 356 265 357 q 203 353 219 356 q 262 163 206 232 q 417 94 318 94 m 217 457 q 301 452 256 460 q 382 428 346 444 q 486 383 451 396 q 540 367 521 369 q 590 365 560 364 q 631 372 610 365 q 573 570 629 499 q 417 642 517 642 q 281 594 333 642 q 211 457 228 546 l 217 457 z "},"ѵ":{"ha":833,"x_min":51,"x_max":806,"o":"m 51 736 l 176 736 l 393 122 l 550 569 q 636 693 578 650 q 774 736 694 736 l 806 736 l 806 633 l 792 633 q 660 531 697 633 l 467 0 l 319 0 l 51 736 z "},"җ":{"ha":833,"x_min":15,"x_max":819,"o":"m 703 0 l 685 0 l 475 354 l 475 0 l 358 0 l 358 354 l 149 0 l 15 0 l 233 368 l 24 736 l 157 736 l 358 383 l 358 736 l 475 736 l 475 383 l 676 736 l 810 736 l 600 368 l 757 103 l 819 103 l 819 -208 l 703 -208 l 703 0 z "},"қ":{"ha":833,"x_min":125,"x_max":778,"o":"m 661 0 l 604 0 l 244 358 l 244 0 l 125 0 l 125 736 l 244 736 l 244 378 l 590 736 l 754 736 l 392 368 l 665 103 l 778 103 l 778 -208 l 661 -208 l 661 0 z "},"ң":{"ha":833,"x_min":126,"x_max":803,"o":"m 686 0 l 590 0 l 590 315 l 243 315 l 243 0 l 126 0 l 126 736 l 243 736 l 243 418 l 590 418 l 590 736 l 707 736 l 707 103 l 803 103 l 803 -208 l 686 -208 l 686 0 z "},"ү":{"ha":833,"x_min":75,"x_max":758,"o":"m 75 736 l 200 736 l 417 140 l 633 736 l 758 736 l 475 0 l 475 -208 l 358 -208 l 358 0 l 75 736 z "},"ұ":{"ha":833,"x_min":75,"x_max":758,"o":"m 194 51 l 339 51 l 75 736 l 200 736 l 417 140 l 633 736 l 758 736 l 494 51 l 638 51 l 638 -51 l 475 -51 l 475 -208 l 358 -208 l 358 -51 l 194 -51 l 194 51 z "},"ҳ":{"ha":833,"x_min":75,"x_max":790,"o":"m 674 0 l 622 0 l 418 297 l 214 0 l 75 0 l 346 378 l 89 736 l 225 736 l 418 458 l 607 736 l 746 736 l 490 375 l 685 103 l 790 103 l 790 -208 l 674 -208 l 674 0 z "},"ҷ":{"ha":833,"x_min":96,"x_max":804,"o":"m 688 0 l 592 0 l 592 357 q 507 257 565 294 q 375 219 449 219 q 172 306 247 219 q 96 539 96 392 l 96 736 l 213 736 l 213 539 q 257 387 213 443 q 375 331 301 331 q 533 387 475 331 q 592 540 592 443 l 592 736 l 708 736 l 708 103 l 804 103 l 804 -208 l 688 -208 l 688 0 z "},"һ":{"ha":833,"x_min":139,"x_max":722,"o":"m 139 986 l 256 986 l 256 621 q 341 719 283 686 q 476 753 399 753 q 658 676 593 753 q 722 474 722 600 l 722 0 l 606 0 l 606 440 q 451 650 606 650 q 308 594 361 650 q 256 439 256 539 l 256 0 l 139 0 l 139 986 z "},"ӏ":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 114 l 357 114 l 357 872 l 111 872 l 111 986 l 722 986 l 722 872 l 476 872 l 476 114 l 722 114 l 722 0 l 111 0 l 111 114 z "},"ә":{"ha":833,"x_min":83,"x_max":742,"o":"m 404 753 q 583 706 507 753 q 701 572 660 658 q 742 368 742 485 q 701 165 742 253 q 585 31 660 78 q 410 -17 510 -17 q 240 28 314 -17 q 124 161 165 74 q 83 369 83 249 l 83 404 l 619 404 q 558 581 614 521 q 404 642 501 642 q 287 609 333 642 q 221 518 240 576 l 96 528 q 208 691 125 629 q 404 753 290 753 m 211 301 q 272 147 219 199 q 410 94 325 94 q 551 147 496 94 q 619 301 606 200 l 211 301 z "},"ӣ":{"ha":833,"x_min":131,"x_max":704,"o":"m 131 736 l 247 736 l 247 178 l 567 736 l 704 736 l 704 0 l 588 0 l 588 547 l 275 0 l 131 0 l 131 736 m 231 968 l 603 968 l 603 868 l 231 868 l 231 968 z "},"ө":{"ha":833,"x_min":81,"x_max":753,"o":"m 417 -17 q 240 31 315 -17 q 122 165 164 78 q 81 368 81 251 q 122 572 81 485 q 240 706 164 658 q 417 753 315 753 q 593 706 517 753 q 711 572 669 658 q 753 368 753 485 q 711 165 753 251 q 593 31 669 78 q 417 -17 517 -17 m 417 94 q 562 153 507 94 q 628 317 617 211 l 206 317 q 271 153 217 211 q 417 94 325 94 m 628 419 q 562 583 617 525 q 417 642 507 642 q 271 583 325 642 q 206 419 217 525 l 628 419 z "},"ӯ":{"ha":833,"x_min":75,"x_max":764,"o":"m 192 -106 l 276 -106 q 333 -94 314 -106 q 363 -56 351 -82 l 389 14 l 349 14 l 75 736 l 203 736 l 426 122 l 636 736 l 764 736 l 465 -93 q 403 -181 444 -153 q 288 -208 361 -208 l 192 -208 l 192 -106 m 233 968 l 606 968 l 606 868 l 233 868 l 233 968 z "},"Λ":{"ha":833,"x_min":36,"x_max":797,"o":"m 336 986 l 497 986 l 797 0 l 675 0 l 417 882 l 158 0 l 36 0 l 336 986 z "},"Ω":{"ha":833,"x_min":13,"x_max":821,"o":"m 15 117 l 199 117 q 65 281 117 172 q 13 506 13 390 q 64 766 13 651 q 207 944 115 881 q 417 1008 299 1008 q 626 944 535 1008 q 769 766 718 881 q 821 506 821 651 q 769 281 821 390 q 635 117 717 172 l 818 117 l 818 0 l 472 0 l 472 117 q 585 169 533 117 q 666 310 636 221 q 696 506 696 399 q 660 706 696 618 q 561 842 625 793 q 417 892 497 892 q 272 842 336 892 q 173 706 208 793 q 138 506 138 618 q 167 310 138 399 q 249 169 197 221 q 361 117 300 117 l 361 0 l 15 0 l 15 117 z "},"λ":{"ha":833,"x_min":82,"x_max":764,"o":"m 717 0 q 587 41 632 0 q 510 186 542 82 l 406 525 l 204 0 l 82 0 l 354 693 l 319 806 q 288 865 307 846 q 233 883 269 883 l 146 883 l 146 986 l 224 986 q 360 944 311 986 q 440 800 408 901 l 631 181 q 662 122 643 140 q 715 103 681 103 l 764 103 l 764 0 l 717 0 z "},"π":{"ha":833,"x_min":22,"x_max":808,"o":"m 763 0 q 602 53 654 0 q 550 203 550 107 l 550 633 l 288 633 l 288 0 l 171 0 l 171 633 l 22 633 l 22 736 l 808 736 l 808 633 l 667 633 l 667 203 q 691 129 667 156 q 761 103 715 103 l 806 103 l 806 0 l 763 0 z "},"ℇ":{"ha":833,"x_min":71,"x_max":781,"o":"m 426 -22 q 241 15 322 -22 q 115 119 160 53 q 71 271 71 186 q 124 435 71 368 q 279 533 176 503 q 104 751 104 586 q 145 885 104 826 q 259 976 186 943 q 426 1008 332 1008 q 608 971 518 1008 q 749 875 697 933 l 671 779 q 558 864 626 831 q 426 897 489 897 q 280 858 333 897 q 226 751 226 818 q 286 619 226 663 q 426 578 346 576 l 501 579 l 501 469 l 426 471 q 257 420 321 472 q 193 281 193 368 q 257 142 193 194 q 426 89 321 89 q 578 126 499 89 q 706 221 657 163 l 781 122 q 628 18 726 58 q 426 -22 529 -22 z "},"⓿":{"ha":833,"x_min":21,"x_max":814,"o":"m 417 206 q 263 283 319 206 q 207 493 207 361 q 263 705 207 626 q 417 783 319 783 q 570 705 514 783 q 626 493 626 626 q 570 283 626 361 q 417 206 514 206 m 417 288 q 509 342 476 288 q 542 493 542 396 q 529 601 542 556 l 349 313 q 417 288 376 288 m 292 493 q 304 389 292 433 l 485 676 q 417 701 458 701 q 324 647 357 701 q 292 493 292 593 m 418 -22 q 628 40 539 -22 q 766 219 718 103 q 814 493 814 336 q 766 767 814 650 q 628 946 718 883 q 418 1008 539 1008 q 208 946 297 1008 q 69 767 118 883 q 21 493 21 650 q 69 219 21 336 q 208 40 118 103 q 418 -22 297 -22 z "},"❶":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 628 40 539 -22 q 766 219 718 103 q 814 493 814 336 q 766 767 814 650 q 628 946 718 883 q 418 1008 539 1008 q 208 946 297 1008 q 69 767 118 883 q 21 493 21 650 q 69 219 21 336 q 208 40 118 103 q 418 -22 297 -22 m 386 579 l 249 578 l 249 639 l 282 639 q 374 671 342 639 q 406 763 406 703 l 471 763 l 471 224 l 386 224 l 386 579 z "},"❷":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 628 40 539 -22 q 766 219 718 103 q 814 493 814 336 q 766 767 814 650 q 628 946 718 883 q 418 1008 539 1008 q 208 946 297 1008 q 69 767 118 883 q 21 493 21 650 q 69 219 21 336 q 208 40 118 103 q 418 -22 297 -22 m 232 222 q 267 362 232 297 q 396 489 301 426 l 440 518 q 494 568 478 543 q 510 624 510 593 q 486 678 510 657 q 429 700 463 700 q 325 600 344 700 l 242 604 q 302 732 251 685 q 429 779 353 779 q 549 735 503 779 q 594 624 594 692 q 561 526 594 575 q 464 438 528 478 l 425 413 q 333 301 335 351 l 599 301 l 599 222 l 232 222 z "},"❸":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 628 40 539 -22 q 766 219 718 103 q 814 493 814 336 q 766 767 814 650 q 628 946 718 883 q 418 1008 539 1008 q 208 946 297 1008 q 69 767 118 883 q 21 493 21 650 q 69 219 21 336 q 208 40 118 103 q 418 -22 297 -22 m 418 206 q 283 251 339 206 q 222 361 226 296 l 307 365 q 342 308 310 332 q 418 285 375 285 q 490 311 461 285 q 519 375 519 338 q 488 444 519 415 q 411 474 457 474 l 376 474 l 376 553 l 411 553 q 475 576 449 553 q 501 626 501 599 q 476 682 501 658 q 417 706 451 706 q 355 688 381 706 q 326 646 329 671 l 242 651 q 301 747 250 708 q 417 785 351 785 q 538 743 489 785 q 586 640 586 701 q 562 565 586 600 q 494 518 538 531 q 575 461 543 503 q 607 369 607 419 q 552 253 607 301 q 418 206 497 206 z "},"❹":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 628 40 539 -22 q 766 219 718 103 q 814 493 814 336 q 766 767 814 650 q 628 946 718 883 q 418 1008 539 1008 q 208 946 297 1008 q 69 767 118 883 q 21 493 21 650 q 69 219 21 336 q 208 40 118 103 q 418 -22 297 -22 m 444 329 l 183 329 l 183 399 l 458 763 l 529 763 l 529 408 l 590 408 l 590 329 l 529 329 l 529 224 l 444 224 l 444 329 m 444 408 l 444 611 l 292 408 l 444 408 z "},"❺":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 628 40 539 -22 q 766 219 718 103 q 814 493 814 336 q 766 767 814 650 q 628 946 718 883 q 418 1008 539 1008 q 208 946 297 1008 q 69 767 118 883 q 21 493 21 650 q 69 219 21 336 q 208 40 118 103 q 418 -22 297 -22 m 417 201 q 288 244 340 201 q 229 356 235 288 l 317 360 q 353 303 324 325 q 419 281 382 281 q 490 313 461 281 q 518 393 518 344 q 490 474 518 440 q 419 507 461 507 q 367 495 390 507 q 322 450 343 483 l 239 450 l 282 764 l 568 764 l 568 685 l 356 685 l 338 543 q 381 574 358 564 q 432 583 403 583 q 556 528 507 583 q 606 393 606 474 q 553 255 606 308 q 417 201 500 201 z "},"❻":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 628 40 539 -22 q 766 219 718 103 q 814 493 814 336 q 766 767 814 650 q 628 946 718 883 q 418 1008 539 1008 q 208 946 297 1008 q 69 767 118 883 q 21 493 21 650 q 69 219 21 336 q 208 40 118 103 q 418 -22 297 -22 m 418 206 q 278 279 326 206 q 229 461 229 353 q 280 690 229 597 q 433 782 331 782 q 538 748 492 782 q 603 651 585 714 l 521 643 q 488 688 511 672 q 433 703 465 703 q 351 655 382 703 q 310 535 319 607 q 433 594 356 594 q 556 541 507 594 q 604 401 604 488 q 551 259 604 313 q 418 206 499 206 m 419 285 q 490 317 464 285 q 517 401 517 350 q 492 484 517 453 q 421 515 467 515 q 351 482 379 515 q 322 399 322 449 q 349 317 322 349 q 419 285 376 285 z "},"❼":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 628 40 539 -22 q 766 219 718 103 q 814 493 814 336 q 766 767 814 650 q 628 946 718 883 q 418 1008 539 1008 q 208 946 297 1008 q 69 767 118 883 q 21 493 21 650 q 69 219 21 336 q 208 40 118 103 q 418 -22 297 -22 m 344 224 q 388 464 344 347 q 510 683 431 581 l 231 683 l 231 763 l 614 763 l 614 701 q 429 224 429 464 l 344 224 z "},"❽":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 628 40 539 -22 q 766 219 718 103 q 814 493 814 336 q 766 767 814 650 q 628 946 718 883 q 418 1008 539 1008 q 208 946 297 1008 q 69 767 118 883 q 21 493 21 650 q 69 219 21 336 q 208 40 118 103 q 418 -22 297 -22 m 417 206 q 276 250 332 206 q 221 369 221 294 q 249 460 221 421 q 326 518 276 499 q 247 631 247 551 q 297 739 247 696 q 417 782 346 782 q 538 739 489 782 q 586 631 586 696 q 507 518 586 551 q 585 458 557 497 q 613 369 613 419 q 557 250 613 294 q 417 206 501 206 m 417 285 q 495 310 465 285 q 525 375 525 335 q 494 447 525 419 q 417 475 464 475 q 339 447 369 475 q 308 375 308 419 q 338 310 308 335 q 417 285 368 285 m 417 550 q 476 572 453 550 q 499 631 499 594 q 476 683 499 663 q 417 703 453 703 q 358 683 381 703 q 335 631 335 663 q 358 572 335 594 q 417 550 381 550 z "},"❾":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 628 40 539 -22 q 766 219 718 103 q 814 493 814 336 q 766 767 814 650 q 628 946 718 883 q 418 1008 539 1008 q 208 946 297 1008 q 69 767 118 883 q 21 493 21 650 q 69 219 21 336 q 208 40 118 103 q 418 -22 297 -22 m 404 206 q 299 240 346 206 q 235 336 253 274 l 317 344 q 349 300 326 315 q 404 285 372 285 q 487 330 454 285 q 529 454 519 375 q 476 409 508 425 q 404 393 444 393 q 282 447 331 393 q 233 586 233 500 q 286 728 233 675 q 419 782 339 782 q 560 708 511 782 q 608 526 608 635 q 553 292 608 379 q 404 206 499 206 m 417 472 q 487 506 458 472 q 515 589 515 539 q 488 671 515 639 q 418 703 460 703 q 347 670 374 703 q 321 586 321 638 q 346 503 321 535 q 417 472 371 472 z "},"⓪":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 208 40 297 -22 q 69 219 118 103 q 21 493 21 336 q 69 767 21 650 q 208 946 118 883 q 418 1008 297 1008 q 628 946 539 1008 q 766 767 718 883 q 814 493 814 650 q 766 219 814 336 q 628 40 718 103 q 418 -22 539 -22 m 418 81 q 574 131 508 81 q 676 274 640 181 q 713 493 713 367 q 676 713 713 619 q 574 856 640 806 q 418 907 507 907 q 261 856 328 907 q 159 713 194 806 q 124 493 124 619 q 159 274 124 367 q 261 131 194 181 q 418 81 328 81 m 417 206 q 263 283 319 206 q 207 493 207 361 q 263 705 207 626 q 417 783 319 783 q 570 705 514 783 q 626 493 626 626 q 570 283 626 361 q 417 206 514 206 m 417 288 q 509 342 476 288 q 542 493 542 396 q 529 601 542 556 l 349 313 q 417 288 376 288 m 292 493 q 304 389 292 433 l 485 676 q 417 701 458 701 q 324 647 357 701 q 292 493 292 593 z "},"①":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 208 40 297 -22 q 69 219 118 103 q 21 493 21 336 q 69 767 21 650 q 208 946 118 883 q 418 1008 297 1008 q 628 946 539 1008 q 766 767 718 883 q 814 493 814 650 q 766 219 814 336 q 628 40 718 103 q 418 -22 539 -22 m 418 81 q 574 131 508 81 q 676 274 640 181 q 713 493 713 367 q 676 713 713 619 q 574 856 640 806 q 418 907 507 907 q 261 856 328 907 q 159 713 194 806 q 124 493 124 619 q 159 274 124 367 q 261 131 194 181 q 418 81 328 81 m 386 579 l 249 578 l 249 639 l 282 639 q 374 671 342 639 q 406 763 406 703 l 471 763 l 471 224 l 386 224 l 386 579 z "},"②":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 208 40 297 -22 q 69 219 118 103 q 21 493 21 336 q 69 767 21 650 q 208 946 118 883 q 418 1008 297 1008 q 628 946 539 1008 q 766 767 718 883 q 814 493 814 650 q 766 219 814 336 q 628 40 718 103 q 418 -22 539 -22 m 418 81 q 574 131 508 81 q 676 274 640 181 q 713 493 713 367 q 676 713 713 619 q 574 856 640 806 q 418 907 507 907 q 261 856 328 907 q 159 713 194 806 q 124 493 124 619 q 159 274 124 367 q 261 131 194 181 q 418 81 328 81 m 232 222 q 267 362 232 297 q 396 489 301 426 l 440 518 q 494 568 478 543 q 510 624 510 593 q 486 678 510 657 q 429 700 463 700 q 325 600 344 700 l 242 604 q 302 732 251 685 q 429 779 353 779 q 549 735 503 779 q 594 624 594 692 q 561 526 594 575 q 464 438 528 478 l 425 413 q 333 301 335 351 l 599 301 l 599 222 l 232 222 z "},"③":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 208 40 297 -22 q 69 219 118 103 q 21 493 21 336 q 69 767 21 650 q 208 946 118 883 q 418 1008 297 1008 q 628 946 539 1008 q 766 767 718 883 q 814 493 814 650 q 766 219 814 336 q 628 40 718 103 q 418 -22 539 -22 m 418 81 q 574 131 508 81 q 676 274 640 181 q 713 493 713 367 q 676 713 713 619 q 574 856 640 806 q 418 907 507 907 q 261 856 328 907 q 159 713 194 806 q 124 493 124 619 q 159 274 124 367 q 261 131 194 181 q 418 81 328 81 m 418 206 q 283 251 339 206 q 222 361 226 296 l 307 365 q 342 308 310 332 q 418 285 375 285 q 490 311 461 285 q 519 375 519 338 q 488 444 519 415 q 411 474 457 474 l 376 474 l 376 553 l 411 553 q 475 576 449 553 q 501 626 501 599 q 476 682 501 658 q 417 706 451 706 q 355 688 381 706 q 326 646 329 671 l 242 651 q 301 747 250 708 q 417 785 351 785 q 538 743 489 785 q 586 640 586 701 q 562 565 586 600 q 494 518 538 531 q 575 461 543 503 q 607 369 607 419 q 552 253 607 301 q 418 206 497 206 z "},"④":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 208 40 297 -22 q 69 219 118 103 q 21 493 21 336 q 69 767 21 650 q 208 946 118 883 q 418 1008 297 1008 q 628 946 539 1008 q 766 767 718 883 q 814 493 814 650 q 766 219 814 336 q 628 40 718 103 q 418 -22 539 -22 m 418 81 q 574 131 508 81 q 676 274 640 181 q 713 493 713 367 q 676 713 713 619 q 574 856 640 806 q 418 907 507 907 q 261 856 328 907 q 159 713 194 806 q 124 493 124 619 q 159 274 124 367 q 261 131 194 181 q 418 81 328 81 m 444 329 l 183 329 l 183 399 l 458 763 l 529 763 l 529 408 l 590 408 l 590 329 l 529 329 l 529 224 l 444 224 l 444 329 m 444 408 l 444 611 l 292 408 l 444 408 z "},"⑤":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 208 40 297 -22 q 69 219 118 103 q 21 493 21 336 q 69 767 21 650 q 208 946 118 883 q 418 1008 297 1008 q 628 946 539 1008 q 766 767 718 883 q 814 493 814 650 q 766 219 814 336 q 628 40 718 103 q 418 -22 539 -22 m 418 81 q 574 131 508 81 q 676 274 640 181 q 713 493 713 367 q 676 713 713 619 q 574 856 640 806 q 418 907 507 907 q 261 856 328 907 q 159 713 194 806 q 124 493 124 619 q 159 274 124 367 q 261 131 194 181 q 418 81 328 81 m 417 201 q 288 244 340 201 q 229 356 235 288 l 317 360 q 353 303 324 325 q 419 281 382 281 q 490 313 461 281 q 518 393 518 344 q 490 474 518 440 q 419 507 461 507 q 367 495 390 507 q 322 450 343 483 l 239 450 l 282 764 l 568 764 l 568 685 l 356 685 l 338 543 q 381 574 358 564 q 432 583 403 583 q 556 528 507 583 q 606 393 606 474 q 553 255 606 308 q 417 201 500 201 z "},"⑥":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 208 40 297 -22 q 69 219 118 103 q 21 493 21 336 q 69 767 21 650 q 208 946 118 883 q 418 1008 297 1008 q 628 946 539 1008 q 766 767 718 883 q 814 493 814 650 q 766 219 814 336 q 628 40 718 103 q 418 -22 539 -22 m 418 81 q 574 131 508 81 q 676 274 640 181 q 713 493 713 367 q 676 713 713 619 q 574 856 640 806 q 418 907 507 907 q 261 856 328 907 q 159 713 194 806 q 124 493 124 619 q 159 274 124 367 q 261 131 194 181 q 418 81 328 81 m 418 206 q 278 279 326 206 q 229 461 229 353 q 280 690 229 597 q 433 782 331 782 q 538 748 492 782 q 603 651 585 714 l 521 643 q 488 688 511 672 q 433 703 465 703 q 351 655 382 703 q 310 535 319 607 q 433 594 356 594 q 556 541 507 594 q 604 401 604 488 q 551 259 604 313 q 418 206 499 206 m 419 285 q 490 317 464 285 q 517 401 517 350 q 492 484 517 453 q 421 515 467 515 q 351 482 379 515 q 322 399 322 449 q 349 317 322 349 q 419 285 376 285 z "},"⑦":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 208 40 297 -22 q 69 219 118 103 q 21 493 21 336 q 69 767 21 650 q 208 946 118 883 q 418 1008 297 1008 q 628 946 539 1008 q 766 767 718 883 q 814 493 814 650 q 766 219 814 336 q 628 40 718 103 q 418 -22 539 -22 m 418 81 q 574 131 508 81 q 676 274 640 181 q 713 493 713 367 q 676 713 713 619 q 574 856 640 806 q 418 907 507 907 q 261 856 328 907 q 159 713 194 806 q 124 493 124 619 q 159 274 124 367 q 261 131 194 181 q 418 81 328 81 m 344 224 q 388 464 344 347 q 510 683 431 581 l 231 683 l 231 763 l 614 763 l 614 701 q 429 224 429 464 l 344 224 z "},"⑧":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 208 40 297 -22 q 69 219 118 103 q 21 493 21 336 q 69 767 21 650 q 208 946 118 883 q 418 1008 297 1008 q 628 946 539 1008 q 766 767 718 883 q 814 493 814 650 q 766 219 814 336 q 628 40 718 103 q 418 -22 539 -22 m 418 81 q 574 131 508 81 q 676 274 640 181 q 713 493 713 367 q 676 713 713 619 q 574 856 640 806 q 418 907 507 907 q 261 856 328 907 q 159 713 194 806 q 124 493 124 619 q 159 274 124 367 q 261 131 194 181 q 418 81 328 81 m 417 206 q 276 250 332 206 q 221 369 221 294 q 249 460 221 421 q 326 518 276 499 q 247 631 247 551 q 297 739 247 696 q 417 782 346 782 q 538 739 489 782 q 586 631 586 696 q 507 518 586 551 q 585 458 557 497 q 613 369 613 419 q 557 250 613 294 q 417 206 501 206 m 417 285 q 495 310 465 285 q 525 375 525 335 q 494 447 525 419 q 417 475 464 475 q 339 447 369 475 q 308 375 308 419 q 338 310 308 335 q 417 285 368 285 m 417 550 q 476 572 453 550 q 499 631 499 594 q 476 683 499 663 q 417 703 453 703 q 358 683 381 703 q 335 631 335 663 q 358 572 335 594 q 417 550 381 550 z "},"⑨":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 208 40 297 -22 q 69 219 118 103 q 21 493 21 336 q 69 767 21 650 q 208 946 118 883 q 418 1008 297 1008 q 628 946 539 1008 q 766 767 718 883 q 814 493 814 650 q 766 219 814 336 q 628 40 718 103 q 418 -22 539 -22 m 418 81 q 574 131 508 81 q 676 274 640 181 q 713 493 713 367 q 676 713 713 619 q 574 856 640 806 q 418 907 507 907 q 261 856 328 907 q 159 713 194 806 q 124 493 124 619 q 159 274 124 367 q 261 131 194 181 q 418 81 328 81 m 404 206 q 299 240 346 206 q 235 336 253 274 l 317 344 q 349 300 326 315 q 404 285 372 285 q 487 330 454 285 q 529 454 519 375 q 476 409 508 425 q 404 393 444 393 q 282 447 331 393 q 233 586 233 500 q 286 728 233 675 q 419 782 339 782 q 560 708 511 782 q 608 526 608 635 q 553 292 608 379 q 404 206 499 206 m 417 472 q 487 506 458 472 q 515 589 515 539 q 488 671 515 639 q 418 703 460 703 q 347 670 374 703 q 321 586 321 638 q 346 503 321 535 q 417 472 371 472 z "},"⁄":{"ha":833,"x_min":18,"x_max":815,"o":"m 699 986 l 815 986 l 135 0 l 18 0 l 699 986 z "},"½":{"ha":833,"x_min":56,"x_max":793,"o":"m 664 986 l 772 986 l 579 631 l 471 631 l 664 986 m 249 354 l 357 354 l 164 0 l 56 0 l 249 354 m 426 0 q 461 140 426 75 q 590 267 496 204 l 635 296 q 688 346 672 321 q 704 401 704 371 q 681 456 704 435 q 624 478 657 478 q 519 378 539 478 l 436 382 q 497 510 446 463 q 624 557 547 557 q 743 513 697 557 q 789 401 789 469 q 756 304 789 353 q 658 215 722 256 l 619 190 q 528 79 529 129 l 793 79 l 793 0 l 426 0 m 210 804 l 72 803 l 72 864 l 106 864 q 197 896 165 864 q 229 988 229 928 l 294 988 l 294 449 l 210 449 l 210 804 z "},"⅓":{"ha":833,"x_min":56,"x_max":801,"o":"m 664 986 l 772 986 l 579 631 l 471 631 l 664 986 m 249 354 l 357 354 l 164 0 l 56 0 l 249 354 m 613 -22 q 477 23 533 -22 q 417 133 421 68 l 501 138 q 537 81 504 104 q 613 57 569 57 q 685 83 656 57 q 714 147 714 110 q 683 217 714 188 q 606 246 651 246 l 571 246 l 571 325 l 606 325 q 669 348 643 325 q 696 399 696 371 q 671 454 696 431 q 611 478 646 478 q 549 460 575 478 q 521 418 524 443 l 436 424 q 495 519 444 481 q 611 557 546 557 q 732 515 683 557 q 781 413 781 474 q 756 338 781 372 q 689 290 732 303 q 769 233 738 275 q 801 142 801 192 q 747 26 801 74 q 613 -22 692 -22 m 208 804 l 71 803 l 71 864 l 104 864 q 196 896 164 864 q 228 988 228 928 l 293 988 l 293 449 l 208 449 l 208 804 z "},"⅔":{"ha":833,"x_min":28,"x_max":807,"o":"m 664 986 l 772 986 l 579 631 l 471 631 l 664 986 m 249 354 l 357 354 l 164 0 l 56 0 l 249 354 m 618 -22 q 483 23 539 -22 q 422 133 426 68 l 507 138 q 542 81 510 104 q 618 57 575 57 q 690 83 661 57 q 719 147 719 110 q 688 217 719 188 q 611 246 657 246 l 576 246 l 576 325 l 611 325 q 675 348 649 325 q 701 399 701 371 q 676 454 701 431 q 617 478 651 478 q 555 460 581 478 q 526 418 529 443 l 442 424 q 501 519 450 481 q 617 557 551 557 q 738 515 689 557 q 786 413 786 474 q 762 338 786 372 q 694 290 738 303 q 775 233 743 275 q 807 142 807 192 q 752 26 807 74 q 618 -22 697 -22 m 28 451 q 63 591 28 526 q 192 718 97 656 l 236 747 q 290 797 274 772 q 306 853 306 822 q 282 908 306 886 q 225 929 258 929 q 121 829 140 929 l 38 833 q 98 961 47 914 q 225 1008 149 1008 q 344 965 299 1008 q 390 853 390 921 q 357 756 390 804 q 260 667 324 707 l 221 642 q 129 531 131 581 l 394 531 l 394 451 l 28 451 z "},"¼":{"ha":833,"x_min":56,"x_max":793,"o":"m 664 986 l 772 986 l 579 631 l 471 631 l 664 986 m 249 354 l 357 354 l 164 0 l 56 0 l 249 354 m 647 106 l 386 106 l 386 175 l 661 539 l 732 539 l 732 185 l 793 185 l 793 106 l 732 106 l 732 0 l 647 0 l 647 106 m 647 185 l 647 388 l 494 185 l 647 185 m 208 804 l 71 803 l 71 864 l 104 864 q 196 896 164 864 q 228 988 228 928 l 293 988 l 293 449 l 208 449 l 208 804 z "},"¾":{"ha":833,"x_min":24,"x_max":793,"o":"m 664 986 l 772 986 l 579 631 l 471 631 l 664 986 m 249 354 l 357 354 l 164 0 l 56 0 l 249 354 m 647 106 l 386 106 l 386 175 l 661 539 l 732 539 l 732 185 l 793 185 l 793 106 l 732 106 l 732 0 l 647 0 l 647 106 m 647 185 l 647 388 l 494 185 l 647 185 m 219 429 q 84 474 140 429 q 24 585 28 519 l 108 589 q 144 532 111 556 q 219 508 176 508 q 292 535 263 508 q 321 599 321 561 q 290 668 321 639 q 213 697 258 697 l 178 697 l 178 776 l 213 776 q 276 799 250 776 q 303 850 303 822 q 278 906 303 882 q 218 929 253 929 q 156 912 182 929 q 128 869 131 894 l 43 875 q 102 970 51 932 q 218 1008 153 1008 q 339 967 290 1008 q 388 864 388 925 q 363 789 388 824 q 296 742 339 754 q 376 685 344 726 q 408 593 408 643 q 353 477 408 525 q 219 429 299 429 z "},"⅕":{"ha":833,"x_min":56,"x_max":779,"o":"m 664 986 l 772 986 l 579 631 l 471 631 l 664 986 m 249 354 l 357 354 l 164 0 l 56 0 l 249 354 m 590 -22 q 461 21 514 -22 q 403 132 408 64 l 490 136 q 526 79 497 101 q 593 57 556 57 q 663 89 635 57 q 692 169 692 121 q 663 250 692 217 q 593 283 635 283 q 540 272 564 283 q 496 226 517 260 l 413 226 l 456 540 l 742 540 l 742 461 l 529 461 l 511 319 q 554 350 532 340 q 606 360 576 360 q 730 305 681 360 q 779 169 779 250 q 726 31 779 85 q 590 -22 674 -22 m 208 804 l 71 803 l 71 864 l 104 864 q 196 896 164 864 q 228 988 228 928 l 293 988 l 293 449 l 208 449 l 208 804 z "},"⅛":{"ha":833,"x_min":56,"x_max":793,"o":"m 664 986 l 772 986 l 579 631 l 471 631 l 664 986 m 249 354 l 357 354 l 164 0 l 56 0 l 249 354 m 597 -22 q 457 22 513 -22 q 401 142 401 67 q 429 232 401 193 q 507 290 457 271 q 428 403 428 324 q 477 511 428 468 q 597 554 526 554 q 718 511 669 554 q 767 403 767 468 q 688 290 767 324 q 765 231 738 269 q 793 142 793 192 q 738 22 793 67 q 597 -22 682 -22 m 597 57 q 676 82 646 57 q 706 147 706 107 q 675 219 706 192 q 597 247 644 247 q 519 219 550 247 q 489 147 489 192 q 519 82 489 107 q 597 57 549 57 m 597 322 q 656 344 633 322 q 679 403 679 367 q 656 455 679 435 q 597 475 633 475 q 538 455 561 475 q 515 403 515 435 q 538 344 515 367 q 597 322 561 322 m 208 804 l 71 803 l 71 864 l 104 864 q 196 896 164 864 q 228 988 228 928 l 293 988 l 293 449 l 208 449 l 208 804 z "},"⅜":{"ha":833,"x_min":28,"x_max":793,"o":"m 664 986 l 772 986 l 579 631 l 471 631 l 664 986 m 249 354 l 357 354 l 164 0 l 56 0 l 249 354 m 597 -22 q 457 22 513 -22 q 401 142 401 67 q 429 232 401 193 q 507 290 457 271 q 428 403 428 324 q 477 511 428 468 q 597 554 526 554 q 718 511 669 554 q 767 403 767 468 q 688 290 767 324 q 765 231 738 269 q 793 142 793 192 q 738 22 793 67 q 597 -22 682 -22 m 597 57 q 676 82 646 57 q 706 147 706 107 q 675 219 706 192 q 597 247 644 247 q 519 219 550 247 q 489 147 489 192 q 519 82 489 107 q 597 57 549 57 m 597 322 q 656 344 633 322 q 679 403 679 367 q 656 455 679 435 q 597 475 633 475 q 538 455 561 475 q 515 403 515 435 q 538 344 515 367 q 597 322 561 322 m 215 425 q 86 468 139 425 q 28 579 33 511 l 115 583 q 151 526 122 549 q 218 504 181 504 q 288 536 260 504 q 317 617 317 568 q 288 697 317 664 q 218 731 260 731 q 165 719 189 731 q 121 674 142 707 l 38 674 l 81 988 l 367 988 l 367 908 l 154 908 l 136 767 q 179 797 157 788 q 231 807 201 807 q 355 752 306 807 q 404 617 404 697 q 351 478 404 532 q 215 425 299 425 z "},"⅝":{"ha":833,"x_min":24,"x_max":793,"o":"m 664 986 l 772 986 l 579 631 l 471 631 l 664 986 m 249 354 l 357 354 l 164 0 l 56 0 l 249 354 m 597 -22 q 457 22 513 -22 q 401 142 401 67 q 429 232 401 193 q 507 290 457 271 q 428 403 428 324 q 477 511 428 468 q 597 554 526 554 q 718 511 669 554 q 767 403 767 468 q 688 290 767 324 q 765 231 738 269 q 793 142 793 192 q 738 22 793 67 q 597 -22 682 -22 m 597 57 q 676 82 646 57 q 706 147 706 107 q 675 219 706 192 q 597 247 644 247 q 519 219 550 247 q 489 147 489 192 q 519 82 489 107 q 597 57 549 57 m 597 322 q 656 344 633 322 q 679 403 679 367 q 656 455 679 435 q 597 475 633 475 q 538 455 561 475 q 515 403 515 435 q 538 344 515 367 q 597 322 561 322 m 219 429 q 84 474 140 429 q 24 585 28 519 l 108 589 q 144 532 111 556 q 219 508 176 508 q 292 535 263 508 q 321 599 321 561 q 290 668 321 639 q 213 697 258 697 l 178 697 l 178 776 l 213 776 q 276 799 250 776 q 303 850 303 822 q 278 906 303 882 q 218 929 253 929 q 156 912 182 929 q 128 869 131 894 l 43 875 q 102 970 51 932 q 218 1008 153 1008 q 339 967 290 1008 q 388 864 388 925 q 363 789 388 824 q 296 742 339 754 q 376 685 344 726 q 408 593 408 643 q 353 477 408 525 q 219 429 299 429 z "},"⅞":{"ha":833,"x_min":56,"x_max":793,"o":"m 664 986 l 772 986 l 579 631 l 471 631 l 664 986 m 249 354 l 357 354 l 164 0 l 56 0 l 249 354 m 597 -22 q 457 22 513 -22 q 401 142 401 67 q 429 232 401 193 q 507 290 457 271 q 428 403 428 324 q 477 511 428 468 q 597 554 526 554 q 718 511 669 554 q 767 403 767 468 q 688 290 767 324 q 765 231 738 269 q 793 142 793 192 q 738 22 793 67 q 597 -22 682 -22 m 597 57 q 676 82 646 57 q 706 147 706 107 q 675 219 706 192 q 597 247 644 247 q 519 219 550 247 q 489 147 489 192 q 519 82 489 107 q 597 57 549 57 m 597 322 q 656 344 633 322 q 679 403 679 367 q 656 455 679 435 q 597 475 633 475 q 538 455 561 475 q 515 403 515 435 q 538 344 515 367 q 597 322 561 322 m 192 447 q 235 688 192 571 q 357 907 278 804 l 78 907 l 78 986 l 461 986 l 461 925 q 276 447 276 688 l 192 447 z "},"₀":{"ha":833,"x_min":207,"x_max":626,"o":"m 417 -21 q 263 57 319 -21 q 207 267 207 135 q 263 478 207 400 q 417 557 319 557 q 570 478 514 557 q 626 267 626 400 q 570 57 626 135 q 417 -21 514 -21 m 417 61 q 509 115 476 61 q 542 267 542 169 q 529 375 542 329 l 349 86 q 417 61 376 61 m 292 267 q 304 163 292 207 l 485 450 q 417 475 458 475 q 324 421 357 475 q 292 267 292 367 z "},"₁":{"ha":833,"x_min":249,"x_max":471,"o":"m 386 356 l 249 354 l 249 415 l 282 415 q 374 447 342 415 q 406 539 406 479 l 471 539 l 471 0 l 386 0 l 386 356 z "},"₂":{"ha":833,"x_min":222,"x_max":589,"o":"m 222 0 q 257 140 222 75 q 386 267 292 204 l 431 296 q 484 346 468 321 q 500 401 500 371 q 476 456 500 435 q 419 478 453 478 q 315 378 335 478 l 232 382 q 292 510 242 463 q 419 557 343 557 q 539 513 493 557 q 585 401 585 469 q 551 304 585 353 q 454 215 518 256 l 415 190 q 324 79 325 129 l 589 79 l 589 0 l 222 0 z "},"₃":{"ha":833,"x_min":222,"x_max":607,"o":"m 418 -22 q 283 23 339 -22 q 222 133 226 68 l 307 138 q 342 81 310 104 q 418 57 375 57 q 490 83 461 57 q 519 147 519 110 q 488 217 519 188 q 411 246 457 246 l 376 246 l 376 325 l 411 325 q 475 348 449 325 q 501 399 501 371 q 476 454 501 431 q 417 478 451 478 q 355 460 381 478 q 326 418 329 443 l 242 424 q 301 519 250 481 q 417 557 351 557 q 538 515 489 557 q 586 413 586 474 q 562 338 586 372 q 494 290 538 303 q 575 233 543 275 q 607 142 607 192 q 552 26 607 74 q 418 -22 497 -22 z "},"₄":{"ha":833,"x_min":183,"x_max":590,"o":"m 444 106 l 183 106 l 183 175 l 458 539 l 529 539 l 529 185 l 590 185 l 590 106 l 529 106 l 529 0 l 444 0 l 444 106 m 444 185 l 444 388 l 292 185 l 444 185 z "},"₅":{"ha":833,"x_min":229,"x_max":606,"o":"m 417 -22 q 288 21 340 -22 q 229 132 235 64 l 317 136 q 353 79 324 101 q 419 57 382 57 q 490 89 461 57 q 518 169 518 121 q 490 250 518 217 q 419 283 461 283 q 367 272 390 283 q 322 226 343 260 l 239 226 l 282 540 l 568 540 l 568 461 l 356 461 l 338 319 q 381 350 358 340 q 432 360 403 360 q 556 305 507 360 q 606 169 606 250 q 553 31 606 85 q 417 -22 500 -22 z "},"₆":{"ha":833,"x_min":229,"x_max":604,"o":"m 418 -22 q 278 51 326 -22 q 229 233 229 125 q 280 462 229 369 q 433 554 331 554 q 538 520 492 554 q 603 424 585 486 l 521 415 q 488 460 511 444 q 433 475 465 475 q 351 427 382 475 q 310 307 319 379 q 433 367 356 367 q 556 313 507 367 q 604 174 604 260 q 551 31 604 85 q 418 -22 499 -22 m 419 57 q 490 90 464 57 q 517 174 517 122 q 492 256 517 225 q 421 288 467 288 q 351 254 379 288 q 322 171 322 221 q 349 89 322 121 q 419 57 376 57 z "},"₇":{"ha":833,"x_min":231,"x_max":614,"o":"m 344 -1 q 388 239 344 122 q 510 458 431 356 l 231 458 l 231 538 l 614 538 l 614 476 q 429 -1 429 239 l 344 -1 z "},"₈":{"ha":833,"x_min":221,"x_max":613,"o":"m 417 -22 q 276 22 332 -22 q 221 142 221 67 q 249 232 221 193 q 326 290 276 271 q 247 403 247 324 q 297 511 247 468 q 417 554 346 554 q 538 511 489 554 q 586 403 586 468 q 507 290 586 324 q 585 231 557 269 q 613 142 613 192 q 557 22 613 67 q 417 -22 501 -22 m 417 57 q 495 82 465 57 q 525 147 525 107 q 494 219 525 192 q 417 247 464 247 q 339 219 369 247 q 308 147 308 192 q 338 82 308 107 q 417 57 368 57 m 417 322 q 476 344 453 322 q 499 403 499 367 q 476 455 499 435 q 417 475 453 475 q 358 455 381 475 q 335 403 335 435 q 358 344 335 367 q 417 322 381 322 z "},"₉":{"ha":833,"x_min":233,"x_max":608,"o":"m 404 -22 q 299 12 346 -22 q 235 108 253 46 l 317 117 q 349 72 326 88 q 404 57 372 57 q 487 102 454 57 q 529 226 519 147 q 476 181 508 197 q 404 165 444 165 q 282 219 331 165 q 233 358 233 272 q 286 501 233 447 q 419 554 339 554 q 560 481 511 554 q 608 299 608 407 q 553 65 608 151 q 404 -22 499 -22 m 417 244 q 487 278 458 244 q 515 361 515 311 q 488 443 515 411 q 418 475 460 475 q 347 442 374 475 q 321 358 321 410 q 346 276 321 307 q 417 244 371 244 z "},"⁰":{"ha":833,"x_min":207,"x_max":626,"o":"m 417 431 q 263 508 319 431 q 207 718 207 586 q 263 930 207 851 q 417 1008 319 1008 q 570 930 514 1008 q 626 718 626 851 q 570 508 626 586 q 417 431 514 431 m 417 513 q 509 567 476 513 q 542 718 542 621 q 529 826 542 781 l 349 538 q 417 513 376 513 m 292 718 q 304 614 292 658 l 485 901 q 417 926 458 926 q 324 872 357 926 q 292 718 292 818 z "},"¹":{"ha":833,"x_min":249,"x_max":471,"o":"m 386 804 l 249 803 l 249 864 l 282 864 q 374 896 342 864 q 406 988 406 928 l 471 988 l 471 449 l 386 449 l 386 804 z "},"²":{"ha":833,"x_min":222,"x_max":589,"o":"m 222 451 q 257 591 222 526 q 386 718 292 656 l 431 747 q 484 797 468 772 q 500 853 500 822 q 476 908 500 886 q 419 929 453 929 q 315 829 335 929 l 232 833 q 292 961 242 914 q 419 1008 343 1008 q 539 965 493 1008 q 585 853 585 921 q 551 756 585 804 q 454 667 518 707 l 415 642 q 324 531 325 581 l 589 531 l 589 451 l 222 451 z "},"³":{"ha":833,"x_min":222,"x_max":607,"o":"m 418 429 q 283 474 339 429 q 222 585 226 519 l 307 589 q 342 532 310 556 q 418 508 375 508 q 490 535 461 508 q 519 599 519 561 q 488 668 519 639 q 411 697 457 697 l 376 697 l 376 776 l 411 776 q 475 799 449 776 q 501 850 501 822 q 476 906 501 882 q 417 929 451 929 q 355 912 381 929 q 326 869 329 894 l 242 875 q 301 970 250 932 q 417 1008 351 1008 q 538 967 489 1008 q 586 864 586 925 q 562 789 586 824 q 494 742 538 754 q 575 685 543 726 q 607 593 607 643 q 552 477 607 525 q 418 429 497 429 z "},"⁴":{"ha":833,"x_min":183,"x_max":590,"o":"m 444 554 l 183 554 l 183 624 l 458 988 l 529 988 l 529 633 l 590 633 l 590 554 l 529 554 l 529 449 l 444 449 l 444 554 m 444 633 l 444 836 l 292 633 l 444 633 z "},"⁵":{"ha":833,"x_min":229,"x_max":606,"o":"m 417 425 q 288 468 340 425 q 229 579 235 511 l 317 583 q 353 526 324 549 q 419 504 382 504 q 490 536 461 504 q 518 617 518 568 q 490 697 518 664 q 419 731 461 731 q 367 719 390 731 q 322 674 343 707 l 239 674 l 282 988 l 568 988 l 568 908 l 356 908 l 338 767 q 381 797 358 788 q 432 807 403 807 q 556 752 507 807 q 606 617 606 697 q 553 478 606 532 q 417 425 500 425 z "},"⁶":{"ha":833,"x_min":229,"x_max":604,"o":"m 418 432 q 278 506 326 432 q 229 688 229 579 q 280 916 229 824 q 433 1008 331 1008 q 538 974 492 1008 q 603 878 585 940 l 521 869 q 488 914 511 899 q 433 929 465 929 q 351 881 382 929 q 310 761 319 833 q 433 821 356 821 q 556 767 507 821 q 604 628 604 714 q 551 485 604 539 q 418 432 499 432 m 419 511 q 490 544 464 511 q 517 628 517 576 q 492 710 517 679 q 421 742 467 742 q 351 708 379 742 q 322 625 322 675 q 349 543 322 575 q 419 511 376 511 z "},"⁷":{"ha":833,"x_min":231,"x_max":614,"o":"m 344 447 q 388 688 344 571 q 510 907 431 804 l 231 907 l 231 986 l 614 986 l 614 925 q 429 447 429 688 l 344 447 z "},"⁸":{"ha":833,"x_min":221,"x_max":613,"o":"m 417 432 q 276 476 332 432 q 221 596 221 521 q 249 686 221 647 q 326 744 276 725 q 247 857 247 778 q 297 965 247 922 q 417 1008 346 1008 q 538 965 489 1008 q 586 857 586 922 q 507 744 586 778 q 585 685 557 724 q 613 596 613 646 q 557 476 613 521 q 417 432 501 432 m 417 511 q 495 536 465 511 q 525 601 525 561 q 494 674 525 646 q 417 701 464 701 q 339 674 369 701 q 308 601 308 646 q 338 536 308 561 q 417 511 368 511 m 417 776 q 476 799 453 776 q 499 857 499 821 q 476 909 499 889 q 417 929 453 929 q 358 909 381 929 q 335 857 335 889 q 358 799 335 821 q 417 776 381 776 z "},"⁹":{"ha":833,"x_min":233,"x_max":608,"o":"m 404 432 q 299 466 346 432 q 235 563 253 500 l 317 571 q 349 526 326 542 q 404 511 372 511 q 487 556 454 511 q 529 681 519 601 q 476 635 508 651 q 404 619 444 619 q 282 673 331 619 q 233 813 233 726 q 286 955 233 901 q 419 1008 339 1008 q 560 935 511 1008 q 608 753 608 861 q 553 519 608 606 q 404 432 499 432 m 417 699 q 487 732 458 699 q 515 815 515 765 q 488 897 515 865 q 418 929 460 929 q 347 897 374 929 q 321 813 321 864 q 346 730 321 761 q 417 699 371 699 z "}," ":{"ha":833,"x_min":0,"x_max":0,"o":""},".":{"ha":833,"x_min":333,"x_max":500,"o":"m 333 167 l 500 167 l 500 0 l 333 0 l 333 167 z "},",":{"ha":833,"x_min":333,"x_max":500,"o":"m 408 0 l 333 0 l 333 167 l 500 167 l 500 19 l 413 -217 l 343 -217 l 408 0 z "},":":{"ha":833,"x_min":333,"x_max":500,"o":"m 333 167 l 500 167 l 500 0 l 333 0 l 333 167 m 333 703 l 500 703 l 500 536 l 333 536 l 333 703 z "},";":{"ha":833,"x_min":333,"x_max":500,"o":"m 333 703 l 500 703 l 500 536 l 333 536 l 333 703 m 408 0 l 333 0 l 333 167 l 500 167 l 500 19 l 413 -217 l 343 -217 l 408 0 z "},"…":{"ha":833,"x_min":76,"x_max":757,"o":"m 76 167 l 243 167 l 243 0 l 76 0 l 76 167 m 332 167 l 499 167 l 499 0 l 332 0 l 332 167 m 590 167 l 757 167 l 757 0 l 590 0 l 590 167 z "},"!":{"ha":833,"x_min":333,"x_max":500,"o":"m 333 167 l 500 167 l 500 0 l 333 0 l 333 167 m 379 290 q 357 667 357 550 l 357 986 l 476 986 l 476 667 q 456 290 476 533 l 379 290 z "},"¡":{"ha":833,"x_min":333,"x_max":500,"o":"m 500 761 l 500 594 l 333 594 l 333 761 l 500 761 m 456 471 q 476 94 476 228 l 476 -225 l 357 -225 l 357 94 q 379 471 357 211 l 456 471 z "},"?":{"ha":833,"x_min":93,"x_max":740,"o":"m 329 167 l 496 167 l 496 0 l 329 0 l 329 167 m 353 279 q 384 437 353 378 q 488 542 415 496 q 569 601 543 575 q 606 653 596 626 q 615 725 615 681 q 567 845 615 799 q 438 892 519 892 q 218 686 239 892 l 93 694 q 197 922 106 835 q 438 1008 289 1008 q 595 972 526 1008 q 702 872 664 936 q 740 725 740 807 q 701 585 740 640 q 568 469 663 531 q 492 393 513 433 q 472 279 472 353 l 353 279 z "},"¿":{"ha":833,"x_min":93,"x_max":740,"o":"m 504 617 l 338 617 l 338 783 l 504 783 l 504 617 m 481 504 q 449 347 481 406 q 346 242 418 288 q 264 183 290 208 q 228 130 238 157 q 218 58 218 103 q 266 -62 218 -15 q 396 -108 314 -108 q 615 97 594 -108 l 740 89 q 636 -138 728 -51 q 396 -225 544 -225 q 238 -189 307 -225 q 131 -88 169 -153 q 93 58 93 -24 q 132 198 93 143 q 265 314 171 253 q 341 390 321 350 q 361 504 361 431 l 481 504 z "},"·":{"ha":833,"x_min":333,"x_max":500,"o":"m 333 504 l 500 504 l 500 338 l 333 338 l 333 504 z "},"•":{"ha":833,"x_min":271,"x_max":563,"o":"m 417 276 q 314 319 357 276 q 271 421 271 361 q 314 524 271 482 q 417 567 357 567 q 520 524 478 567 q 563 421 563 482 q 519 319 563 361 q 417 276 476 276 z "},"*":{"ha":833,"x_min":181,"x_max":653,"o":"m 253 578 l 346 719 l 181 719 l 181 808 l 346 808 l 253 950 l 325 997 l 417 849 l 510 997 l 582 950 l 488 808 l 653 808 l 653 719 l 488 719 l 582 578 l 510 531 l 417 678 l 325 531 l 253 578 z "},"〃":{"ha":833,"x_min":224,"x_max":610,"o":"m 481 668 l 610 668 l 485 390 l 394 390 l 481 668 m 311 668 l 439 668 l 314 390 l 224 390 l 311 668 z "},"#":{"ha":833,"x_min":75,"x_max":758,"o":"m 464 274 l 290 274 l 239 0 l 139 0 l 190 274 l 75 274 l 92 371 l 208 371 l 253 611 l 131 611 l 147 708 l 271 708 l 322 986 l 422 986 l 371 708 l 544 708 l 596 986 l 696 986 l 644 708 l 758 708 l 742 611 l 626 611 l 582 371 l 703 371 l 686 274 l 564 274 l 513 0 l 413 0 l 464 274 m 482 371 l 526 611 l 353 611 l 308 371 l 482 371 z "},"/":{"ha":833,"x_min":150,"x_max":683,"o":"m 572 1042 l 683 1042 l 261 -153 l 150 -153 l 572 1042 z "},"\\":{"ha":833,"x_min":150,"x_max":683,"o":"m 572 -153 l 150 1042 l 261 1042 l 683 -153 l 572 -153 z "},"-":{"ha":833,"x_min":208,"x_max":625,"o":"m 208 458 l 625 458 l 625 350 l 208 350 l 208 458 z "},"–":{"ha":833,"x_min":139,"x_max":694,"o":"m 139 460 l 694 460 l 694 351 l 139 351 l 139 460 z "},"—":{"ha":833,"x_min":56,"x_max":778,"o":"m 56 460 l 778 460 l 778 351 l 56 351 l 56 460 z "},"_":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 0 l 722 0 l 722 -108 l 111 -108 l 111 0 z "},"〜":{"ha":833,"x_min":8,"x_max":825,"o":"m 586 343 q 417 403 511 343 q 247 467 314 467 q 89 353 167 467 l 8 433 q 113 543 49 503 q 247 583 178 583 q 417 524 322 583 q 586 460 519 460 q 744 574 665 460 l 825 493 q 722 383 786 424 q 586 343 657 343 z "},"(":{"ha":833,"x_min":263,"x_max":624,"o":"m 524 -125 q 328 159 394 6 q 263 472 263 313 q 328 785 263 632 q 524 1069 394 939 l 624 1069 q 446 781 506 933 q 386 472 386 629 q 446 163 386 315 q 624 -125 506 11 l 524 -125 z "},")":{"ha":833,"x_min":210,"x_max":571,"o":"m 210 -125 q 388 163 328 11 q 447 472 447 315 q 388 781 447 629 q 210 1069 328 933 l 310 1069 q 505 786 439 939 q 571 472 571 633 q 505 159 571 313 q 310 -125 439 6 l 210 -125 z "},"{":{"ha":833,"x_min":132,"x_max":686,"o":"m 608 -132 q 479 -101 536 -132 q 391 -17 422 -71 q 360 107 360 38 l 360 222 q 307 368 360 324 q 132 411 254 413 l 132 526 q 307 569 254 525 q 360 715 360 614 l 360 831 q 391 954 360 900 q 479 1039 422 1008 q 608 1069 536 1069 l 686 1069 l 686 961 q 519 936 567 965 q 471 833 471 907 l 471 706 q 424 549 471 613 q 301 471 376 486 l 301 469 q 424 390 378 454 q 471 232 471 326 l 471 104 q 519 3 471 32 q 686 -24 567 -26 l 686 -132 l 608 -132 z "},"}":{"ha":833,"x_min":147,"x_max":701,"o":"m 147 -132 l 147 -24 q 315 3 267 -26 q 363 104 363 32 l 363 232 q 409 390 363 326 q 532 469 456 454 l 532 471 q 410 549 457 486 q 363 706 363 613 l 363 833 q 315 936 363 907 q 147 961 267 965 l 147 1069 l 225 1069 q 354 1039 297 1069 q 442 954 411 1008 q 474 831 474 900 l 474 715 q 526 569 474 614 q 701 526 579 525 l 701 411 q 526 368 579 413 q 474 222 474 324 l 474 107 q 442 -17 474 38 q 354 -101 411 -71 q 225 -132 297 -132 l 147 -132 z "},"[":{"ha":833,"x_min":235,"x_max":636,"o":"m 235 -125 l 235 1069 l 636 1069 l 636 961 l 346 961 l 346 -17 l 636 -17 l 636 -125 l 235 -125 z "},"]":{"ha":833,"x_min":197,"x_max":599,"o":"m 197 -17 l 488 -17 l 488 961 l 197 961 l 197 1069 l 599 1069 l 599 -125 l 197 -125 l 197 -17 z "},"〈":{"ha":833,"x_min":238,"x_max":603,"o":"m 238 492 l 492 1140 l 603 1140 l 349 492 l 603 -158 l 492 -158 l 238 492 z "},"〉":{"ha":833,"x_min":231,"x_max":596,"o":"m 231 -158 l 485 492 l 231 1140 l 342 1140 l 596 492 l 342 -158 l 231 -158 z "},"【":{"ha":833,"x_min":257,"x_max":638,"o":"m 257 -87 l 257 1146 l 638 1146 q 450 529 450 849 q 638 -87 450 213 l 257 -87 z "},"】":{"ha":833,"x_min":196,"x_max":576,"o":"m 196 -87 q 383 529 383 213 q 196 1146 383 849 l 576 1146 l 576 -87 l 196 -87 z "},"「":{"ha":833,"x_min":236,"x_max":657,"o":"m 236 1147 l 657 1147 l 657 1039 l 349 1039 l 349 326 l 236 326 l 236 1147 z "},"」":{"ha":833,"x_min":176,"x_max":597,"o":"m 597 -143 l 176 -143 l 176 -35 l 485 -35 l 485 678 l 597 678 l 597 -143 z "},"《":{"ha":833,"x_min":117,"x_max":714,"o":"m 117 492 l 371 1140 l 482 1140 l 228 492 l 482 -158 l 371 -158 l 117 492 m 349 492 l 603 1140 l 714 1140 l 460 492 l 714 -158 l 603 -158 l 349 492 z "},"》":{"ha":833,"x_min":119,"x_max":717,"o":"m 351 -158 l 606 492 l 351 1140 l 463 1140 l 717 492 l 463 -158 l 351 -158 m 119 -158 l 374 492 l 119 1140 l 231 1140 l 485 492 l 231 -158 l 119 -158 z "},"〔":{"ha":833,"x_min":244,"x_max":665,"o":"m 244 111 l 244 871 l 590 1181 l 663 1101 l 356 826 l 356 156 l 665 -124 l 593 -203 l 244 111 z "},"〕":{"ha":833,"x_min":168,"x_max":589,"o":"m 168 -124 l 478 156 l 478 826 l 171 1101 l 243 1181 l 589 871 l 589 111 l 240 -203 l 168 -124 z "},"『":{"ha":833,"x_min":236,"x_max":657,"o":"m 236 1147 l 657 1147 l 657 925 l 458 925 l 458 324 l 236 324 l 236 1147 m 404 379 l 404 981 l 603 981 l 603 1092 l 292 1092 l 292 379 l 404 379 z "},"』":{"ha":833,"x_min":176,"x_max":597,"o":"m 597 -143 l 176 -143 l 176 79 l 375 79 l 375 681 l 597 681 l 597 -143 m 429 625 l 429 24 l 231 24 l 231 -87 l 542 -87 l 542 625 l 429 625 z "},"〖":{"ha":833,"x_min":257,"x_max":625,"o":"m 257 -87 l 257 1146 l 625 1146 q 438 529 438 849 q 625 -87 438 213 l 257 -87 m 528 -32 q 383 529 383 250 q 528 1090 383 810 l 313 1090 l 313 -32 l 528 -32 z "},"〗":{"ha":833,"x_min":208,"x_max":576,"o":"m 208 -87 q 396 529 396 213 q 208 1146 396 849 l 576 1146 l 576 -87 l 208 -87 m 521 -32 l 521 1090 l 306 1090 q 450 529 450 814 q 306 -32 450 247 l 521 -32 z "},"‚":{"ha":833,"x_min":342,"x_max":508,"o":"m 417 0 l 342 0 l 342 167 l 508 167 l 508 19 l 421 -217 l 351 -217 l 417 0 z "},"„":{"ha":833,"x_min":219,"x_max":617,"o":"m 294 0 l 219 0 l 219 167 l 386 167 l 386 19 l 299 -217 l 229 -217 l 294 0 m 525 0 l 450 0 l 450 167 l 617 167 l 617 19 l 529 -217 l 460 -217 l 525 0 z "},"“":{"ha":833,"x_min":217,"x_max":614,"o":"m 539 763 l 614 763 l 614 596 l 447 596 l 447 743 l 535 979 l 604 979 l 539 763 m 308 763 l 383 763 l 383 596 l 217 596 l 217 743 l 304 979 l 374 979 l 308 763 z "},"”":{"ha":833,"x_min":219,"x_max":617,"o":"m 294 826 l 219 826 l 219 993 l 386 993 l 386 846 l 299 610 l 229 610 l 294 826 m 525 826 l 450 826 l 450 993 l 617 993 l 617 846 l 529 610 l 460 610 l 525 826 z "},"‘":{"ha":833,"x_min":324,"x_max":490,"o":"m 415 763 l 490 763 l 490 596 l 324 596 l 324 743 l 411 979 l 481 979 l 415 763 z "},"’":{"ha":833,"x_min":342,"x_max":508,"o":"m 417 826 l 342 826 l 342 993 l 508 993 l 508 846 l 421 610 l 351 610 l 417 826 z "},"«":{"ha":833,"x_min":110,"x_max":724,"o":"m 300 106 l 110 332 l 110 435 l 300 661 l 443 661 l 207 383 l 443 106 l 300 106 m 581 106 l 390 332 l 390 435 l 581 661 l 724 661 l 488 383 l 724 106 l 581 106 z "},"»":{"ha":833,"x_min":110,"x_max":724,"o":"m 626 383 l 390 661 l 533 661 l 724 435 l 724 332 l 533 106 l 390 106 l 626 383 m 346 383 l 110 661 l 253 661 l 443 435 l 443 332 l 253 106 l 110 106 l 346 383 z "},"‹":{"ha":833,"x_min":276,"x_max":610,"o":"m 467 106 l 276 332 l 276 435 l 467 661 l 610 661 l 374 383 l 610 106 l 467 106 z "},"›":{"ha":833,"x_min":224,"x_max":557,"o":"m 460 383 l 224 661 l 367 661 l 557 435 l 557 332 l 367 106 l 224 106 l 460 383 z "},"\"":{"ha":833,"x_min":238,"x_max":596,"o":"m 474 986 l 596 986 l 596 875 l 564 532 l 479 532 l 474 986 m 238 875 l 238 986 l 360 986 l 354 532 l 269 532 l 238 875 z "},"'":{"ha":833,"x_min":356,"x_max":478,"o":"m 356 881 l 356 986 l 478 986 l 478 881 l 458 532 l 375 532 l 356 881 z "},"ƒ":{"ha":833,"x_min":69,"x_max":725,"o":"m 69 0 l 69 103 l 215 103 q 275 122 253 103 q 301 179 297 142 l 351 633 l 172 633 l 172 736 l 363 736 l 369 800 q 578 986 390 986 l 725 986 l 725 883 l 568 883 q 508 863 531 883 q 482 806 486 843 l 474 736 l 725 736 l 725 633 l 463 633 l 413 185 q 204 -1 392 -3 l 69 0 z "},"฿":{"ha":833,"x_min":125,"x_max":772,"o":"m 342 0 l 125 0 l 125 986 l 342 986 l 342 1118 l 446 1118 l 446 985 q 658 905 586 975 q 731 717 731 835 q 682 580 731 636 q 554 508 633 524 q 715 433 657 494 q 772 275 772 371 q 685 75 772 149 q 446 0 597 1 l 446 -126 l 342 -126 l 342 0 m 342 114 l 342 444 l 244 444 l 244 114 l 342 114 m 342 558 l 342 872 l 244 872 l 244 558 l 342 558 m 446 114 q 596 158 544 115 q 647 275 647 200 q 596 399 647 354 q 446 444 544 443 l 446 114 m 446 560 q 606 714 606 574 q 446 871 606 857 l 446 560 z "},"":{"ha":833,"x_min":11,"x_max":822,"o":"m 146 0 q 49 38 88 0 q 11 135 11 76 l 11 676 q 49 773 11 735 q 146 811 88 811 l 688 811 q 784 773 746 811 q 822 676 822 735 l 822 135 q 784 38 822 76 q 688 0 746 0 l 146 0 m 713 172 l 415 688 l 119 172 l 713 172 z "},"@":{"ha":833,"x_min":31,"x_max":803,"o":"m 599 -40 q 381 -97 489 -97 q 203 -40 282 -97 q 77 125 124 17 q 31 382 31 233 q 80 673 31 535 q 228 899 129 811 q 467 986 326 986 q 642 923 565 986 q 760 747 718 860 q 803 489 803 633 q 778 299 803 385 q 708 163 753 213 q 610 114 664 114 q 537 143 568 114 q 492 228 506 172 q 433 143 471 174 q 349 113 396 113 q 236 176 279 113 q 193 342 193 239 q 217 500 193 418 q 290 638 242 582 q 407 693 339 693 q 469 676 442 693 q 507 631 496 660 l 511 681 l 613 681 l 588 361 l 585 301 q 624 203 585 203 q 680 276 658 203 q 701 482 701 350 q 672 692 701 600 q 588 834 642 783 q 464 885 535 885 q 281 812 356 885 q 169 624 206 739 q 132 383 132 508 q 164 173 132 260 q 253 42 196 86 q 385 -1 311 -1 q 557 50 465 -1 l 599 -40 m 381 200 q 447 248 422 200 q 483 358 472 296 q 493 464 493 419 q 474 576 493 535 q 419 617 454 617 q 354 572 381 617 q 315 466 328 526 q 303 358 303 406 q 324 243 303 286 q 381 200 344 200 z "},"&":{"ha":833,"x_min":42,"x_max":819,"o":"m 390 -17 q 137 56 232 -17 q 42 261 42 129 q 64 377 42 331 q 121 456 86 424 q 235 546 156 488 q 142 781 142 669 q 173 899 142 846 q 256 981 204 951 q 365 1010 307 1010 q 478 981 425 1010 q 562 903 531 951 q 593 796 593 854 q 582 728 593 760 q 517 619 564 672 q 400 521 471 565 l 628 231 q 662 322 651 264 q 668 442 672 381 l 790 431 q 766 271 790 349 q 701 143 742 193 l 819 0 l 678 0 l 628 63 q 524 3 586 24 q 390 -17 463 -17 m 390 92 q 483 107 442 92 q 547 150 525 122 l 307 454 q 203 367 243 411 q 163 261 163 322 q 224 138 163 183 q 390 92 286 92 m 331 610 q 438 685 399 640 q 476 776 476 731 q 448 860 476 828 q 371 892 419 892 q 293 858 325 892 q 261 779 261 825 q 331 610 261 706 z "},"¶":{"ha":833,"x_min":58,"x_max":700,"o":"m 396 425 q 149 501 240 425 q 58 706 58 576 q 149 910 58 835 q 396 986 240 986 l 700 986 l 700 0 l 597 0 l 597 881 l 500 881 l 500 0 l 396 0 l 396 425 z "},"§":{"ha":833,"x_min":111,"x_max":722,"o":"m 431 -151 q 276 -119 347 -151 q 162 -31 206 -87 q 111 97 118 25 l 233 106 q 297 -1 243 39 q 431 -40 351 -40 q 550 -12 504 -40 q 596 63 596 15 q 553 135 596 104 q 413 190 510 165 q 197 273 260 226 q 135 399 135 319 q 267 576 135 521 q 139 728 139 631 q 220 888 139 831 q 424 946 301 946 q 551 917 486 946 q 663 835 615 889 q 719 710 710 782 l 600 701 q 540 799 589 764 q 410 835 490 835 q 303 808 346 835 q 261 738 261 781 q 276 689 261 708 q 329 653 292 669 q 438 618 367 636 q 608 561 547 592 q 696 488 669 531 q 722 381 722 446 q 604 229 722 275 q 693 156 668 199 q 718 53 718 113 q 677 -58 718 -12 q 570 -128 636 -104 q 431 -151 504 -151 m 433 292 q 554 318 508 292 q 600 390 600 344 q 539 477 600 449 q 406 506 478 506 q 299 478 342 506 q 257 410 257 451 q 433 292 257 311 z "},"©":{"ha":833,"x_min":6,"x_max":826,"o":"m 415 -17 q 203 49 296 -17 q 58 231 110 114 q 6 496 6 347 q 58 761 6 644 q 203 943 110 878 q 415 1008 296 1008 q 628 943 535 1008 q 774 761 722 878 q 826 496 826 644 q 774 231 826 347 q 628 49 722 114 q 415 -17 535 -17 m 415 79 q 583 130 511 79 q 693 275 654 181 q 732 496 732 369 q 693 717 732 624 q 583 862 654 811 q 415 913 511 913 q 248 862 319 913 q 138 717 176 811 q 100 496 100 624 q 138 275 100 369 q 248 130 176 181 q 415 79 319 79 m 425 181 q 247 266 314 181 q 181 494 181 351 q 211 660 181 589 q 297 771 242 732 q 425 810 351 810 q 528 784 479 810 q 608 710 576 758 q 647 600 640 663 l 551 593 q 509 683 543 651 q 425 714 475 714 q 314 658 351 714 q 276 494 276 601 q 314 333 276 389 q 425 276 351 276 q 511 310 476 276 q 556 408 546 344 l 651 401 q 575 241 639 301 q 425 181 511 181 z "},"®":{"ha":833,"x_min":67,"x_max":767,"o":"m 417 306 q 241 353 321 306 q 114 481 161 400 q 67 658 67 563 q 114 833 67 753 q 241 961 161 914 q 417 1008 321 1008 q 592 961 513 1008 q 719 833 672 914 q 767 658 767 753 q 719 481 767 563 q 592 353 672 400 q 417 306 513 306 m 417 385 q 553 422 490 385 q 652 522 615 458 q 689 658 689 585 q 652 794 689 732 q 553 892 615 856 q 417 929 490 929 q 281 892 343 929 q 181 794 218 856 q 144 658 144 732 q 181 522 144 585 q 281 422 218 458 q 417 385 343 385 m 278 846 l 404 846 q 520 815 478 846 q 563 728 563 783 q 540 660 563 688 q 479 622 518 632 l 578 465 l 497 465 l 408 613 l 349 613 l 349 465 l 278 465 l 278 846 m 415 672 q 473 688 453 672 q 493 731 493 703 q 473 771 493 757 q 415 785 453 785 l 349 785 l 349 672 l 415 672 z "},"℗":{"ha":833,"x_min":67,"x_max":767,"o":"m 417 306 q 241 353 321 306 q 114 481 161 400 q 67 658 67 563 q 114 833 67 753 q 241 961 161 914 q 417 1008 321 1008 q 592 961 513 1008 q 719 833 672 914 q 767 658 767 753 q 719 481 767 563 q 592 353 672 400 q 417 306 513 306 m 417 385 q 553 422 490 385 q 652 522 615 458 q 689 658 689 585 q 652 794 689 732 q 553 892 615 856 q 417 929 490 929 q 281 892 343 929 q 181 794 218 856 q 144 658 144 732 q 181 522 144 585 q 281 422 218 458 q 417 385 343 385 m 299 846 l 425 846 q 540 814 497 846 q 583 728 583 782 q 543 642 583 672 q 428 613 503 613 l 369 613 l 369 465 l 299 465 l 299 846 m 436 672 q 493 688 472 672 q 514 731 514 703 q 493 771 514 757 q 436 785 472 785 l 369 785 l 369 672 l 436 672 z "},"™":{"ha":833,"x_min":24,"x_max":801,"o":"m 457 750 l 457 499 l 375 499 l 375 986 l 464 986 l 588 607 l 714 986 l 801 986 l 801 499 l 719 499 l 719 749 l 635 499 l 542 499 l 457 750 m 138 908 l 24 908 l 24 986 l 335 986 l 335 908 l 219 908 l 219 499 l 138 499 l 138 908 z "},"°":{"ha":833,"x_min":208,"x_max":625,"o":"m 417 542 q 313 570 361 542 q 237 647 265 599 q 208 750 208 694 q 237 853 208 806 q 313 930 265 901 q 417 958 361 958 q 520 930 472 958 q 597 853 568 901 q 625 750 625 806 q 597 647 625 694 q 520 570 568 599 q 417 542 472 542 m 417 643 q 491 674 460 643 q 522 750 522 706 q 491 826 522 794 q 417 857 460 857 q 342 826 374 857 q 311 750 311 794 q 342 674 311 706 q 417 643 374 643 z "},"′":{"ha":833,"x_min":308,"x_max":525,"o":"m 396 1026 l 525 1026 l 400 568 l 308 568 l 396 1026 z "},"″":{"ha":833,"x_min":197,"x_max":638,"o":"m 285 1026 l 414 1026 l 289 568 l 197 568 l 285 1026 m 508 1026 l 638 1026 l 513 568 l 421 568 l 508 1026 z "},"|":{"ha":833,"x_min":360,"x_max":474,"o":"m 360 1069 l 474 1069 l 474 -125 l 360 -125 l 360 1069 z "},"¦":{"ha":833,"x_min":363,"x_max":471,"o":"m 363 986 l 471 986 l 471 493 l 363 493 l 363 986 m 363 285 l 471 285 l 471 -208 l 363 -208 l 363 285 z "},"†":{"ha":833,"x_min":149,"x_max":685,"o":"m 364 633 l 149 633 l 149 736 l 364 736 l 364 986 l 472 986 l 472 736 l 685 736 l 685 633 l 472 633 l 472 0 l 364 0 l 364 633 z "},"‡":{"ha":833,"x_min":149,"x_max":685,"o":"m 363 19 l 149 19 l 149 124 l 363 124 l 363 633 l 149 633 l 149 736 l 363 736 l 363 986 l 471 986 l 471 736 l 685 736 l 685 633 l 471 633 l 471 124 l 685 124 l 685 19 l 471 19 l 471 -208 l 363 -208 l 363 19 z "},"№":{"ha":833,"x_min":7,"x_max":828,"o":"m 7 986 l 167 986 l 335 304 l 335 986 l 451 986 l 451 0 l 297 0 l 124 694 l 124 0 l 7 0 l 7 986 m 517 108 l 806 108 l 806 0 l 517 0 l 517 108 m 661 196 q 541 262 588 196 q 494 436 494 328 q 541 610 494 544 q 661 675 588 675 q 781 610 735 675 q 828 436 828 544 q 781 262 828 328 q 661 196 735 196 m 661 301 q 706 338 689 301 q 724 436 724 375 q 706 533 724 497 q 661 569 689 569 q 617 533 635 569 q 600 436 600 496 q 617 338 600 375 q 661 301 635 301 z "},"␣":{"ha":833,"x_min":53,"x_max":781,"o":"m 53 0 l 163 0 l 163 -156 l 671 -156 l 671 0 l 781 0 l 781 -264 l 53 -264 l 53 0 z "},"␌":{"ha":833,"x_min":110,"x_max":744,"o":"m 461 557 l 744 557 l 744 489 l 531 489 l 531 357 l 732 357 l 732 289 l 531 289 l 531 99 l 461 99 l 461 557 m 110 875 l 393 875 l 393 807 l 181 807 l 181 675 l 381 675 l 381 607 l 181 607 l 181 417 l 110 417 l 110 875 z "},"⌧":{"ha":833,"x_min":19,"x_max":814,"o":"m 19 986 l 814 986 l 814 0 l 19 0 l 19 986 m 718 96 l 718 890 l 115 890 l 115 96 l 718 96 m 214 365 l 340 492 l 213 618 l 290 696 l 418 569 l 543 694 l 619 618 l 494 493 l 621 365 l 543 288 l 417 415 l 290 289 l 214 365 z "},"⌫":{"ha":833,"x_min":6,"x_max":822,"o":"m 6 490 l 219 986 l 822 986 l 822 0 l 219 0 l 6 490 m 726 96 l 726 890 l 285 890 l 111 492 l 285 96 l 726 96 m 251 365 l 378 492 l 250 618 l 328 696 l 454 568 l 581 694 l 656 618 l 531 493 l 657 365 l 581 288 l 453 415 l 328 289 l 251 365 z "},"⌦":{"ha":833,"x_min":11,"x_max":828,"o":"m 11 0 l 11 986 l 614 986 l 828 490 l 614 0 l 11 0 m 722 492 l 549 890 l 107 890 l 107 96 l 549 96 l 722 492 m 381 415 l 253 288 l 176 365 l 303 493 l 178 618 l 253 694 l 379 568 l 506 696 l 583 618 l 457 492 l 582 365 l 506 289 l 381 415 z "},"⏎":{"ha":833,"x_min":6,"x_max":824,"o":"m 6 419 l 419 847 l 419 581 l 501 581 l 501 986 l 824 986 l 824 258 l 419 258 l 419 -11 l 6 419 m 715 367 l 715 878 l 610 878 l 610 472 l 332 472 l 332 608 l 150 418 l 332 229 l 332 367 l 715 367 z "},"␋":{"ha":833,"x_min":72,"x_max":761,"o":"m 72 875 l 146 875 l 250 521 l 354 875 l 428 875 l 288 417 l 213 417 l 72 875 m 558 489 l 426 489 l 426 557 l 761 557 l 761 489 l 629 489 l 629 99 l 558 99 l 558 489 z "},"¢":{"ha":833,"x_min":97,"x_max":750,"o":"m 332 -3 q 160 128 222 31 q 97 368 97 225 q 178 635 97 532 q 397 751 260 738 l 408 874 l 513 874 l 501 747 q 662 667 599 732 q 744 497 725 603 l 622 489 q 574 583 608 546 q 492 635 540 621 l 444 94 q 563 140 514 97 q 628 261 611 182 l 750 253 q 642 56 726 129 q 436 -17 558 -17 l 435 -17 l 425 -126 l 321 -126 l 332 -3 m 219 368 q 251 208 219 274 q 342 114 283 143 l 388 638 q 263 551 307 621 q 219 368 219 481 z "},"¤":{"ha":833,"x_min":78,"x_max":754,"o":"m 415 135 q 258 183 332 135 l 150 75 l 78 147 l 186 256 q 138 413 138 329 q 186 569 138 499 l 78 679 l 150 751 l 260 643 q 415 690 332 690 q 571 642 500 690 l 682 751 l 754 679 l 644 568 q 693 413 693 497 q 646 257 693 329 l 754 147 l 682 75 l 572 183 q 415 135 501 135 m 415 236 q 503 260 463 236 q 567 324 543 283 q 592 413 592 365 q 567 501 592 460 q 503 565 543 542 q 415 589 463 589 q 327 565 368 589 q 263 501 286 542 q 239 413 239 460 q 263 324 239 365 q 327 260 286 283 q 415 236 368 236 z "},"$":{"ha":833,"x_min":65,"x_max":776,"o":"m 369 -17 q 158 92 240 1 q 65 315 76 182 l 190 324 q 249 177 203 233 q 369 103 294 121 l 369 457 q 205 522 265 488 q 117 606 144 557 q 89 728 89 654 q 164 917 89 842 q 369 1006 239 993 l 369 1118 l 472 1118 l 472 1004 q 669 904 594 988 q 758 694 743 821 l 633 686 q 582 817 622 764 q 472 886 542 869 l 472 549 q 651 474 585 515 q 747 378 717 433 q 776 242 776 322 q 692 60 776 132 q 472 -21 608 -11 l 472 -126 l 369 -126 l 369 -17 m 214 733 q 247 643 214 675 q 369 581 279 611 l 369 889 q 256 837 297 879 q 214 733 214 794 m 472 96 q 606 140 560 101 q 651 244 651 179 q 615 347 651 307 q 472 425 578 388 l 472 96 z "},"€":{"ha":833,"x_min":14,"x_max":786,"o":"m 14 643 l 78 643 q 197 913 106 817 q 433 1008 289 1008 q 656 923 564 1008 q 778 685 747 838 l 650 676 q 569 836 626 781 q 433 892 513 892 q 283 828 342 892 q 204 643 225 764 l 529 643 l 514 540 l 193 540 l 192 492 l 193 429 l 501 429 l 486 326 l 207 326 q 288 153 231 213 q 433 94 344 94 q 580 156 521 94 q 660 332 639 217 l 786 325 q 665 70 758 163 q 433 -22 572 -22 q 201 69 292 -22 q 81 326 110 161 l 14 326 l 14 429 l 68 429 l 67 492 l 68 540 l 14 540 l 14 643 z "},"₴":{"ha":833,"x_min":31,"x_max":803,"o":"m 31 643 l 600 643 q 635 732 635 678 q 578 847 635 801 q 432 892 521 892 q 308 849 363 892 q 219 729 253 807 l 89 742 q 209 937 121 865 q 415 1008 297 1008 q 667 933 574 1008 q 760 732 760 858 q 733 643 760 685 l 803 643 l 803 540 l 31 540 l 31 643 m 407 -17 q 231 18 308 -17 q 111 115 154 53 q 68 256 68 176 q 85 326 68 296 l 31 326 l 31 429 l 803 429 l 803 326 l 218 326 q 193 256 193 293 q 251 143 193 186 q 400 100 308 100 q 538 139 476 100 q 628 243 600 178 l 764 232 q 635 50 731 117 q 407 -17 539 -17 z "},"₽":{"ha":833,"x_min":46,"x_max":792,"o":"m 46 272 l 153 272 l 153 986 l 453 986 q 700 907 608 986 q 792 688 792 828 q 700 468 792 547 q 453 389 608 389 l 272 389 l 272 272 l 396 272 l 396 169 l 272 169 l 272 0 l 153 0 l 153 169 l 46 169 l 46 272 m 439 503 q 667 688 667 503 q 439 872 667 872 l 272 872 l 272 503 l 439 503 z "},"₹":{"ha":833,"x_min":51,"x_max":779,"o":"m 51 383 l 51 500 l 321 500 q 539 632 507 500 l 51 632 l 51 736 l 540 736 q 321 869 511 869 l 51 869 l 51 986 l 779 986 l 779 883 l 601 883 q 667 736 654 825 l 779 736 l 779 632 l 665 632 q 558 449 647 514 q 321 383 468 383 l 215 383 l 636 0 l 468 0 l 51 383 z "},"₪":{"ha":833,"x_min":28,"x_max":804,"o":"m 247 771 l 367 771 l 367 115 l 599 115 q 660 138 638 115 q 683 201 683 161 l 683 986 l 804 986 l 804 208 q 748 56 804 113 q 596 0 692 0 l 247 0 l 247 771 m 28 986 l 378 986 q 528 930 472 986 q 585 778 585 874 l 585 215 l 465 215 l 465 785 q 442 847 465 824 q 381 871 419 871 l 149 871 l 149 0 l 28 0 l 28 986 z "},"£":{"ha":833,"x_min":65,"x_max":772,"o":"m 67 121 q 226 386 226 215 l 224 440 l 65 440 l 65 543 l 200 543 l 192 571 q 160 735 160 669 q 199 874 160 811 q 305 972 238 936 q 453 1008 372 1008 q 673 941 592 1008 q 772 778 754 874 l 635 768 q 569 859 617 826 q 458 892 521 892 q 372 869 413 892 q 308 806 332 846 q 283 715 283 765 q 321 543 283 660 l 567 543 l 567 440 l 343 440 q 346 400 346 413 q 302 234 346 308 q 172 121 258 160 l 757 121 l 757 4 l 67 0 l 67 121 z "},"¥":{"ha":833,"x_min":61,"x_max":772,"o":"m 358 199 l 131 199 l 131 307 l 358 307 l 358 399 l 131 399 l 131 507 l 306 507 l 61 986 l 197 986 l 417 542 l 636 986 l 772 986 l 531 507 l 706 507 l 706 399 l 478 399 l 478 307 l 706 307 l 706 199 l 478 199 l 478 0 l 358 0 l 358 199 z "},"+":{"ha":833,"x_min":86,"x_max":747,"o":"m 363 369 l 86 369 l 86 478 l 363 478 l 363 754 l 471 754 l 471 478 l 747 478 l 747 369 l 471 369 l 471 93 l 363 93 l 363 369 z "},"−":{"ha":833,"x_min":86,"x_max":747,"o":"m 86 507 l 747 507 l 747 399 l 86 399 l 86 507 z "},"×":{"ha":833,"x_min":175,"x_max":719,"o":"m 371 389 l 175 583 l 251 660 l 447 464 l 643 660 l 719 583 l 524 389 l 719 193 l 643 117 l 447 313 l 251 117 l 175 193 l 371 389 z "},"÷":{"ha":833,"x_min":86,"x_max":747,"o":"m 86 507 l 747 507 l 747 399 l 86 399 l 86 507 m 417 117 q 358 141 382 117 q 333 200 333 165 q 358 259 333 235 q 417 283 382 283 q 476 259 451 283 q 500 200 500 235 q 476 141 500 165 q 417 117 451 117 m 417 635 q 358 659 382 635 q 333 718 333 683 q 358 777 333 753 q 417 801 382 801 q 476 777 451 801 q 500 718 500 753 q 476 659 500 683 q 417 635 451 635 z "},"=":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 628 l 722 628 l 722 519 l 111 519 l 111 628 m 111 321 l 722 321 l 722 213 l 111 213 l 111 321 z "},"≠":{"ha":833,"x_min":111,"x_max":722,"o":"m 303 213 l 111 213 l 111 321 l 336 321 l 396 519 l 111 519 l 111 628 l 429 628 l 472 768 l 579 768 l 536 628 l 722 628 l 722 519 l 503 519 l 443 321 l 722 321 l 722 213 l 410 213 l 363 60 l 256 60 l 303 213 z "},">":{"ha":833,"x_min":101,"x_max":733,"o":"m 101 193 l 629 429 l 101 665 l 101 785 l 733 503 l 733 356 l 101 74 l 101 193 z "},"<":{"ha":833,"x_min":100,"x_max":732,"o":"m 100 356 l 100 503 l 732 785 l 732 665 l 204 429 l 732 193 l 732 74 l 100 356 z "},"≥":{"ha":833,"x_min":86,"x_max":747,"o":"m 86 364 l 629 508 l 86 651 l 86 765 l 747 582 l 747 435 l 86 251 l 86 364 m 86 108 l 747 108 l 747 0 l 86 0 l 86 108 z "},"≤":{"ha":833,"x_min":86,"x_max":747,"o":"m 86 435 l 86 582 l 747 765 l 747 651 l 204 508 l 747 364 l 747 251 l 86 435 m 86 0 l 86 108 l 747 108 l 747 0 l 86 0 z "},"±":{"ha":833,"x_min":86,"x_max":747,"o":"m 86 158 l 747 158 l 747 50 l 86 50 l 86 158 m 86 568 l 363 568 l 363 750 l 471 750 l 471 568 l 747 568 l 747 460 l 471 460 l 471 278 l 363 278 l 363 460 l 86 460 l 86 568 z "},"≈":{"ha":833,"x_min":110,"x_max":724,"o":"m 543 419 q 457 442 489 419 q 392 506 425 464 q 344 560 363 543 q 294 576 325 576 q 237 538 256 576 q 218 432 218 500 l 110 432 q 159 610 110 542 q 290 679 208 679 q 372 659 340 679 q 435 601 404 639 q 465 563 450 582 q 498 533 481 543 q 539 522 515 522 q 597 560 578 522 q 615 665 615 599 l 724 665 q 674 488 724 557 q 543 419 625 419 m 543 115 q 454 139 486 115 q 388 207 422 163 q 342 256 361 240 q 294 272 322 272 q 237 235 256 272 q 218 129 218 197 l 110 129 q 159 306 110 238 q 290 375 208 375 q 365 360 335 375 q 418 318 394 344 l 444 285 q 489 234 469 250 q 539 218 508 218 q 597 257 578 218 q 615 363 615 296 l 724 363 q 674 185 724 254 q 543 115 625 115 z "},"~":{"ha":833,"x_min":108,"x_max":724,"o":"m 108 328 q 154 514 108 431 q 301 597 200 597 q 388 572 357 597 q 449 501 418 547 q 488 451 472 467 q 531 436 504 436 q 588 479 567 436 q 608 597 608 522 l 724 597 q 678 411 724 494 q 531 328 632 328 q 436 354 468 328 q 375 428 404 381 q 340 474 354 460 q 301 489 325 489 q 244 446 265 489 q 224 328 224 403 l 108 328 z "},"¬":{"ha":833,"x_min":125,"x_max":668,"o":"m 560 369 l 125 369 l 125 478 l 668 478 l 668 132 l 560 132 l 560 369 z "},"^":{"ha":833,"x_min":160,"x_max":674,"o":"m 343 986 l 490 986 l 674 463 l 557 463 l 417 875 l 276 463 l 160 463 l 343 986 z "},"∞":{"ha":833,"x_min":10,"x_max":822,"o":"m 210 160 q 107 189 153 160 q 35 267 61 218 q 10 376 10 317 q 35 485 10 436 q 107 564 61 535 q 210 593 153 593 q 325 555 268 593 q 417 454 382 517 q 510 556 451 519 q 629 592 568 592 q 728 563 683 592 q 797 484 772 533 q 822 376 822 435 q 797 267 822 317 q 726 189 771 218 q 626 160 682 160 q 508 197 567 160 q 417 299 450 233 q 326 197 383 233 q 210 160 268 160 m 210 265 q 296 297 251 265 q 367 376 340 328 q 296 455 340 424 q 210 488 251 486 q 142 456 168 488 q 115 376 115 425 q 142 297 115 328 q 210 265 168 265 m 624 265 q 690 297 664 265 q 717 376 717 328 q 690 455 717 424 q 624 486 664 486 q 540 456 583 486 q 465 376 496 425 q 539 297 494 328 q 624 265 583 265 z "},"∫":{"ha":833,"x_min":153,"x_max":679,"o":"m 153 -207 l 163 -103 l 229 -106 q 289 -86 267 -107 q 315 -28 311 -65 l 406 800 q 469 940 417 893 q 615 986 521 988 l 679 985 l 669 881 l 604 883 q 544 864 567 885 q 518 806 522 843 l 426 -22 q 363 -162 415 -115 q 217 -208 311 -210 l 153 -207 z "},"∆":{"ha":833,"x_min":21,"x_max":813,"o":"m 21 176 l 339 986 l 494 986 l 813 176 l 813 0 l 21 0 l 21 176 m 724 108 l 417 888 l 110 108 l 724 108 z "},"∏":{"ha":833,"x_min":24,"x_max":810,"o":"m 179 878 l 24 878 l 24 986 l 810 986 l 810 878 l 660 878 l 660 -208 l 551 -208 l 551 878 l 288 878 l 288 -208 l 179 -208 l 179 878 z "},"∑":{"ha":833,"x_min":97,"x_max":758,"o":"m 97 117 l 350 493 l 97 869 l 97 986 l 758 986 l 758 869 l 235 869 l 486 493 l 235 117 l 758 117 l 758 0 l 97 0 l 97 117 z "},"√":{"ha":833,"x_min":24,"x_max":811,"o":"m 24 467 l 136 467 l 232 1 l 421 986 l 811 986 l 811 883 l 514 883 l 303 -208 l 164 -208 l 24 467 z "},"∂":{"ha":833,"x_min":54,"x_max":750,"o":"m 369 -17 q 203 34 275 -10 q 93 149 132 78 q 54 310 54 221 q 56 346 54 333 q 155 576 68 493 q 379 660 242 660 q 535 620 468 660 q 638 506 603 581 q 531 806 635 690 q 244 939 426 922 l 272 1046 q 547 947 440 1029 q 702 747 654 865 q 750 492 750 628 q 708 240 750 358 q 597 61 665 121 q 399 -18 501 -18 q 369 -17 379 -18 m 368 90 q 488 118 433 85 q 578 217 543 151 q 618 368 613 282 q 540 500 604 451 q 388 549 476 549 q 244 490 301 549 q 179 336 186 431 q 178 303 178 325 q 229 156 178 213 q 368 90 281 100 z "},"µ":{"ha":833,"x_min":132,"x_max":708,"o":"m 132 736 l 249 736 l 249 297 q 400 86 249 86 q 540 143 489 86 q 590 297 590 200 l 590 736 l 708 736 l 708 0 l 596 0 l 596 122 q 517 21 569 58 q 400 -17 465 -17 q 307 7 347 -17 q 249 71 267 31 l 249 -208 l 132 -208 l 132 736 z "},"%":{"ha":833,"x_min":18,"x_max":815,"o":"m 651 1008 l 783 1008 l 158 -17 l 26 -17 l 651 1008 m 643 0 q 515 67 561 0 q 469 240 469 135 q 515 412 469 344 q 643 479 561 479 q 769 412 724 479 q 815 240 815 344 q 769 68 815 136 q 643 0 724 0 m 643 106 q 694 141 675 106 q 713 240 713 176 q 694 339 713 304 q 643 374 675 374 q 592 340 610 374 q 575 240 575 306 q 592 140 575 175 q 643 106 610 106 m 190 508 q 64 576 110 508 q 18 747 18 643 q 64 919 18 851 q 190 988 110 988 q 318 920 272 988 q 364 747 364 853 q 318 576 364 643 q 190 508 272 508 m 190 614 q 242 649 224 614 q 260 747 260 683 q 242 847 260 813 q 190 882 224 882 q 140 847 158 882 q 122 747 122 811 q 140 649 122 683 q 190 614 158 614 z "},"‰":{"ha":833,"x_min":6,"x_max":829,"o":"m 529 1008 l 651 1008 l 128 -17 l 6 -17 l 529 1008 m 469 0 q 361 62 401 0 q 321 217 321 124 q 361 374 321 311 q 469 436 401 436 q 575 371 538 436 q 681 436 613 436 q 789 374 749 436 q 829 217 829 311 q 789 62 829 124 q 681 0 749 0 q 575 65 613 0 q 469 0 538 0 m 476 96 q 524 127 507 96 q 540 217 540 158 q 524 307 540 275 q 476 339 507 339 q 431 308 447 339 q 415 217 415 276 q 431 127 415 158 q 476 96 447 96 m 672 96 q 719 128 701 96 q 736 217 736 160 q 719 307 736 275 q 672 339 701 339 q 626 308 642 339 q 611 217 611 276 q 626 127 611 158 q 672 96 642 96 m 174 551 q 60 613 101 551 q 18 769 18 675 q 60 926 18 864 q 174 988 101 988 q 290 926 249 988 q 332 769 332 864 q 290 613 332 674 q 174 551 249 551 m 174 647 q 221 679 204 647 q 238 769 238 711 q 221 859 238 828 q 174 890 204 890 q 128 859 144 890 q 113 769 113 828 q 128 679 113 711 q 174 647 144 647 z "},"↑":{"ha":833,"x_min":40,"x_max":793,"o":"m 471 761 l 471 25 l 363 25 l 363 764 l 40 442 l 40 589 l 417 964 l 793 588 l 793 440 l 471 761 z "},"↗":{"ha":833,"x_min":33,"x_max":736,"o":"m 631 804 l 110 283 l 33 360 l 556 882 l 100 882 l 204 986 l 736 986 l 736 453 l 632 349 l 631 804 z "},"→":{"ha":833,"x_min":24,"x_max":824,"o":"m 622 383 l 24 383 l 24 492 l 622 492 l 315 800 l 463 800 l 824 438 l 463 76 l 314 75 l 622 383 z "},"↘":{"ha":833,"x_min":51,"x_max":754,"o":"m 572 106 l 51 626 l 128 703 l 650 181 l 650 636 l 754 532 l 754 0 l 221 0 l 117 104 l 572 106 z "},"↓":{"ha":833,"x_min":40,"x_max":793,"o":"m 793 401 l 417 25 l 40 400 l 40 547 l 363 225 l 363 964 l 471 964 l 471 228 l 793 549 l 793 401 z "},"↙":{"ha":833,"x_min":79,"x_max":782,"o":"m 613 0 l 79 0 l 79 532 l 183 636 l 183 181 l 706 703 l 782 626 l 261 106 l 717 104 l 613 0 z "},"←":{"ha":833,"x_min":10,"x_max":810,"o":"m 371 76 l 10 438 l 371 800 l 518 800 l 211 492 l 810 492 l 810 383 l 211 383 l 519 75 l 371 76 z "},"↖":{"ha":833,"x_min":97,"x_max":800,"o":"m 97 453 l 97 986 l 629 986 l 733 882 l 278 882 l 800 360 l 724 283 l 203 804 l 201 349 l 97 453 z "},"↔":{"ha":833,"x_min":3,"x_max":831,"o":"m 3 476 l 228 701 l 374 701 l 204 531 l 629 531 l 460 701 l 606 701 l 831 476 l 604 249 l 458 249 l 629 422 l 204 422 l 375 249 l 229 249 l 3 476 z "},"↕":{"ha":833,"x_min":178,"x_max":656,"o":"m 178 226 l 178 374 l 364 189 l 364 892 l 178 707 l 178 854 l 418 1093 l 656 856 l 656 708 l 472 892 l 472 189 l 656 372 l 656 225 l 418 -12 l 178 226 z "},"↝":{"ha":833,"x_min":25,"x_max":804,"o":"m 403 257 q 343 313 361 281 q 319 389 325 346 l 315 421 q 301 484 310 461 q 268 521 292 507 q 238 529 254 529 q 182 503 208 529 q 140 431 156 476 l 25 431 q 69 535 35 488 q 150 609 103 582 q 246 636 197 636 q 318 617 283 636 q 382 553 365 589 q 406 464 399 517 q 419 392 410 418 q 454 351 428 367 q 483 343 469 343 q 537 367 510 343 q 589 436 564 392 l 476 503 l 774 640 l 804 311 l 683 382 q 588 274 642 313 q 479 236 535 236 q 403 257 440 236 z "},"⇤":{"ha":833,"x_min":28,"x_max":806,"o":"m 172 436 l 440 660 l 440 490 l 806 490 l 806 382 l 442 382 l 442 213 l 172 436 m 28 707 l 136 707 l 136 165 l 28 165 l 28 707 z "},"⇥":{"ha":833,"x_min":28,"x_max":806,"o":"m 392 382 l 28 382 l 28 490 l 393 490 l 393 660 l 661 436 l 392 213 l 392 382 m 697 165 l 697 707 l 806 707 l 806 165 l 697 165 z "},"↩":{"ha":833,"x_min":24,"x_max":810,"o":"m 226 342 l 403 164 l 256 164 l 24 396 l 253 626 l 401 626 l 224 450 l 504 450 q 603 474 558 450 q 674 540 649 499 q 700 632 700 582 q 674 724 700 682 q 603 791 649 767 q 504 815 558 815 l 94 815 l 94 924 l 504 924 q 658 885 589 924 q 769 779 728 846 q 810 632 810 713 q 769 485 810 551 q 658 380 728 418 q 504 342 589 342 l 226 342 z "},"↪":{"ha":833,"x_min":24,"x_max":810,"o":"m 329 342 q 175 380 244 342 q 65 485 106 418 q 24 632 24 551 q 65 779 24 713 q 175 885 106 846 q 329 924 244 924 l 739 924 l 739 815 l 329 815 q 230 791 275 815 q 159 724 185 767 q 133 632 133 682 q 159 540 133 582 q 230 474 185 499 q 329 450 275 450 l 610 450 l 432 626 l 581 626 l 810 396 l 578 164 l 431 164 l 607 342 l 329 342 z "},"↰":{"ha":833,"x_min":25,"x_max":792,"o":"m 683 564 l 294 564 l 294 394 l 25 618 l 293 842 l 293 672 l 792 672 l 792 0 l 683 0 l 683 564 z "},"↱":{"ha":833,"x_min":42,"x_max":808,"o":"m 42 0 l 42 672 l 540 672 l 540 842 l 808 618 l 539 394 l 539 564 l 150 564 l 150 0 l 42 0 z "},"↳":{"ha":833,"x_min":42,"x_max":808,"o":"m 150 422 l 539 422 l 539 592 l 808 368 l 540 144 l 540 314 l 42 314 l 42 986 l 150 986 l 150 422 z "},"↴":{"ha":833,"x_min":19,"x_max":821,"o":"m 375 269 l 543 269 l 543 876 l 19 876 l 19 985 l 653 985 l 653 268 l 821 268 l 597 0 l 375 269 z "},"↵":{"ha":833,"x_min":25,"x_max":792,"o":"m 792 986 l 792 314 l 293 314 l 293 144 l 25 368 l 294 592 l 294 422 l 683 422 l 683 986 l 792 986 z "},"⇧":{"ha":833,"x_min":3,"x_max":831,"o":"m 418 985 l 831 550 l 639 550 l 639 0 l 197 0 l 197 550 l 3 550 l 418 985 m 306 632 l 306 108 l 531 108 l 531 632 l 607 632 l 418 835 l 228 632 l 306 632 z "},"●":{"ha":833,"x_min":22,"x_max":813,"o":"m 418 110 q 217 159 307 110 q 74 297 126 208 q 22 494 22 385 q 74 694 22 606 q 217 832 126 782 q 418 882 307 882 q 618 832 528 882 q 760 694 708 782 q 813 494 813 606 q 760 297 813 385 q 618 159 708 208 q 418 110 528 110 z "},"○":{"ha":833,"x_min":22,"x_max":813,"o":"m 418 110 q 217 160 307 110 q 74 297 126 210 q 22 494 22 385 q 74 694 22 606 q 217 832 126 782 q 418 882 307 882 q 618 832 528 882 q 760 694 708 782 q 813 494 813 606 q 760 297 813 385 q 618 160 708 210 q 418 110 528 110 m 418 214 q 564 250 499 214 q 667 350 629 286 q 704 494 704 414 q 667 641 704 576 q 564 742 629 706 q 418 778 499 778 q 272 742 338 778 q 168 641 206 706 q 131 494 131 576 q 168 350 131 414 q 272 250 206 286 q 418 214 338 214 z "},"◌":{"ha":833,"x_min":3,"x_max":829,"o":"m 653 265 q 699 319 681 290 l 767 276 q 710 206 744 240 l 653 265 m 197 150 q 125 206 158 174 l 181 263 q 239 221 208 238 l 197 150 m 78 258 q 33 339 53 292 l 108 369 q 143 307 124 332 l 78 258 m 347 97 q 261 119 308 103 l 290 194 q 360 178 329 182 l 347 97 m 13 407 q 3 496 3 446 l 83 496 q 92 424 83 460 l 13 407 m 415 174 q 489 179 460 174 l 504 101 q 415 92 456 92 l 415 174 m 8 567 q 32 653 18 619 l 108 624 q 89 554 96 596 l 8 567 m 544 196 q 608 229 585 213 l 653 161 q 574 121 621 140 l 544 196 m 65 718 q 122 788 90 757 l 178 729 q 133 675 151 703 l 65 718 m 178 833 q 258 874 211 856 l 286 799 q 222 765 254 786 l 178 833 m 725 371 q 744 440 738 403 l 824 428 q 800 340 814 375 l 725 371 m 326 894 q 414 904 364 901 l 415 822 q 342 815 381 822 l 326 894 m 749 499 q 740 571 749 536 l 819 588 q 829 499 829 547 l 749 499 m 544 800 q 474 818 517 811 l 486 899 q 572 875 528 893 l 544 800 m 725 625 q 689 688 708 663 l 757 735 q 799 656 785 692 l 725 625 m 653 732 q 596 775 631 754 l 638 844 q 710 790 679 819 l 653 732 z "},"◊":{"ha":833,"x_min":110,"x_max":722,"o":"m 110 447 l 344 894 l 488 894 l 722 447 l 488 0 l 344 0 l 110 447 m 417 93 l 603 447 l 417 801 l 229 447 l 417 93 z "},"▲":{"ha":833,"x_min":11,"x_max":821,"o":"m 415 701 l 821 0 l 11 0 l 415 701 z "},"▶":{"ha":833,"x_min":-75,"x_max":626,"o":"m 626 497 l -75 92 l -75 901 l 626 497 z "},"▼":{"ha":833,"x_min":11,"x_max":821,"o":"m 821 986 l 415 285 l 11 986 l 821 986 z "},"◀":{"ha":833,"x_min":207,"x_max":908,"o":"m 908 92 l 207 497 l 908 901 l 908 92 z "},"△":{"ha":833,"x_min":11,"x_max":821,"o":"m 415 838 l 821 136 l 11 136 l 415 838 m 644 239 l 415 635 l 188 239 l 644 239 z "},"▷":{"ha":833,"x_min":61,"x_max":763,"o":"m 763 497 l 61 92 l 61 901 l 763 497 m 164 268 l 560 497 l 164 725 l 164 268 z "},"▽":{"ha":833,"x_min":11,"x_max":821,"o":"m 821 861 l 415 160 l 11 861 l 821 861 m 415 363 l 644 758 l 188 758 l 415 363 z "},"◁":{"ha":833,"x_min":71,"x_max":772,"o":"m 772 92 l 71 497 l 772 901 l 772 92 m 274 497 l 669 268 l 669 725 l 274 497 z "},"┌":{"ha":833,"x_min":357,"x_max":833,"o":"m 357 563 l 833 563 l 833 443 l 476 443 l 476 -343 l 357 -343 l 357 563 z "},"─":{"ha":833,"x_min":-56,"x_max":833,"o":"m -56 563 l 833 563 l 833 443 l -56 443 l -56 563 z "},"└":{"ha":833,"x_min":357,"x_max":833,"o":"m 357 1404 l 476 1404 l 476 563 l 833 563 l 833 443 l 357 443 l 357 1404 z "},"│":{"ha":833,"x_min":357,"x_max":476,"o":"m 357 1406 l 476 1406 l 476 -343 l 357 -343 l 357 1406 z "},"├":{"ha":833,"x_min":357,"x_max":833,"o":"m 357 1406 l 476 1406 l 476 563 l 833 563 l 833 443 l 476 443 l 476 -343 l 357 -343 l 357 1406 z "},"̈":{"ha":0,"x_min":222,"x_max":611,"o":"m 222 988 l 344 988 l 344 851 l 222 851 l 222 988 m 489 988 l 611 988 l 611 851 l 489 851 l 489 988 z "},"̇":{"ha":0,"x_min":356,"x_max":478,"o":"m 356 988 l 478 988 l 478 851 l 356 851 l 356 988 z "},"̀":{"ha":0,"x_min":306,"x_max":528,"o":"m 306 1014 l 436 1014 l 528 836 l 431 836 l 306 1014 z "},"́":{"ha":0,"x_min":306,"x_max":528,"o":"m 397 1014 l 528 1014 l 403 836 l 306 836 l 397 1014 z "},"̋":{"ha":0,"x_min":208,"x_max":625,"o":"m 300 1014 l 431 1014 l 306 836 l 208 836 l 300 1014 m 494 1014 l 625 1014 l 500 836 l 403 836 l 494 1014 z "},"̂":{"ha":0,"x_min":222,"x_max":611,"o":"m 351 1015 l 482 1015 l 611 831 l 514 831 l 417 956 l 319 831 l 222 831 l 351 1015 z "},"̌":{"ha":0,"x_min":222,"x_max":611,"o":"m 319 1014 l 417 889 l 514 1014 l 611 1014 l 482 829 l 351 829 l 222 1014 l 319 1014 z "},"̆":{"ha":0,"x_min":222,"x_max":611,"o":"m 417 838 q 279 885 332 838 q 222 1015 226 933 l 303 1015 q 417 924 315 924 q 531 1015 518 924 l 611 1015 q 554 885 607 933 q 417 838 501 838 z "},"̊":{"ha":0,"x_min":289,"x_max":544,"o":"m 417 814 q 326 851 363 814 q 289 942 289 888 q 326 1033 289 996 q 417 1069 363 1069 q 508 1033 471 1069 q 544 942 544 996 q 508 851 544 888 q 417 814 471 814 m 417 883 q 458 900 442 883 q 475 942 475 917 q 458 983 475 967 q 417 1000 442 1000 q 375 983 392 1000 q 358 942 358 967 q 375 900 358 917 q 417 883 392 883 z "},"̃":{"ha":0,"x_min":197,"x_max":636,"o":"m 500 847 q 403 889 454 847 q 363 918 376 911 q 331 925 350 925 q 269 838 274 925 l 197 838 q 235 958 199 915 q 331 1000 272 1000 q 385 989 361 1000 q 436 956 410 978 q 474 930 458 938 q 506 922 489 922 q 546 942 532 922 q 564 1014 560 963 l 636 1014 q 596 891 632 935 q 500 847 560 847 z "},"̄":{"ha":0,"x_min":231,"x_max":603,"o":"m 231 968 l 603 968 l 603 868 l 231 868 l 231 968 z "},"̒":{"ha":0,"x_min":333,"x_max":500,"o":"m 425 949 l 472 949 l 472 824 l 333 824 l 333 943 l 422 1074 l 500 1074 l 425 949 z "},"̦":{"ha":0,"x_min":333,"x_max":500,"o":"m 408 -172 l 361 -172 l 361 -47 l 500 -47 l 500 -167 l 411 -297 l 333 -297 l 408 -172 z "},"̧":{"ha":0,"x_min":268,"x_max":567,"o":"m 431 -306 q 347 -284 383 -306 q 268 -212 311 -262 l 325 -160 q 372 -211 350 -192 q 425 -231 394 -231 q 473 -215 456 -231 q 490 -171 490 -199 q 471 -129 490 -144 q 418 -114 451 -114 q 385 -118 404 -114 l 346 -67 l 419 14 l 479 0 l 432 -51 q 529 -86 492 -51 q 567 -176 567 -121 q 527 -268 567 -231 q 431 -306 488 -306 z "},"̨":{"ha":0,"x_min":294,"x_max":539,"o":"m 419 -292 q 328 -262 361 -292 q 294 -181 294 -232 q 351 -42 294 -112 l 396 14 l 492 0 l 443 -62 q 409 -117 418 -96 q 400 -156 400 -137 q 442 -197 400 -197 q 476 -192 458 -197 q 506 -178 494 -187 l 539 -256 q 490 -282 522 -272 q 419 -292 458 -292 z "},"̵":{"ha":0,"x_min":139,"x_max":694,"o":"m 139 447 l 694 447 l 694 344 l 139 344 l 139 447 z "},"¨":{"ha":833,"x_min":222,"x_max":611,"o":"m 222 988 l 344 988 l 344 851 l 222 851 l 222 988 m 489 988 l 611 988 l 611 851 l 489 851 l 489 988 z "},"˙":{"ha":833,"x_min":356,"x_max":478,"o":"m 356 988 l 478 988 l 478 851 l 356 851 l 356 988 z "},"`":{"ha":833,"x_min":306,"x_max":528,"o":"m 306 1014 l 436 1014 l 528 836 l 431 836 l 306 1014 z "},"´":{"ha":833,"x_min":306,"x_max":528,"o":"m 397 1014 l 528 1014 l 403 836 l 306 836 l 397 1014 z "},"˝":{"ha":833,"x_min":208,"x_max":625,"o":"m 300 1014 l 431 1014 l 306 836 l 208 836 l 300 1014 m 494 1014 l 625 1014 l 500 836 l 403 836 l 494 1014 z "},"ˆ":{"ha":833,"x_min":222,"x_max":611,"o":"m 351 1015 l 482 1015 l 611 831 l 514 831 l 417 956 l 319 831 l 222 831 l 351 1015 z "},"ˇ":{"ha":833,"x_min":222,"x_max":611,"o":"m 319 1014 l 417 889 l 514 1014 l 611 1014 l 482 829 l 351 829 l 222 1014 l 319 1014 z "},"˘":{"ha":833,"x_min":222,"x_max":611,"o":"m 417 838 q 279 885 332 838 q 222 1015 226 933 l 303 1015 q 417 924 315 924 q 531 1015 518 924 l 611 1015 q 554 885 607 933 q 417 838 501 838 z "},"˚":{"ha":833,"x_min":289,"x_max":544,"o":"m 417 814 q 326 851 363 814 q 289 942 289 888 q 326 1033 289 996 q 417 1069 363 1069 q 508 1033 471 1069 q 544 942 544 996 q 508 851 544 888 q 417 814 471 814 m 417 883 q 458 900 442 883 q 475 942 475 917 q 458 983 475 967 q 417 1000 442 1000 q 375 983 392 1000 q 358 942 358 967 q 375 900 358 917 q 417 883 392 883 z "},"˜":{"ha":833,"x_min":197,"x_max":636,"o":"m 500 847 q 403 889 454 847 q 363 918 376 911 q 331 925 350 925 q 269 838 274 925 l 197 838 q 235 958 199 915 q 331 1000 272 1000 q 385 989 361 1000 q 436 956 410 978 q 474 930 458 938 q 506 922 489 922 q 546 942 532 922 q 564 1014 560 963 l 636 1014 q 596 891 632 935 q 500 847 560 847 z "},"¯":{"ha":833,"x_min":231,"x_max":603,"o":"m 231 968 l 603 968 l 603 868 l 231 868 l 231 968 z "},"¸":{"ha":833,"x_min":268,"x_max":567,"o":"m 431 -306 q 347 -284 383 -306 q 268 -212 311 -262 l 325 -160 q 372 -211 350 -192 q 425 -231 394 -231 q 473 -215 456 -231 q 490 -171 490 -199 q 471 -129 490 -144 q 418 -114 451 -114 q 385 -118 404 -114 l 346 -67 l 419 14 l 479 0 l 432 -51 q 529 -86 492 -51 q 567 -176 567 -121 q 527 -268 567 -231 q 431 -306 488 -306 z "},"˛":{"ha":833,"x_min":294,"x_max":539,"o":"m 419 -292 q 328 -262 361 -292 q 294 -181 294 -232 q 351 -42 294 -112 l 396 14 l 492 0 l 443 -62 q 409 -117 418 -96 q 400 -156 400 -137 q 442 -197 400 -197 q 476 -192 458 -197 q 506 -178 494 -187 l 539 -256 q 490 -282 522 -272 q 419 -292 458 -292 z "},"ʼ":{"ha":833,"x_min":335,"x_max":501,"o":"m 410 826 l 335 826 l 335 993 l 501 993 l 501 846 l 414 610 l 344 610 l 410 826 z "},"ʹ":{"ha":833,"x_min":306,"x_max":528,"o":"m 397 1014 l 528 1014 l 403 836 l 306 836 l 397 1014 z "},"ˈ":{"ha":833,"x_min":356,"x_max":478,"o":"m 356 881 l 356 986 l 478 986 l 478 881 l 458 593 l 375 593 l 356 881 z "}},"familyName":"Geist Mono","ascender":1278,"descender":-306,"underlinePosition":-139,"underlineThickness":69,"boundingBox":{"yMin":-343,"xMin":-75,"yMax":1406,"xMax":4081},"resolution":1000,"original_font_information":{"format":0,"fontFamily":"Geist Mono","fontSubfamily":"Regular","uniqueID":"1.200;VRCL;GeistMono-Regular","fullName":"Geist Mono Regular","version":"Version 1.200","postScriptName":"GeistMono-Regular","manufacturer":"Basement.studio, Vercel, Andrés Briganti, Guido Ferreyra, Mateo Zaragoza","designer":"Basement.studio, Andrés Briganti, Mateo Zaragoza","manufacturerURL":"https://basement.studio/","designerURL":"https://basement.studio/","preferredFamily":"Geist Mono","preferredSubfamily":"Regular","unknown1":"No tail a","unknown2":"Alt a","unknown3":"Alt l","unknown4":"Alt R","unknown5":"Alt G","unknown6":"Alt arrows","unknown7":"Rounded dot","unknown8":"Slashed zero"},"cssFontWeight":"normal","cssFontStyle":"normal"} diff --git a/packages/three/src/geometries/label-geometry.ts b/packages/three/src/geometries/label-geometry.ts new file mode 100644 index 000000000..598b9a11b --- /dev/null +++ b/packages/three/src/geometries/label-geometry.ts @@ -0,0 +1,45 @@ +import type { BufferGeometry } from 'three'; +import { FontGeometry } from '#geometries/font-geometry.js'; +import { RoundedRectangleGeometry } from '#geometries/rounded-rectangle-geometry.js'; + +// eslint-disable-next-line @typescript-eslint/naming-convention -- Three.js naming convention +export const LabelTextGeometry = ({ + text, + size = 30, + depth = 2, +}: { + text: string; + size?: number; + depth?: number; +}): BufferGeometry => + // eslint-disable-next-line new-cap -- Three.js geometry function + FontGeometry({ text, size, depth }); + +// eslint-disable-next-line @typescript-eslint/naming-convention -- Three.js naming convention +export const LabelBackgroundGeometry = ({ + text, + characterWidth = 18, + padding = 40, + height = 70, + radius = 20, + depth = 10, +}: { + text: string; + characterWidth?: number; + padding?: number; + height?: number; + radius?: number; + depth?: number; +}): BufferGeometry => { + // Calculate width based on character count (fixed-width mono font) + const width = text.length * characterWidth + padding * 2; + + // eslint-disable-next-line new-cap -- Three.js geometry function + return RoundedRectangleGeometry({ + width, + height, + radius, + smoothness: 16, + depth, + }); +}; diff --git a/packages/three/src/geometries/rounded-rectangle-geometry.ts b/packages/three/src/geometries/rounded-rectangle-geometry.ts new file mode 100644 index 000000000..194ad991d --- /dev/null +++ b/packages/three/src/geometries/rounded-rectangle-geometry.ts @@ -0,0 +1,61 @@ +import type { BufferGeometry } from 'three'; +import { ExtrudeGeometry, Shape, Vector2 } from 'three'; + +// eslint-disable-next-line @typescript-eslint/naming-convention -- Three.js naming convention +export const RoundedRectangleGeometry = ({ + width, + height, + radius, + smoothness, + depth, +}: { + width: number; + height: number; + radius: number; + smoothness: number; + depth: number; +}): BufferGeometry => { + const pi2 = Math.PI * 2; + const n = (smoothness + 1) * 4; // Number of segments + const points: Vector2[] = []; + let qu: number; + let sgx: number; + let sgy: number; + let x: number; + let y: number; + + // Generate contour points + for (let j = 0; j < n; j++) { + qu = Math.trunc((4 * j) / n) + 1; // Quadrant qu: 1..4 + sgx = qu === 1 || qu === 4 ? 1 : -1; // Signum left/right + sgy = qu < 3 ? 1 : -1; // Signum top / bottom + x = sgx * (width / 2 - radius) + radius * Math.cos((pi2 * (j - qu + 1)) / (n - 4)); // Corner center + circle + y = sgy * (height / 2 - radius) + radius * Math.sin((pi2 * (j - qu + 1)) / (n - 4)); + + points.push(new Vector2(x, y)); + } + + // Create shape from points + const shape = new Shape(); + const firstPoint = points[0]; + + if (firstPoint !== undefined) { + shape.moveTo(firstPoint.x, firstPoint.y); + + for (let i = 1; i < points.length; i++) { + const point = points[i]; + + if (point !== undefined) { + shape.lineTo(point.x, point.y); + } + } + + shape.closePath(); + } + + // Extrude the shape + const geometry = new ExtrudeGeometry(shape, { depth, bevelEnabled: false }); + geometry.center(); + + return geometry; +}; diff --git a/packages/three/src/geometries/svg-geometry.ts b/packages/three/src/geometries/svg-geometry.ts new file mode 100644 index 000000000..6b53af6ad --- /dev/null +++ b/packages/three/src/geometries/svg-geometry.ts @@ -0,0 +1,53 @@ +import type { BufferGeometry } from 'three'; +import { Vector2, Shape, ExtrudeGeometry } from 'three'; +import { SVGLoader } from 'three/examples/jsm/Addons.js'; + +// eslint-disable-next-line @typescript-eslint/naming-convention -- Three.js naming convention +export const SvgGeometry = ({ svg, depth }: { svg: string; depth: number }): BufferGeometry => { + let geometry: BufferGeometry; + const loader = new SVGLoader(); + const svgData = loader.parse(svg); + + for (const path of svgData.paths) { + const pts = path.subPaths[0]!.getPoints(10); + pts.pop(); + for (const p of pts) { + p.y *= -1; + } + + pts.reverse(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- SvgLoader adds this + const { strokeWidth } = path.userData!['style']; + + const pPrevious = new Vector2(); + const pNext = new Vector2(); + const c = new Vector2(); + const offsetPts = []; + for (const [idx, p] of pts.entries()) { + let idxPrevious = idx - 1; + if (idxPrevious < 0) { + idxPrevious = pts.length - 1; + } + + let idxNext = idx + 1; + if (idxNext === pts.length) { + idxNext = 0; + } + + pPrevious.subVectors(pts[idxPrevious]!, p).normalize(); + pNext.subVectors(pts[idxNext]!, p).normalize(); + const anglePrevious = pPrevious.angle(); + const angleNext = pNext.angle(); + const angleMid = (angleNext - anglePrevious) * 0.5; + pPrevious.rotateAround(c, angleMid); + const offsetDist = strokeWidth / Math.cos(angleMid - Math.PI * 0.5); + offsetPts.push(pPrevious.clone().setLength(offsetDist).add(p)); + } + + const shape = new Shape(offsetPts); + geometry = new ExtrudeGeometry(shape, { depth, bevelEnabled: false }); + geometry.center(); + } + + return geometry!; +}; diff --git a/packages/three/src/hooks/use-graphics.ts b/packages/three/src/hooks/use-graphics.ts deleted file mode 100644 index 0511494be..000000000 --- a/packages/three/src/hooks/use-graphics.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Default graphics context for standalone package usage. - * Apps can provide their own implementation via module resolution or context. - */ -const defaultGraphicsState = { - context: { - cameraFovAngle: 50, - }, -}; - -/** - * Select a value from the graphics context state. - * In standalone mode returns from default state; apps override via GraphicsProvider. - */ -export function useGraphicsSelector(selector: (state: { context: { cameraFovAngle: number } }) => T): T { - return selector(defaultGraphicsState); -} - -/** No-op actor for standalone package usage. Apps provide their own via GraphicsProvider. */ -const noopCameraCapability = { - send(_event: { type: string; reset?: () => void }) { - // No-op - }, -}; - -/** - * Get the camera capability actor for registering reset handlers. - * In standalone mode returns a no-op; apps provide their own via GraphicsProvider. - */ -export function useCameraCapability(): typeof noopCameraCapability { - return noopCameraCapability; -} diff --git a/packages/three/src/icons/rotate-icon-single.svg b/packages/three/src/icons/rotate-icon-single.svg new file mode 100644 index 000000000..30d8b1065 --- /dev/null +++ b/packages/three/src/icons/rotate-icon-single.svg @@ -0,0 +1,14 @@ + + + + diff --git a/packages/three/src/icons/rotate-icon.svg b/packages/three/src/icons/rotate-icon.svg new file mode 100644 index 000000000..c4b75e2c6 --- /dev/null +++ b/packages/three/src/icons/rotate-icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/three/src/icons/rotation-arrow.svg b/packages/three/src/icons/rotation-arrow.svg new file mode 100644 index 000000000..8231edee2 --- /dev/null +++ b/packages/three/src/icons/rotation-arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/three/src/icons/translation-arrow.svg b/packages/three/src/icons/translation-arrow.svg new file mode 100644 index 000000000..59ec7f5c7 --- /dev/null +++ b/packages/three/src/icons/translation-arrow.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/three/src/materials/gltf-edges.ts b/packages/three/src/materials/gltf-edges.ts new file mode 100644 index 000000000..e5ade1974 --- /dev/null +++ b/packages/three/src/materials/gltf-edges.ts @@ -0,0 +1,380 @@ +import type { GLTF } from 'three/addons/loaders/GLTFLoader.js'; +import type { Group, LineSegments, Vector2 } from 'three'; +import { InterleavedBufferAttribute } from 'three'; +import { LineSegments2, LineSegmentsGeometry, LineMaterial } from 'three/addons'; + +/** + * Default line width in pixels for edge rendering. + * This is screen-space width, not world units. + */ +const defaultLineWidth = 1; + +/** + * Edge color for fat line materials. + * Default: black (matching middleware) + */ +const defaultEdgeColor = 0x00_00_00; + +/** + * Base depth bias factor for edge lines with logarithmic depth buffer. + * + * This multiplicative factor is applied to vFragDepth BEFORE taking log2. + * Due to logarithm properties (log(a*b) = log(a) + log(b)), this produces + * a constant additive offset in log space, which scales correctly with + * scene size and depth precision. + * + * FOV-ADAPTIVE SCALING: + * + * The shader dynamically adjusts this bias based on camera FOV to maintain + * correct occlusion at all FOV values. At low FOV (near-orthographic), the + * camera is far away, and the depth buffer range for geometry becomes + * compressed. A fixed bias would be too aggressive, causing lines to + * incorrectly show through occluding geometry. + * + * The adjustment formula uses: adjustedBias = pow(baseBias, fovScale) + * where fovScale = tan(fov/2) / tan(30°) + * + * Effective values at different FOV: + * - At 60° FOV: adjustedBias = 0.9999 (unchanged, this is the reference) + * - At 6° FOV: adjustedBias ≈ 0.99999 (10x less bias) + * - At 0.6° FOV: adjustedBias ≈ 0.999999 (100x less bias) + * + * IMPORTANT TRADE-OFF (MSAA vs Occlusion): + * + * Writing to gl_FragDepth (required for logarithmic depth buffer) breaks MSAA + * anti-aliasing at the subsample level: + * + * - With subtle bias (0.999): Some subsamples see the line, some don't due to + * z-fighting. This causes partial coverage → lines appear thinner/rougher. + * + * - With aggressive bias (0.99): ALL subsamples agree the line is visible. + * Full coverage → smooth lines. BUT lines may incorrectly show through + * geometry that should occlude them. + * + * This is a fundamental limitation of gl_FragDepth + MSAA. Alternatives: + * - Use `reverseDepthBuffer: true` instead of `logarithmicDepthBuffer: true` + * (avoids gl_FragDepth, restores MSAA, requires EXT_clip_control support) + * - Use post-process AA (FXAA/SMAA) instead of MSAA + * - Accept the trade-off and tune this value for your use case + */ +const depthBiasFactor = 0.999; + +/** + * Extract positions from indexed geometry with InterleavedBufferAttribute. + */ +function extractFromInterleavedIndexed( + positionAttribute: InterleavedBufferAttribute, + indices: Iterable, +): number[] { + const interleavedBuffer = positionAttribute.data; + const { stride } = interleavedBuffer; + const { offset } = positionAttribute; + const { array } = interleavedBuffer; + const positions: number[] = []; + + for (const index of indices) { + const vertexIndex = index * stride + offset; + const x = array[vertexIndex] ?? 0; + const y = array[vertexIndex + 1] ?? 0; + const z = array[vertexIndex + 2] ?? 0; + positions.push(x, y, z); + } + + return positions; +} + +/** + * Extract positions from non-indexed geometry with InterleavedBufferAttribute. + */ +function extractFromInterleavedNonIndexed(positionAttribute: InterleavedBufferAttribute): number[] { + const interleavedBuffer = positionAttribute.data; + const { stride } = interleavedBuffer; + const { offset } = positionAttribute; + const { array } = interleavedBuffer; + const { count } = positionAttribute; + const positions: number[] = []; + + for (let i = 0; i < count; i++) { + const vertexIndex = i * stride + offset; + const x = array[vertexIndex] ?? 0; + const y = array[vertexIndex + 1] ?? 0; + const z = array[vertexIndex + 2] ?? 0; + positions.push(x, y, z); + } + + return positions; +} + +/** + * Extract positions from indexed geometry with regular BufferAttribute. + */ +function extractFromRegularIndexed(array: Float32Array, indices: Iterable): number[] { + const positions: number[] = []; + + for (const index of indices) { + const vertexIndex = index * 3; + const x = array[vertexIndex] ?? 0; + const y = array[vertexIndex + 1] ?? 0; + const z = array[vertexIndex + 2] ?? 0; + positions.push(x, y, z); + } + + return positions; +} + +/** + * Extract positions from a LineSegments geometry, handling both regular and interleaved buffers. + * Expands indexed geometry to non-indexed positions as required by LineSegmentsGeometry. + * + * @param lineSegments - The LineSegments object to extract positions from + * @returns Array of position values [x1, y1, z1, x2, y2, z2, ...] or undefined if extraction fails + */ +function extractPositions(lineSegments: LineSegments): number[] | undefined { + const { geometry } = lineSegments; + const positionAttribute = geometry.attributes['position']; + + if (!positionAttribute) { + console.warn('[FatLines] No position attribute found on LineSegments'); + return undefined; + } + + const indexAttribute = geometry.index; + + // Handle InterleavedBufferAttribute (GLTFLoader optimization) + if (positionAttribute instanceof InterleavedBufferAttribute) { + if (indexAttribute) { + return extractFromInterleavedIndexed(positionAttribute, indexAttribute.array); + } + + return extractFromInterleavedNonIndexed(positionAttribute); + } + + // Regular BufferAttribute + const array = positionAttribute.array as Float32Array; + + if (indexAttribute) { + return extractFromRegularIndexed(array, indexAttribute.array); + } + + // Non-indexed regular buffer - copy directly + return [...array]; +} + +/** + * Create a LineMaterial with FOV-adaptive logarithmic depth bias for edge rendering. + * + * Uses onBeforeCompile to modify the fragment shader's logarithmic depth + * calculation, applying a multiplicative bias to vFragDepth before the log2 + * operation. The bias is dynamically scaled based on camera FOV (derived from + * the projection matrix) to maintain correct occlusion at all FOV values, + * from wide-angle perspective to near-orthographic views. + * + * @param resolution - The viewport resolution for line width calculation + * @returns A configured LineMaterial with FOV-adaptive depth bias + */ +function createEdgeLineMaterial(resolution: Vector2): LineMaterial { + const material = new LineMaterial({ + color: defaultEdgeColor, + linewidth: defaultLineWidth, + worldUnits: false, // Screen-space pixels + resolution: resolution.clone(), + // Keep depth test enabled for proper occlusion + }); + + // Apply depth bias in fragment shader for logarithmic depth buffer. + // This prevents z-fighting between edge lines and co-planar mesh surfaces. + // + // MATHEMATICAL REASONING: + // The logarithmic depth formula is: gl_FragDepth = log2(vFragDepth) * logDepthBufFC * 0.5 + // Our biased version multiplies vFragDepth by a factor < 1.0: + // gl_FragDepth = log2(vFragDepth * depthBias) * logDepthBufFC * 0.5 + // + // Due to logarithm properties: log2(a * b) = log2(a) + log2(b) + // So: log2(vFragDepth * 0.999) = log2(vFragDepth) + log2(0.999) + // + // Since log2(0.999) ≈ -0.00144 is a CONSTANT, this multiplicative approach + // produces a CONSTANT ADDITIVE OFFSET in logarithmic space. + // + // This is ideal because: + // 1. Logarithmic depth distributes precision logarithmically - near objects + // get high precision, far objects get less. + // 2. A constant offset in log space means the bias is always proportional + // to the available precision at that depth. + // 3. It scales automatically with scene size (logDepthBufFC is derived from + // the camera's far plane). + // + // Result: The bias works consistently for any scene size - from tiny precision + // parts to massive architectural models - without needing depth-dependent tuning. + // Create shared uniform object so it can be updated at runtime + const depthBiasUniform = { value: depthBiasFactor }; + + material.onBeforeCompile = (shader) => { + // Add depthBias uniform for runtime adjustment (shared reference) + shader.uniforms['depthBias'] = depthBiasUniform; + + // Add varying to pass FOV scale from vertex to fragment shader. + // projectionMatrix is only available in vertex shader for LineMaterial. + shader.vertexShader = shader.vertexShader.replace( + '#include ', + `#include + varying float vFovScale;`, + ); + + // Calculate FOV scale in vertex shader and pass to fragment + shader.vertexShader = shader.vertexShader.replace( + '#include ', + `#include + // FOV scale for adaptive depth bias + // Check if perspective camera (projectionMatrix[3][3] == 0 for perspective) + if (projectionMatrix[3][3] == 0.0) { + // projectionMatrix[1][1] = 1 / tan(fov/2) for perspective cameras + float tanHalfFov = 1.0 / projectionMatrix[1][1]; + float tanHalfRefFov = 0.57735; // tan(30°) for 60° reference FOV + vFovScale = tanHalfFov / tanHalfRefFov; + } else { + // Orthographic camera - use default scale + vFovScale = 1.0; + }`, + ); + + // Declare the varying and uniform in fragment shader + shader.fragmentShader = shader.fragmentShader.replace( + '#include ', + `#include + uniform float depthBias; + varying float vFovScale;`, + ); + + // Use the varying in the depth calculation + shader.fragmentShader = shader.fragmentShader.replace( + '#include ', + `#if defined( USE_LOGDEPTHBUF ) + // FOV-adaptive depth bias for correct occlusion at all FOV values. + // At low FOV (near-orthographic), camera distance is large and a fixed bias + // becomes too aggressive relative to geometry depth separation. + // vFovScale is calculated in vertex shader from projectionMatrix. + float adjustedBias = pow(depthBias, vFovScale); + + float biasedFragDepth = vFragDepth * adjustedBias; + #if defined( USE_LOGDEPTHBUF_EXT ) + gl_FragDepthEXT = log2( biasedFragDepth ) * logDepthBufFC * 0.5; + #else + gl_FragDepth = log2( biasedFragDepth ) * logDepthBufFC * 0.5; + #endif + #endif`, + ); + }; + + // Store reference to uniform for runtime updates + // To adjust: material.userData['depthBiasUniform'].value = 0.995; + material.userData['depthBiasUniform'] = depthBiasUniform; + + return material; +} + +/** + * Convert a LineSegments object to LineSegments2 for fat line rendering. + * + * @param lineSegments - The LineSegments object to convert + * @param resolution - The viewport resolution for line width calculation + * @returns A LineSegments2 object or undefined if conversion fails + */ +function convertToLineSegments2(lineSegments: LineSegments, resolution: Vector2): LineSegments2 | undefined { + const positions = extractPositions(lineSegments); + + if (!positions || positions.length === 0) { + console.warn('[FatLines] Failed to extract positions from LineSegments'); + return undefined; + } + + // Create LineSegmentsGeometry with expanded positions + const geometry = new LineSegmentsGeometry(); + geometry.setPositions(positions); + + // Create LineMaterial with custom depth bias via onBeforeCompile + const material = createEdgeLineMaterial(resolution); + + const lineSegments2 = new LineSegments2(geometry, material); + + // Copy transforms from original + lineSegments2.position.copy(lineSegments.position); + lineSegments2.rotation.copy(lineSegments.rotation); + lineSegments2.scale.copy(lineSegments.scale); + lineSegments2.quaternion.copy(lineSegments.quaternion); + + // Copy name and userData + lineSegments2.name = lineSegments.name; + lineSegments2.userData = { ...lineSegments.userData }; + + // Render lines after meshes + lineSegments2.renderOrder = 1; + + return lineSegments2; +} + +/** + * Apply fat line segments to a GLTF scene by converting LineSegments to LineSegments2. + * + * This function traverses the GLTF scene, finds all LineSegments objects (created by + * the edge detection middleware), and converts them to LineSegments2 for fat line + * rendering with constant screen-space width. + * + * @param gltf - The GLTF scene to process + * @param resolution - The viewport resolution for line width calculation + */ +export function applyFatLineSegments(gltf: GLTF, resolution: Vector2): void { + // Collect LineSegments for replacement (avoid modifying during traversal) + const replacements: Array<{ + parent: Group; + oldChild: LineSegments; + newChild: LineSegments2; + }> = []; + + gltf.scene.traverse((object) => { + if (object.type === 'LineSegments') { + const lineSegments = object as LineSegments; + const parent = lineSegments.parent as Group | undefined; + + if (parent) { + const lineSegments2 = convertToLineSegments2(lineSegments, resolution); + if (lineSegments2) { + replacements.push({ parent, oldChild: lineSegments, newChild: lineSegments2 }); + } + } + } + }); + + // Perform replacements + for (const { parent, oldChild, newChild } of replacements) { + parent.remove(oldChild); + parent.add(newChild); + + // Dispose old geometry and material + oldChild.geometry.dispose(); + if (Array.isArray(oldChild.material)) { + for (const material of oldChild.material) { + material.dispose(); + } + } else { + oldChild.material.dispose(); + } + } +} + +/** + * Update the resolution of all LineMaterial instances in a scene. + * Call this when the viewport size changes to maintain correct line widths. + * + * @param scene - The scene to update + * @param resolution - The new viewport resolution + */ +export function updateLineMaterialResolution(scene: Group, resolution: Vector2): void { + scene.traverse((object) => { + if (object instanceof LineSegments2) { + const { material } = object; + if ('resolution' in material) { + (material as { resolution: Vector2 }).resolution.copy(resolution); + } + } + }); +} diff --git a/packages/three/src/materials/gltf-matcap.ts b/packages/three/src/materials/gltf-matcap.ts new file mode 100644 index 000000000..9bca7872b --- /dev/null +++ b/packages/three/src/materials/gltf-matcap.ts @@ -0,0 +1,132 @@ +import type { GLTF } from 'three/addons/loaders/GLTFLoader.js'; +import type { Mesh, Material, Scene, Texture } from 'three'; +import { DoubleSide, MeshMatcapMaterial } from 'three'; +import { LineSegments2 } from 'three/addons'; +import { matcapMaterial } from '#materials/matcap-material.js'; + +/** + * Dispose a material or array of materials, releasing GPU resources. + */ +function disposeMaterials(material: Material | Material[]): void { + const materials = Array.isArray(material) ? material : [material]; + for (const mat of materials) { + mat.dispose(); + } +} + +/** + * Apply Three.js matcap to a GLTF scene, respecting vertex colors and material colors. + * + * Note: LineSegments2 extends Mesh but uses LineMaterial for fat line rendering. + * We must exclude LineSegments2 from matcap application to preserve edge rendering. + * + * @param gltf - The GLTF scene to apply matcap to. + */ +export const applyMatcap = async (gltf: GLTF): Promise => { + // Load matcap texture + const matcapTexture = matcapMaterial(); + + gltf.scene.traverse((child) => { + // Skip LineSegments2 - they extend Mesh but use LineMaterial for fat lines + if (child instanceof LineSegments2) { + return; + } + + if ('isMesh' in child && child.isMesh) { + const meshMatcap = new MeshMatcapMaterial({ + matcap: matcapTexture, + side: DoubleSide, + }); + const mesh = child as Mesh; + + const hasVertexColors = Boolean(mesh.geometry.attributes['color'] ?? mesh.geometry.attributes['COLOR_0']); + + if (hasVertexColors) { + meshMatcap.vertexColors = true; + } else { + if ('color' in mesh.material) { + const material = mesh.material as { color: { getHexString(): string } }; + meshMatcap.color.set(`#${material.color.getHexString()}`); + } + + if ('opacity' in mesh.material) { + const material = mesh.material as { opacity: number }; + meshMatcap.opacity = material.opacity; + if (material.opacity < 1) { + meshMatcap.transparent = true; + } + } + } + + // Dispose the old material(s) before replacing to prevent GPU memory leaks + disposeMaterials(mesh.material); + + mesh.material = meshMatcap; + } + }); +}; + +/** + * Apply matcap materials to a cloned scene for screenshot rendering. + * + * Unlike {@link applyMatcap}, this function does **not** dispose the original + * materials because `scene.clone()` creates meshes that share material + * references with the live scene. Disposing them would corrupt the original. + * + * @param scene - The cloned THREE.Scene to apply matcap materials to. + * @param matcapTexture - A fully-loaded matcap texture (use `ensureMatcapTextureLoaded()`). + */ +export function applyMatcapToClonedScene(scene: Scene, matcapTexture: Texture): void { + scene.traverse((child) => { + // Skip LineSegments2 — they extend Mesh but use LineMaterial for fat lines + if (child instanceof LineSegments2) { + return; + } + + if ('isMesh' in child && child.isMesh) { + const mesh = child as Mesh; + const meshMatcap = new MeshMatcapMaterial({ + matcap: matcapTexture, + side: DoubleSide, + }); + + const hasVertexColors = Boolean(mesh.geometry.attributes['color'] ?? mesh.geometry.attributes['COLOR_0']); + + if (hasVertexColors) { + meshMatcap.vertexColors = true; + } else { + if ('color' in mesh.material) { + const material = mesh.material as { color: { getHexString(): string } }; + meshMatcap.color.set(`#${material.color.getHexString()}`); + } + + if ('opacity' in mesh.material) { + const material = mesh.material as { opacity: number }; + meshMatcap.opacity = material.opacity; + if (material.opacity < 1) { + meshMatcap.transparent = true; + } + } + } + + // Do NOT dispose — materials are shared references with the original live scene + mesh.material = meshMatcap; + } + }); +} + +/** + * Dispose matcap materials that were applied to a cloned screenshot scene. + * + * Traverses the scene and disposes each mesh's material. The shared matcap + * texture singleton is unaffected — `Material.dispose()` only releases the + * compiled shader program, not referenced textures. + */ +export function disposeClonedSceneMaterials(scene: Scene): void { + scene.traverse((child) => { + if ('isMesh' in child && child.isMesh) { + const mesh = child as Mesh; + disposeMaterials(mesh.material); + } + }); +} diff --git a/packages/three/src/materials/infinite-grid-material.ts b/packages/three/src/materials/infinite-grid-material.ts new file mode 100644 index 000000000..51b5397e3 --- /dev/null +++ b/packages/three/src/materials/infinite-grid-material.ts @@ -0,0 +1,348 @@ +import * as THREE from 'three'; + +export type InfiniteGridMaterialProperties = { + /** + * The distance between the lines of the small grid. + * Increasing makes the small grid lines more sparse/farther apart. + */ + readonly smallSize?: number; + /** + * The thickness of the lines of the small grid in screen pixels. + * Increasing makes small grid lines thicker and more prominent. + * @default 1.25 + */ + readonly smallThickness?: number; + /** + * The distance between the lines of the large grid. + * Increasing makes large grid lines more sparse/farther apart. + */ + readonly largeSize?: number; + /** + * The thickness of the lines of the large grid in screen pixels. + * Increasing makes large grid lines thicker and more prominent. + * @default 2 + */ + readonly largeThickness?: number; + /** + * The color of the grid. + * Use darker colors for better visibility against light backgrounds. + * Use lighter colors for better visibility against dark backgrounds. + */ + readonly color?: THREE.Color; + /** + * The axes to use for the grid. + * Defines the plane orientation of the grid. + * - 'xyz': Grid on XY plane with Z as normal (standard top-down view) + * - 'xzy': Grid on XZ plane with Y as normal (standard front view) + * - 'zyx': Grid on ZY plane with X as normal (standard side view) + * @default 'xyz' + */ + readonly axes?: 'xyz' | 'xzy' | 'zyx'; + /** + * The base opacity of the grid lines. + * Increasing makes the entire grid more visible/opaque. + * @default 0.3 + */ + readonly lineOpacity?: number; + /** + * Minimum grid distance to ensure visibility. + * Increasing ensures grid is always drawn at least this far from camera. + * @default 10 + */ + readonly minGridDistance?: number; + /** + * Controls how far the grid extends from the camera. + * Increasing extends the grid farther from the camera, creating a larger visible area. + * @default 10 + */ + readonly gridDistanceMultiplier?: number; + /** + * Alpha threshold for fragment discard (transparency cutoff). + * Increasing makes semi-transparent areas of the grid fully transparent. + * @default 0.01 + */ + readonly alphaThreshold?: number; + /** + * The fade start value for grid smoothstep (0-1). Lower values start fading closer to the camera. + * @default 0.05 + */ + readonly fadeStart?: number; + /** + * The fade end value for grid smoothstep (0-1). Higher values end fading further from the camera. + * @default 0.2 + */ + readonly fadeEnd?: number; + /** + * Offset applied to the grid along its normal axis to prevent z-fighting with other geometry. + * Increasing this value pushes the grid further away from the plane. + * @default 0.001 + */ + readonly normalOffset?: number; +}; + +/** + * Maps string-based axes to numeric indices for the shader uniform. + * This provides a user-friendly API while maintaining shader security by avoiding string interpolation. + */ +function mapAxesToIndex(axes: 'xyz' | 'xzy' | 'zyx'): 0 | 1 | 2 { + const mapping = { + xyz: 0, + xzy: 1, + zyx: 2, + } as const; + return mapping[axes]; +} + +// Original Author: Fyrestar https://mevedia.com (https://github.com/Fyrestar/THREE.InfiniteGridHelper) +// Modified by @rifont to: +// - use varying thickness and enhanced distance falloff +// - work correctly with logarithmic depth buffer +// - use secure uniform-based axis configuration instead of string interpolation +export function infiniteGridMaterial(properties?: InfiniteGridMaterialProperties): THREE.ShaderMaterial { + const { + smallSize = 1, + largeSize = 100, + color = new THREE.Color('grey'), + axes = 'xyz', + smallThickness = 1.25, + largeThickness = 2, + lineOpacity = 0.3, + minGridDistance = 10, + gridDistanceMultiplier = 20, + fadeStart = 0.05, + fadeEnd = 0.2, + alphaThreshold = 0.01, + normalOffset = 0.001, + } = properties ?? {}; + + // Validate and convert axes parameter to numeric index + if (!['xyz', 'xzy', 'zyx'].includes(axes)) { + throw new Error('Invalid axes parameter: must be "xyz", "xzy", or "zyx"'); + } + + const axesIndex = mapAxesToIndex(axes); + + const material = new THREE.ShaderMaterial({ + side: THREE.DoubleSide, + transparent: true, + depthWrite: false, + uniforms: { + uSmallSize: { + value: smallSize, + }, + uLargeSize: { + value: largeSize, + }, + uColor: { + value: color, + }, + uSmallThickness: { + value: smallThickness, + }, + uLargeThickness: { + value: largeThickness, + }, + uLineOpacity: { + value: lineOpacity, + }, + uMinGridDistance: { + value: minGridDistance, + }, + uGridDistanceMultiplier: { + value: gridDistanceMultiplier, + }, + uAlphaThreshold: { + value: alphaThreshold, + }, + uFadeStart: { + value: fadeStart, + }, + uFadeEnd: { + value: fadeEnd, + }, + uAxes: { + value: axesIndex, + }, + uNormalOffset: { + value: normalOffset, + }, + }, + + vertexShader: ` + #include + #include + varying vec3 worldPosition; + + uniform float uGridDistanceMultiplier; + uniform float uMinGridDistance; + uniform float uNormalOffset; + uniform int uAxes; + + void main() { + // Calculate the camera distance + float cameraDistance = length(cameraPosition); + + // Calculate grid distance without distance normalization + float gridDistance = cameraDistance * uGridDistanceMultiplier; + + // Always ensure a reasonable minimum distance + gridDistance = max(gridDistance, uMinGridDistance); + + // Scale the grid based on the calculated distance + // Use conditional logic instead of string interpolation for security + vec3 pos; + if (uAxes == 0) { + // xyz: Grid on XY plane with Z as normal + pos = position.xyz * gridDistance; + pos.z -= uNormalOffset; + } else if (uAxes == 1) { + // xzy: Grid on XZ plane with Y as normal + pos = position.xzy * gridDistance; + pos.y -= uNormalOffset; + } else { + // zyx: Grid on ZY plane with X as normal + pos = position.zyx * gridDistance; + pos.x -= uNormalOffset; + } + + worldPosition = pos; + + gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0); + + #include + } + `, + + fragmentShader: ` + #include + #include + + varying vec3 worldPosition; + + uniform float uSmallSize; + uniform float uLargeSize; + uniform float uSmallThickness; + uniform float uLargeThickness; + uniform vec3 uColor; + uniform float uLineOpacity; + uniform float uGridDistanceMultiplier; + uniform float uMinGridDistance; + uniform float uAlphaThreshold; + uniform float uFadeStart; + uniform float uFadeEnd; + uniform int uAxes; + + // Pristine Grid — based on Ben Golus's "The Best Darn Grid Shader (Yet)" + // https://bgolus.medium.com/the-best-darn-grid-shader-yet-727f9278b9d8 + // Adapted for constant-pixel-width lines with phone-wire AA, + // draw width clamping, Moire suppression, and premultiplied alpha blending. + float pristineGrid(vec2 uv, float thickness) { + // Per-axis screen-space derivatives using length() instead of fwidth(). + // fwidth() = abs(dFdx) + abs(dFdy) overestimates on diagonals; + // length() gives the geometrically correct derivative magnitude per axis. + vec4 uvDDXY = vec4(dFdx(uv), dFdy(uv)); + vec2 uvDeriv = vec2(length(uvDDXY.xz), length(uvDDXY.yw)); + + // Convert pixel thickness to UV-space line width (fraction of cell). + // Clamp to [0, 1] since a line cannot be wider than the cell itself. + vec2 targetWidth = clamp(uvDeriv * thickness, 0.0, 1.0); + + // Phone-wire AA + draw width clamping: + // - min = uvDeriv: line is never thinner than 1 screen pixel + // (prevents sub-pixel aliasing; instead lines stay 1px and fade) + // - max = 0.5: ensures correct brightness convergence at the horizon + // (at 0.5, average intensity matches the target, preventing dark gutters) + vec2 drawWidth = clamp(targetWidth, uvDeriv, vec2(0.5)); + + // 1.5px AA border — smoothstep with 1.5 pixel width produces + // a similar perceived sharpness to a 1px linear gradient, but smoother. + vec2 lineAA = max(uvDeriv, 0.000001) * 1.5; + + // Distance to nearest grid line (0 at line center, 0.5 at midpoint) + vec2 gridUV = 1.0 - abs(fract(uv) * 2.0 - 1.0); + + // Smooth antialiased grid lines + vec2 grid2 = smoothstep(drawWidth + lineAA, drawWidth - lineAA, gridUV); + + // Phone-wire AA intensity fade: when lines were expanded beyond their + // target width to stay at minimum 1px, reduce opacity proportionally. + // This creates the illusion of sub-pixel lines fading out gracefully + // rather than aliasing as they recede into the distance. + grid2 *= clamp(targetWidth / drawWidth, 0.0, 1.0); + + // Moire suppression: when grid cells approach sub-pixel size + // (uvDeriv > 0.5), smoothly transition from individual lines to a + // solid average color. This eliminates interference patterns that + // appear when multiple grid cells fall within a single pixel. + grid2 = mix(grid2, targetWidth, clamp(uvDeriv * 2.0 - 1.0, 0.0, 1.0)); + + // Premultiplied alpha blend to combine both axes. + // Equivalent to: grid2.x * (1.0 - grid2.y) + grid2.y + // This correctly composites overlapping transparent lines, + // unlike max() which loses intensity at intersections. + return mix(grid2.x, 1.0, grid2.y); + } + + void main() { + #include + + // Extract plane axes based on configuration + // Use conditional logic instead of string interpolation for security + vec2 worldPlane; + vec2 cameraPlane; + + if (uAxes == 0) { + // xyz: Grid on XY plane + worldPlane = worldPosition.xy; + cameraPlane = cameraPosition.xy; + } else if (uAxes == 1) { + // xzy: Grid on XZ plane + worldPlane = worldPosition.xz; + cameraPlane = cameraPosition.xz; + } else { + // zyx: Grid on ZY plane + worldPlane = worldPosition.zy; + cameraPlane = cameraPosition.zy; + } + + // Calculate planar distance - distance in the grid plane + float planarDistance = distance(cameraPlane, worldPlane); + + // Calculate the camera distance + float cameraDistance = length(cameraPosition); + + // Calculate grid distance with scaling factors + float gridDistance = cameraDistance * uGridDistanceMultiplier; + + // Ensure minimum distance + gridDistance = max(gridDistance, uMinGridDistance); + + // Calculate distance ratio + float distanceRatio = planarDistance / gridDistance; + + // Calculate fade factor using smoothstep for cleaner fade + float fadeFactor = smoothstep(uFadeEnd, uFadeStart, distanceRatio); + + // Compute grid for both scales using Pristine Grid algorithm. + // Each grid gets its own UV space (worldPlane / size) so the + // derivative-based antialiasing is computed per-scale. + float gridSmall = pristineGrid(worldPlane / uSmallSize, uSmallThickness); + float gridLarge = pristineGrid(worldPlane / uLargeSize, uLargeThickness); + + // Combine grids using premultiplied alpha blend (large over small). + // Where large grid lines exist, they take priority; elsewhere the + // small grid shows through. This is equivalent to layered alpha + // compositing and produces correct brightness at intersections. + float grid = mix(gridSmall, 1.0, gridLarge); + + // Apply final color with basic opacity + gl_FragColor = vec4(uColor.rgb, grid * fadeFactor * uLineOpacity); + + // Use a simple alpha threshold + if (gl_FragColor.a < uAlphaThreshold) discard; + } + `, + }); + + return material; +} diff --git a/packages/three/src/materials/matcap-material.ts b/packages/three/src/materials/matcap-material.ts new file mode 100644 index 000000000..5b80d48e8 --- /dev/null +++ b/packages/three/src/materials/matcap-material.ts @@ -0,0 +1,42 @@ +import * as THREE from 'three'; +import { TextureLoader } from 'three'; + +/** + * Cached matcap texture singleton. + * Loaded once and reused across all calls to avoid redundant I/O and GPU uploads. + */ +let cachedMatcapTexture: THREE.Texture | undefined; + +export const matcapMaterial = (): THREE.Texture => { + if (cachedMatcapTexture) { + return cachedMatcapTexture; + } + + const textureLoader = new TextureLoader(); + const matcapTexture = textureLoader.load('/textures/matcap-soft.png'); + matcapTexture.colorSpace = THREE.SRGBColorSpace; + cachedMatcapTexture = matcapTexture; + return matcapTexture; +}; + +/** + * Ensure the matcap texture is fully loaded before use. + * + * The synchronous `matcapMaterial()` returns a texture object immediately but + * loads pixel data asynchronously. This async variant guarantees the texture + * image is available, which is required for offline rendering (screenshots) + * where the GPU must sample real texels on the first draw call. + * + * Uses the same singleton cache — subsequent calls resolve instantly. + */ +export async function ensureMatcapTextureLoaded(): Promise { + if (cachedMatcapTexture?.image) { + return cachedMatcapTexture; + } + + const textureLoader = new TextureLoader(); + const texture = await textureLoader.loadAsync('/textures/matcap-soft.png'); + texture.colorSpace = THREE.SRGBColorSpace; + cachedMatcapTexture = texture; + return texture; +} diff --git a/packages/three/src/materials/striped-material.ts b/packages/three/src/materials/striped-material.ts new file mode 100644 index 000000000..726949b38 --- /dev/null +++ b/packages/three/src/materials/striped-material.ts @@ -0,0 +1,133 @@ +import * as THREE from 'three'; + +type StripedMaterialProperties = { + /** + * The frequency of the stripes (distance between stripes in pixels). + * @default 2 + */ + readonly stripeFrequency?: number; + /** + * The width of each stripe in pixels. + * @default 0.25 + */ + readonly stripeWidth?: number; + /** + * The base color of the material. + * @default 0xffffff (white) + */ + readonly baseColor?: number; + /** + * The color of the stripes. + * @default 0xffffff (white) + */ + readonly stripeColor?: number; + /** + * Stripe angle in radians (screen space). 0 = horizontal, PI/2 = vertical. + * @default Math.PI / 4 (45° diagonal) + */ + readonly stripeAngle?: number; +}; + +/** + * Creates a striped material for cap planes. + * + * Default behavior: diagonal stripes that are locked to the cap plane's + * surface (object space), so they do not slide when the camera moves. + * + * This material uses stencil operations for cross-section capping, ensuring it only + * renders at mesh/plane intersections when used with the Cutter component. + * + * @param stripeFrequency - Distance between stripes in plane units (same units as geometry) + * @param baseColor - Base color of the material + * @param stripeColor - Color of the stripes + * @returns A THREE.ShaderMaterial with striped pattern + */ +export function createStripedMaterial(properties?: StripedMaterialProperties): THREE.ShaderMaterial { + const { + stripeFrequency = 2, + stripeWidth = 0.25, + baseColor = 0xdd_dd_dd, + stripeColor = 0xbb_bb_bb, + stripeAngle = Math.PI / 4, + } = properties ?? {}; + + const stripedMaterial = new THREE.ShaderMaterial({ + side: THREE.DoubleSide, + transparent: true, + stencilWrite: true, + stencilRef: 0, + stencilFunc: THREE.NotEqualStencilFunc, + stencilFail: THREE.ReplaceStencilOp, + // eslint-disable-next-line @typescript-eslint/naming-convention -- Three.js API naming + stencilZFail: THREE.ReplaceStencilOp, + // eslint-disable-next-line @typescript-eslint/naming-convention -- Three.js API naming + stencilZPass: THREE.ReplaceStencilOp, + uniforms: { + uBaseColor: { + value: new THREE.Color(baseColor), + }, + uStripeFrequency: { + value: stripeFrequency, + }, + uStripeColor: { + value: new THREE.Color(stripeColor), + }, + uStripeWidth: { + value: stripeWidth, + }, + uStripeAngle: { + value: stripeAngle, + }, + }, + + vertexShader: ` + #include + #include + + varying vec2 vSurfacePos; // plane-local XY in geometry units + + void main() { + vSurfacePos = position.xy; // lock pattern to the plane surface + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + + #include + } + `, + + fragmentShader: ` + #include + #include + + uniform vec3 uBaseColor; + uniform float uStripeFrequency; + uniform vec3 uStripeColor; + uniform float uStripeWidth; + uniform float uStripeAngle; + + varying vec2 vSurfacePos; + + mat2 rotation2D(float angle) { + float s = sin(angle); + float c = cos(angle); + return mat2(c, -s, s, c); + } + + void main() { + #include + + // Rotate plane-local coordinates so the stripes are anchored to the plane + vec2 rotated = rotation2D(uStripeAngle) * vSurfacePos; + float pattern = mod(rotated.y, uStripeFrequency); + + // Antialiased stripe edge using screen-space derivatives + float aa = fwidth(pattern) * 1.5; + float stripeMask = smoothstep(uStripeWidth - aa, uStripeWidth + aa, pattern); + vec3 finalColor = mix(uStripeColor, uBaseColor, stripeMask); + + gl_FragColor = vec4(finalColor, 1.0); + } + `, + }); + + return stripedMaterial; +} diff --git a/packages/three/src/react/hooks/use-camera-framing.test.ts b/packages/three/src/react/hooks/use-camera-framing.test.ts index e0d74b91c..d57c7e088 100644 --- a/packages/three/src/react/hooks/use-camera-framing.test.ts +++ b/packages/three/src/react/hooks/use-camera-framing.test.ts @@ -17,6 +17,10 @@ vi.mock('@react-three/fiber', () => ({ useThree: () => ({ size: mockSize }), })); +vi.mock('#react/stores/store-context.js', () => ({ + useViewerStore: (selector: (state: { fieldOfView: number }) => unknown) => selector({ fieldOfView: 50 }), +})); + /** * Spy returned by the mocked `useCameraReset`. When invoked, simulates the * real behaviour by committing the current geometry radius via `setSceneRadius`. diff --git a/packages/three/src/react/hooks/use-camera-framing.ts b/packages/three/src/react/hooks/use-camera-framing.ts index fc8e64356..9525904cf 100644 --- a/packages/three/src/react/hooks/use-camera-framing.ts +++ b/packages/three/src/react/hooks/use-camera-framing.ts @@ -2,7 +2,7 @@ import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useThree } from '@react-three/fiber'; import type * as THREE from 'three'; import { useCameraReset } from '#react/use-camera-reset.js'; -import { useGraphicsSelector } from '#hooks/use-graphics.js'; +import { useViewerStore } from '#react/stores/store-context.js'; import type { StageOptions } from '#react/stage.js'; import { defaultStageOptions } from '#react/stage.js'; @@ -25,7 +25,7 @@ export function useCameraFraming( geometryCenter: THREE.Vector3, stageOptions: StageOptions = defaultStageOptions, ): (options?: { enableConfiguredAngles?: boolean }) => void { - const cameraFovAngle = useGraphicsSelector((state) => state.context.cameraFovAngle); + const cameraFovAngle = useViewerStore((state) => state.fieldOfView); // Merge caller options with defaults const { offsetRatio, nearPlane, minimumFarPlane, farPlaneRadiusMultiplier, zoomLevel, rotation } = useMemo( diff --git a/packages/three/src/react/hooks/use-geometry-bounds.ts b/packages/three/src/react/hooks/use-geometry-bounds.ts new file mode 100644 index 000000000..800fceff8 --- /dev/null +++ b/packages/three/src/react/hooks/use-geometry-bounds.ts @@ -0,0 +1,113 @@ +import { useEffect, useRef, useState } from 'react'; +import type { RefObject } from 'react'; +import * as THREE from 'three'; +import { useFrame } from '@react-three/fiber'; +import { useViewerStore } from '#react/stores/store-context.js'; + +const _box3 = new THREE.Box3(); +const _centerPoint = new THREE.Vector3(); +const _sphere = new THREE.Sphere(); + +type GeometryBoundsOptions = { + enableCentering?: boolean; + geometryKey?: string; + onSceneRadiusChange?: (radius: number) => void; +}; + +type GeometryBoundsResult = { + geometryRadius: number; + geometryCenter: THREE.Vector3; +}; + +/** + * Tracks the axis-aligned bounding box of the geometry inside `innerRef`, + * exposes the bounding sphere radius and center as React state, and syncs + * the radius to the viewer store. + * + * Uses `geometryKey` to avoid expensive scene traversals once bounds have + * stabilized -- they are only recomputed when new geometry loads (key change) + * and until the radius converges, then skipped entirely during orbit/pan/zoom. + * + * Optionally applies a centering transform to `outerRef` so the geometry's + * bounding box center sits at the world origin. + */ +export function useGeometryBounds( + // eslint-disable-next-line @typescript-eslint/no-restricted-types -- React refs use null + innerRef: RefObject, + // eslint-disable-next-line @typescript-eslint/no-restricted-types -- React refs use null + outerRef: RefObject, + options: GeometryBoundsOptions = {}, +): GeometryBoundsResult { + const { enableCentering = false, geometryKey, onSceneRadiusChange } = options; + + const setStoreSceneRadius = useViewerStore((state) => state.setSceneRadius); + + const [{ geometryRadius, geometryCenter }, set] = useState<{ + geometryRadius: number; + geometryCenter: THREE.Vector3; + }>({ + geometryRadius: 0, + geometryCenter: new THREE.Vector3(), + }); + + const lastGeometryKeyRef = useRef(undefined); + const boundsStableRef = useRef(false); + + useFrame(() => { + if (!innerRef.current) { + return; + } + + if (geometryKey !== lastGeometryKeyRef.current) { + lastGeometryKeyRef.current = geometryKey; + boundsStableRef.current = false; + } + + if (boundsStableRef.current) { + return; + } + + if (outerRef.current) { + outerRef.current.updateWorldMatrix(true, true); + } + + _box3.setFromObject(innerRef.current); + + if (_box3.isEmpty()) { + return; + } + + _box3.getCenter(_centerPoint); + _box3.getBoundingSphere(_sphere); + + if (enableCentering && outerRef.current) { + outerRef.current.position.set(-_centerPoint.x, -_centerPoint.y, -_centerPoint.z); + } + + const snapshotRadius = _sphere.radius; + const snapshotCenter = _centerPoint.clone(); + + set((previous) => { + const centerChanged = !previous.geometryCenter.equals(snapshotCenter); + + if (previous.geometryRadius === snapshotRadius && !centerChanged) { + boundsStableRef.current = true; + return previous; + } + + return { + geometryRadius: snapshotRadius, + geometryCenter: centerChanged ? snapshotCenter : previous.geometryCenter, + }; + }); + }); + + useEffect(() => { + if (geometryRadius > 0) { + setStoreSceneRadius(geometryRadius); + onSceneRadiusChange?.(geometryRadius); + } + }, [geometryRadius, setStoreSceneRadius, onSceneRadiusChange]); + + return { geometryRadius, geometryCenter }; +} diff --git a/packages/three/src/react/hooks/use-section-view.ts b/packages/three/src/react/hooks/use-section-view.ts new file mode 100644 index 000000000..43da65cf4 --- /dev/null +++ b/packages/three/src/react/hooks/use-section-view.ts @@ -0,0 +1,92 @@ +import { useEffect, useMemo, useRef } from 'react'; +import * as THREE from 'three'; +import { createStripedMaterial } from '#materials/striped-material.js'; +import { useSectionViewStore, useViewerStore } from '#react/stores/store-context.js'; + +export type SectionViewResult = { + readonly plane: THREE.Plane; + readonly cappingMaterial: THREE.ShaderMaterial; + readonly isActive: boolean; + readonly selectedId: string | undefined; + readonly enableLines: boolean; + readonly enableMesh: boolean; +}; + +const defaultPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0); + +/** + * Reads section view state from the zustand stores and computes the derived + * THREE.Plane and capping material. Fully self-contained -- no external refs + * or props required. + * + * The capping material is automatically disposed when its dependencies change + * and on unmount to prevent GPU resource leaks. + */ +export function useSectionView(): SectionViewResult { + const isSectionViewActive = useSectionViewStore((s) => s.isActive); + const selectedSectionViewId = useSectionViewStore((s) => s.selectedPlaneId); + const sectionViewRotation = useSectionViewStore((s) => s.rotation); + const sectionViewDirection = useSectionViewStore((s) => s.direction); + const sectionViewPivot = useSectionViewStore((s) => s.pivot); + const availableSectionViews = useSectionViewStore((s) => s.availableSectionViews); + const enableClippingLines = useSectionViewStore((s) => s.enableLines); + const enableClippingMesh = useSectionViewStore((s) => s.enableMesh); + const gridSizes = useViewerStore((s) => s.gridSizes); + + const plane = useMemo(() => { + if (!selectedSectionViewId) { + return defaultPlane; + } + + const selectedPlane = availableSectionViews.find((p) => p.id === selectedSectionViewId); + if (!selectedPlane) { + return defaultPlane; + } + + const normal = new THREE.Vector3(...selectedPlane.normal); + + const [rotX, rotY, rotZ] = sectionViewRotation; + if (rotX !== 0 || rotY !== 0 || rotZ !== 0) { + const euler = new THREE.Euler(rotX, rotY, rotZ); + normal.applyEuler(euler); + } + + normal.multiplyScalar(-sectionViewDirection); + + const constant = -normal.dot(new THREE.Vector3(...sectionViewPivot)); + + return new THREE.Plane(normal, constant); + }, [selectedSectionViewId, sectionViewPivot, sectionViewRotation, sectionViewDirection, availableSectionViews]); + + const cappingMaterialRef = useRef(undefined); + + const cappingMaterial = useMemo(() => { + cappingMaterialRef.current?.dispose(); + + const stripeSpacing = gridSizes.largeSize * 0.1; + const stripeWidth = stripeSpacing * 0.2; + + const material = createStripedMaterial({ + stripeFrequency: stripeSpacing, + stripeWidth, + }); + + cappingMaterialRef.current = material; + return material; + }, [gridSizes.largeSize]); + + useEffect(() => { + return () => { + cappingMaterialRef.current?.dispose(); + }; + }, []); + + return { + plane, + cappingMaterial, + isActive: Boolean(isSectionViewActive && selectedSectionViewId), + selectedId: selectedSectionViewId, + enableLines: enableClippingLines, + enableMesh: enableClippingMesh, + }; +} diff --git a/packages/three/src/react/index.ts b/packages/three/src/react/index.ts index d1622faad..ab6047f84 100644 --- a/packages/three/src/react/index.ts +++ b/packages/three/src/react/index.ts @@ -14,6 +14,12 @@ export { type UpDirection, } from './section-view-controls.js'; export { TransformControls, type TransformControlsProps } from './transform-controls-drei.js'; +export { Stage, defaultStageOptions, type StageOptions } from './stage.js'; +export { UpDirectionHandler } from './up-direction-handler.js'; +export { useCameraFraming } from './hooks/use-camera-framing.js'; +export { useCameraReset } from './use-camera-reset.js'; +export { useGeometryBounds } from './hooks/use-geometry-bounds.js'; +export { useSectionView, type SectionViewResult } from './hooks/use-section-view.js'; export { PostProcessing } from './post-processing.js'; export { Grid } from './grid.js'; export { ViewportGizmoCube } from './viewport-gizmo.js'; diff --git a/packages/three/src/react/stage.ts b/packages/three/src/react/stage.ts deleted file mode 100644 index 99d4e11fd..000000000 --- a/packages/three/src/react/stage.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** Configuration options for the 3D stage camera and view. */ -export type StageOptions = { - /** - * The ratio of the scene's radius to offset the camera from the center. Adjusting this value will change the applied perspective of the scene. - */ - offsetRatio?: number; - /** - * The near plane of the camera. - */ - nearPlane?: number; - /** - * The minimum far plane of the camera. - */ - minimumFarPlane?: number; - /** - * The multiplier for the camera's far plane. - */ - farPlaneRadiusMultiplier?: number; - /** - * The zoom level of the camera. - */ - zoomLevel?: number; - rotation?: { - /** - * The initial z-axis rotation of the camera in radians. - */ - side?: number; - - /** - * The initial xy-plane rotation of the camera in radians. - */ - vertical?: number; - }; -}; - -// Default configuration constants -export const defaultStageOptions = { - offsetRatio: 2, - nearPlane: 1e-3, - minimumFarPlane: 10_000_000_000, - farPlaneRadiusMultiplier: 5, - zoomLevel: 1, - rotation: { - side: -Math.PI / 4, // Default rotation is 45 degrees counter-clockwise - vertical: Math.PI / 6, // Default rotation is 30 degrees upwards - }, -} as const satisfies StageOptions; diff --git a/packages/three/src/react/stage.tsx b/packages/three/src/react/stage.tsx new file mode 100644 index 000000000..95f7f7610 --- /dev/null +++ b/packages/three/src/react/stage.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import type { ReactNode } from 'react'; +import type * as THREE from 'three'; +import { PerspectiveCamera } from '@react-three/drei'; +import { Lights } from '#react/lights.js'; +import { SectionView } from '#react/section-view.js'; +import { useSectionView } from '#react/hooks/use-section-view.js'; +import { useGeometryBounds } from '#react/hooks/use-geometry-bounds.js'; +import { useCameraFraming } from '#react/hooks/use-camera-framing.js'; +import { useViewerStore } from '#react/stores/store-context.js'; + +/** Configuration options for the 3D stage camera and view. */ +export type StageOptions = { + /** + * The ratio of the scene's radius to offset the camera from the center. Adjusting this value will change the applied perspective of the scene. + */ + offsetRatio?: number; + /** + * The near plane of the camera. + */ + nearPlane?: number; + /** + * The minimum far plane of the camera. + */ + minimumFarPlane?: number; + /** + * The multiplier for the camera's far plane. + */ + farPlaneRadiusMultiplier?: number; + /** + * The zoom level of the camera. + */ + zoomLevel?: number; + rotation?: { + /** + * The initial z-axis rotation of the camera in radians. + */ + side?: number; + + /** + * The initial xy-plane rotation of the camera in radians. + */ + vertical?: number; + }; +}; + +export const defaultStageOptions = { + offsetRatio: 2, + nearPlane: 1e-3, + minimumFarPlane: 10_000_000_000, + farPlaneRadiusMultiplier: 5, + zoomLevel: 1, + rotation: { + side: -Math.PI / 4, + vertical: Math.PI / 6, + }, +} as const satisfies StageOptions; + +type StageProperties = { + readonly children: ReactNode; + readonly enableCentering?: boolean; + readonly stageOptions?: StageOptions; + readonly geometryKey?: string; + readonly onSceneRadiusChange?: (radius: number) => void; +} & Omit, 'id'>; + +export function Stage({ + children, + enableCentering = false, + stageOptions = defaultStageOptions, + geometryKey, + onSceneRadiusChange, + ...properties +}: StageProperties): React.JSX.Element { + const outer = React.useRef(null); + const inner = React.useRef(null); + + const enableMatcap = useViewerStore((s) => s.enableMatcap); + const environmentPreset = useViewerStore((s) => s.environmentPreset); + const upDirection = useViewerStore((s) => s.upDirection); + + const sectionView = useSectionView(); + + const { geometryRadius, geometryCenter } = useGeometryBounds(inner, outer, { + enableCentering, + geometryKey, + onSceneRadiusChange, + }); + + useCameraFraming(geometryRadius, geometryCenter, stageOptions); + + return ( + + + + + {children} + + + + + ); +} diff --git a/packages/three/src/react/up-direction-handler.tsx b/packages/three/src/react/up-direction-handler.tsx new file mode 100644 index 000000000..fbb132f5d --- /dev/null +++ b/packages/three/src/react/up-direction-handler.tsx @@ -0,0 +1,50 @@ +import { useEffect } from 'react'; +import { useThree } from '@react-three/fiber'; +import type { OrbitControls } from 'three/addons'; +import * as THREE from 'three'; + +type UpDirectionHandlerProperties = { + readonly upDirection: 'x' | 'y' | 'z'; + readonly onResetCamera?: () => void; +}; + +/** + * Component that handles dynamic up direction changes for the camera and all scene objects. + * Must be inside the Canvas component to access the Three.js context. + */ +export function UpDirectionHandler({ upDirection, onResetCamera }: UpDirectionHandlerProperties): undefined { + const { camera, scene, controls, invalidate } = useThree(); + + useEffect(() => { + const newUp = + upDirection === 'x' + ? new THREE.Vector3(1, 0, 0) + : upDirection === 'y' + ? new THREE.Vector3(0, 1, 0) + : new THREE.Vector3(0, 0, 1); + + THREE.Object3D.DEFAULT_UP.copy(newUp); + + camera.up.copy(newUp); + + scene.traverse((object) => { + object.up.copy(newUp); + }); + scene.updateMatrixWorld(true); + + camera.lookAt(0, 0, 0); + camera.updateProjectionMatrix(); + + if (controls && 'target' in controls && 'update' in controls) { + const orbitControls = controls as OrbitControls; + orbitControls.target.set(0, 0, 0); + orbitControls.update(); + } + + onResetCamera?.(); + + invalidate(); + }, [upDirection, camera, scene, controls, invalidate, onResetCamera]); + + return undefined; +} diff --git a/packages/three/src/react/use-camera-reset.ts b/packages/three/src/react/use-camera-reset.ts index 9dd228d32..bc21b6d3b 100644 --- a/packages/three/src/react/use-camera-reset.ts +++ b/packages/three/src/react/use-camera-reset.ts @@ -3,9 +3,7 @@ import { useThree } from '@react-three/fiber'; import type { RefObject } from 'react'; import type * as THREE from 'three'; import { resetCamera as resetCameraFn } from '#utils/camera.utils.js'; -import { useCameraCapability } from '#hooks/use-graphics.js'; -// Define the specific types needed for camera reset type ResetRotation = { side: number; vertical: number; @@ -27,29 +25,21 @@ type ResetCameraParameters = { setSceneRadius: (radius: number) => void; originalDistanceReference?: RefObject; cameraFovAngle: number; + onResetCamera?: (reset: () => void) => void; }; /** - * Hook that provides camera reset functionality and registers it with the graphics context + * Hook that provides camera reset functionality. * - * @param parameters - The parameters for the camera reset. - * @returns The reset function. + * Consumers can pass an optional `onResetCamera` callback to register the + * reset function externally (e.g. with a capability actor in the host app). */ export function useCameraReset(parameters: ResetCameraParameters): (options?: { - /** - * Whether to enable configured angles. - * @default true - */ enableConfiguredAngles?: boolean; }) => void { const { camera, controls, invalidate, size } = useThree(); const viewportAspect = size.width > 0 && size.height > 0 ? size.width / size.height : 1; - const cameraCapabilityActor = useCameraCapability(); - const isRegistered = useRef(false); - // Store viewportAspect in a ref so the resetCamera callback remains stable - // during resize. The aspect is read lazily when reset is actually called, - // preventing callback recreation on every resize pixel. const viewportAspectRef = useRef(viewportAspect); viewportAspectRef.current = viewportAspect; @@ -61,11 +51,11 @@ export function useCameraReset(parameters: ResetCameraParameters): (options?: { setSceneRadius, originalDistanceReference, cameraFovAngle, + onResetCamera, } = parameters; const resetCamera = useCallback( (options?: { enableConfiguredAngles?: boolean }) => { - // Reset original distance reference if available if (originalDistanceReference?.current !== undefined) { originalDistanceReference.current = undefined; } @@ -98,14 +88,9 @@ export function useCameraReset(parameters: ResetCameraParameters): (options?: { ], ); - // Register the reset function with the camera capability actor only once useEffect(() => { - if (!isRegistered.current) { - cameraCapabilityActor.send({ type: 'registerReset', reset: resetCamera }); - isRegistered.current = true; - } - }, [resetCamera, cameraCapabilityActor]); + onResetCamera?.(resetCamera); + }, [resetCamera, onResetCamera]); - // Return the reset function for direct use if needed return resetCamera; } diff --git a/packages/three/src/vite-env.d.ts b/packages/three/src/vite-env.d.ts new file mode 100644 index 000000000..5bc4cf710 --- /dev/null +++ b/packages/three/src/vite-env.d.ts @@ -0,0 +1,4 @@ +declare module '*?raw' { + const src: string; + export default src; +} diff --git a/packages/three/tsconfig.build.json b/packages/three/tsconfig.build.json new file mode 100644 index 000000000..da40e74ec --- /dev/null +++ b/packages/three/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "composite": false, + "declarationMap": false + } +} diff --git a/packages/three/tsconfig.json b/packages/three/tsconfig.json new file mode 100644 index 000000000..62ebbd946 --- /dev/null +++ b/packages/three/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/three/tsconfig.lib.json b/packages/three/tsconfig.lib.json new file mode 100644 index 000000000..d8fa518c2 --- /dev/null +++ b/packages/three/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/lib", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "types": ["node"], + "target": "ESNext", + "jsx": "react-jsx", + "paths": { + "#*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/packages/three/tsconfig.spec.json b/packages/three/tsconfig.spec.json new file mode 100644 index 000000000..e1b837ea5 --- /dev/null +++ b/packages/three/tsconfig.spec.json @@ -0,0 +1,25 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": ["node", "vitest/importMeta", "vitest"], + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2024"], + "target": "ESNext", + "jsx": "react-jsx", + "paths": { + "#*": ["./src/*"] + } + }, + "include": [ + "vitest.config.ts", + "tsdown.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test-d.ts", + "src/**/*.d.ts", + "src/**/*.ts", + "src/**/*.tsx" + ] +} diff --git a/packages/three/tsdown.config.ts b/packages/three/tsdown.config.ts new file mode 100644 index 000000000..ebfeae4a3 --- /dev/null +++ b/packages/three/tsdown.config.ts @@ -0,0 +1,39 @@ +import { defineConfig } from 'tsdown'; +import type { Options } from 'tsdown'; + +const baseConfig: Options = { + entry: [ + 'src/index.ts', + 'src/react/index.ts', + ], + sourcemap: false, + clean: true, + dts: true, + minify: true, + tsconfig: 'tsconfig.build.json', + unbundle: true, + external: [ + 'react', + 'react-dom', + 'react/jsx-runtime', + 'three', + '@react-three/fiber', + '@react-three/drei', + '@react-three/postprocessing', + ], +}; + +const cjsConfig: Options = { + ...baseConfig, + format: 'cjs', + outDir: 'dist/cjs', + dts: false, +}; + +const esmConfig: Options = { + ...baseConfig, + format: 'esm', + outDir: 'dist/esm', +}; + +export default defineConfig([esmConfig, cjsConfig]); diff --git a/packages/three/vitest.config.ts b/packages/three/vitest.config.ts new file mode 100644 index 000000000..14ae66fa3 --- /dev/null +++ b/packages/three/vitest.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vitest/config'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- vite type mismatch from pnpm duplicate @types/node resolutions + plugins: [nxViteTsPaths() as any], + test: { + environment: 'node', + typecheck: { + enabled: true, + include: ['**/*.test-d.ts'], + tsconfig: './tsconfig.spec.json', + ignoreSourceErrors: true, + }, + reporters: ['verbose'], + coverage: { + provider: 'v8', + reportsDirectory: '../../coverage/packages/three', + include: ['src/**/*'], + exclude: ['src/**/*.{test,spec}.ts'], + }, + }, +}); From 41565ada530f2104b03fa26e0d9bfd4757acfec3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 25 Feb 2026 10:39:14 +0000 Subject: [PATCH 09/14] fix lint errors in decoupled three package files - Fix prettier formatting in use-camera-reset.ts function signature - Rename enableCentering to isCenteringEnabled in stage.tsx to match boolean prop naming rule - Add useViewerStore mock to camera framing test Co-authored-by: richard --- packages/three/src/react/stage.tsx | 6 +++--- packages/three/src/react/use-camera-reset.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/three/src/react/stage.tsx b/packages/three/src/react/stage.tsx index 95f7f7610..ad217cf3f 100644 --- a/packages/three/src/react/stage.tsx +++ b/packages/three/src/react/stage.tsx @@ -58,7 +58,7 @@ export const defaultStageOptions = { type StageProperties = { readonly children: ReactNode; - readonly enableCentering?: boolean; + readonly isCenteringEnabled?: boolean; readonly stageOptions?: StageOptions; readonly geometryKey?: string; readonly onSceneRadiusChange?: (radius: number) => void; @@ -66,7 +66,7 @@ type StageProperties = { export function Stage({ children, - enableCentering = false, + isCenteringEnabled = false, stageOptions = defaultStageOptions, geometryKey, onSceneRadiusChange, @@ -82,7 +82,7 @@ export function Stage({ const sectionView = useSectionView(); const { geometryRadius, geometryCenter } = useGeometryBounds(inner, outer, { - enableCentering, + enableCentering: isCenteringEnabled, geometryKey, onSceneRadiusChange, }); diff --git a/packages/three/src/react/use-camera-reset.ts b/packages/three/src/react/use-camera-reset.ts index bc21b6d3b..a4a64c744 100644 --- a/packages/three/src/react/use-camera-reset.ts +++ b/packages/three/src/react/use-camera-reset.ts @@ -34,9 +34,9 @@ type ResetCameraParameters = { * Consumers can pass an optional `onResetCamera` callback to register the * reset function externally (e.g. with a capability actor in the host app). */ -export function useCameraReset(parameters: ResetCameraParameters): (options?: { - enableConfiguredAngles?: boolean; -}) => void { +export function useCameraReset( + parameters: ResetCameraParameters, +): (options?: { enableConfiguredAngles?: boolean }) => void { const { camera, controls, invalidate, size } = useThree(); const viewportAspect = size.width > 0 && size.height > 0 ? size.width / size.height : 1; From 3da0b5edb33a65c6fba7fa902454d9a33d78cd15 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 25 Feb 2026 10:51:11 +0000 Subject: [PATCH 10/14] feat(three): decouple MeasureTool and Controls from xstate actor - MeasureTool uses useMeasureStore for reactive reads and measureStore.getState() for imperative actions (start, complete, cancel measurement, toggle pinned, set hovered) - Controls uses useSectionViewStore for section view state/actions and useViewerStore for upDirection - geometryKey accepted as optional prop on MeasureTool (default '') - All imports updated from app-specific paths to package paths - Exports added to react/index.ts Co-authored-by: richard --- packages/three/src/react/controls.tsx | 124 ++++ packages/three/src/react/index.ts | 2 + packages/three/src/react/measure-tool.tsx | 821 ++++++++++++++++++++++ 3 files changed, 947 insertions(+) create mode 100644 packages/three/src/react/controls.tsx create mode 100644 packages/three/src/react/measure-tool.tsx diff --git a/packages/three/src/react/controls.tsx b/packages/three/src/react/controls.tsx new file mode 100644 index 000000000..643fd299d --- /dev/null +++ b/packages/three/src/react/controls.tsx @@ -0,0 +1,124 @@ +/* eslint-disable react/require-default-props -- defaultProps is deprecated; optional props have no defaults */ +import { OrbitControls } from '@react-three/drei'; +import React from 'react'; +import type * as THREE from 'three'; +import { ViewportGizmoCube } from '#react/viewport-gizmo.js'; +import { SectionViewControls } from '#react/section-view-controls.js'; +import { MeasureTool } from '#react/measure-tool.js'; +import type { PlaneSelectorId } from '#react/section-view-controls.js'; +import { useSectionViewStore, useViewerStore } from '#react/stores/store-context.js'; + +type ControlsProperties = { + /** + * @description Whether to enable the gizmo for the viewport. + */ + readonly enableGizmo: boolean; + /** + * @description Whether to enable damping for the camera. + */ + readonly enableDamping: boolean; + /** + * @description Whether to enable zooming for the camera. + */ + readonly enableZoom: boolean; + /** + * @description Whether to enable panning for the camera. + */ + readonly enablePan: boolean; + /** + * @description The speed of the camera zoom. + */ + readonly zoomSpeed: number; + /** + * A container element or selector to append the gizmo to. + */ + readonly gizmoContainer?: HTMLElement | string; + /** + * Key used to invalidate cached meshes when geometry changes. + */ + readonly geometryKey?: string; +}; + +export const Controls = React.memo(function ({ + enableGizmo, + enableDamping, + enableZoom, + enablePan, + zoomSpeed, + gizmoContainer, + geometryKey, +}: ControlsProperties): React.JSX.Element { + const isActive = useSectionViewStore((s) => s.isActive); + const selectedPlaneId = useSectionViewStore((s) => s.selectedPlaneId); + const rotation = useSectionViewStore((s) => s.rotation); + const pivot = useSectionViewStore((s) => s.pivot); + const availablePlanes = useSectionViewStore((s) => s.availableSectionViews); + const planeName = useSectionViewStore((s) => s.planeName); + const hoveredSectionViewId = useSectionViewStore((s) => s.hoveredSectionViewId) as PlaneSelectorId | undefined; + const upDirection = useViewerStore((s) => s.upDirection); + + const selectPlane = useSectionViewStore((s) => s.selectPlane); + const setDirection = useSectionViewStore((s) => s.setDirection); + const setRotation = useSectionViewStore((s) => s.setRotation); + const setPivot = useSectionViewStore((s) => s.setPivot); + const setHoveredSectionView = useSectionViewStore((s) => s.setHoveredSectionView); + + const handleSelectPlane = (planeId: 'xy' | 'xz' | 'yz' | 'yx' | 'zx' | 'zy'): void => { + const id = planeId.toLowerCase() as 'xy' | 'xz' | 'yz' | 'yx' | 'zx' | 'zy'; + const isInverse = id === 'yx' || id === 'zx' || id === 'zy'; + const base: 'xy' | 'xz' | 'yz' = ((): 'xy' | 'xz' | 'yz' => { + if (id === 'xy' || id === 'yx') { + return 'xy'; + } + + if (id === 'xz' || id === 'zx') { + return 'xz'; + } + + return 'yz'; + })(); + const newDir: 1 | -1 = isInverse ? -1 : 1; + selectPlane(base); + setDirection(newDir); + }; + + const handleSetRotation = (eulerRotation: THREE.Euler): void => { + setRotation([eulerRotation.x, eulerRotation.y, eulerRotation.z]); + }; + + const handleSetPivot = (value: [number, number, number]): void => { + setPivot(value); + }; + + const handleHover = (planeId: 'xy' | 'xz' | 'yz' | 'yx' | 'zx' | 'zy' | undefined): void => { + setHoveredSectionView(planeId); + }; + + return ( + <> + + + + {enableGizmo ? : null} + + ); +}); diff --git a/packages/three/src/react/index.ts b/packages/three/src/react/index.ts index ab6047f84..be4ea8d53 100644 --- a/packages/three/src/react/index.ts +++ b/packages/three/src/react/index.ts @@ -23,6 +23,8 @@ export { useSectionView, type SectionViewResult } from './hooks/use-section-view export { PostProcessing } from './post-processing.js'; export { Grid } from './grid.js'; export { ViewportGizmoCube } from './viewport-gizmo.js'; +export { MeasureTool } from './measure-tool.js'; +export { Controls } from './controls.js'; // Zustand stores export { diff --git a/packages/three/src/react/measure-tool.tsx b/packages/three/src/react/measure-tool.tsx new file mode 100644 index 000000000..1ab8c639c --- /dev/null +++ b/packages/three/src/react/measure-tool.tsx @@ -0,0 +1,821 @@ +/* eslint-disable complexity -- Label/line sizing and camera-facing math in a single component */ +import React, { useEffect, useRef, useState, useMemo, useContext } from 'react'; +import * as THREE from 'three'; +import { useFrame, useThree } from '@react-three/fiber'; +import { LabelTextGeometry, LabelBackgroundGeometry } from '#geometries/label-geometry.js'; +import { detectSnapPoints, findClosestSnapPoint } from '#utils/snap-detection.utils.js'; +import type { SnapPoint } from '#utils/snap-detection.utils.js'; +import { computeAxisRotationForCamera } from '#utils/rotation.utils.js'; +import { matcapMaterial } from '#materials/matcap-material.js'; +import { useMeasureStore, CadStoreContext } from '#react/stores/store-context.js'; + +function calculateScaleFromCamera(position: THREE.Vector3, camera: THREE.Camera): number { + const distanceToCamera = camera.position.distanceTo(position); + + let factor: number; + + // Handle orthographic camera + if ('isOrthographicCamera' in camera && camera.isOrthographicCamera) { + const orthoCamera = camera as THREE.OrthographicCamera; + factor = (orthoCamera.top - orthoCamera.bottom) / orthoCamera.zoom; + } else { + // Handle perspective camera with FOV consideration + const perspCamera = camera as THREE.PerspectiveCamera; + factor = distanceToCamera * Math.min((1.9 * Math.tan((Math.PI * perspCamera.fov) / 360)) / perspCamera.zoom, 7); + } + + const size = 1; + return (factor * size) / 4000; +} + +// ── Module-scope scratch objects for useFrame callbacks (avoids per-frame GC pressure) ── + +// SnapPointIndicator scratch +const _snapDirection = new THREE.Vector3(); +const _snapQuaternion = new THREE.Quaternion(); +const _snapUp = new THREE.Vector3(0, 1, 0); + +// MeasurementLine scratch +const _baseQuat = new THREE.Quaternion(); +const _currentNormal = new THREE.Vector3(); +const _axisRotation = new THREE.Quaternion(); +const _finalQuat = new THREE.Quaternion(); +const _flipQuat = new THREE.Quaternion(); +const _labelNormal = new THREE.Vector3(); +const _labelUp = new THREE.Vector3(); +const _cameraUp = new THREE.Vector3(); +const _cameraUpProjected = new THREE.Vector3(); +const _lineDir = new THREE.Vector3(); +const _coneOffset = new THREE.Vector3(); + +type MeasureToolProperties = { + readonly geometryKey?: string; +}; + +/** Interactive measurement tool for Three.js scenes, providing snap-to-vertex/edge measurement with labels. */ +export function MeasureTool({ geometryKey = '' }: MeasureToolProperties): React.JSX.Element { + const { camera, gl, scene } = useThree(); + const stores = useContext(CadStoreContext); + if (!stores) { + throw new Error('MeasureTool must be used within a CadStoreProvider'); + } + + const { measureStore } = stores; + + const measurements = useMeasureStore((s) => s.measurements); + const currentStart = useMeasureStore((s) => s.currentStart); + const snapDistance = useMeasureStore((s) => s.snapDistance); + const lengthFactor = useMeasureStore((s) => s.units.factor); + const lengthSymbol = useMeasureStore((s) => s.units.symbol); + const hoveredMeasurementId = useMeasureStore((s) => s.hoveredMeasurementId); + const isMeasureActive = useMeasureStore((s) => s.isActive); + + const [hoveredSnapPoints, setHoveredSnapPoints] = useState([]); + const [activeSnapPoint, setActiveSnapPoint] = useState(); + const [mousePosition, setMousePosition] = useState(); + const lastSnapPointsRef = useRef(undefined); + + // Refs for values that change rapidly (every mouse move) so the event-listener + // effect doesn't tear down and re-add 4 DOM listeners per mouse event. + const activeSnapPointRef = useRef(activeSnapPoint); + activeSnapPointRef.current = activeSnapPoint; + const mousePositionRef = useRef(mousePosition); + mousePositionRef.current = mousePosition; + const currentStartRef = useRef(currentStart); + currentStartRef.current = currentStart; + + const raycasterRef = useRef(new THREE.Raycaster()); + const mouseRef = useRef(new THREE.Vector2()); + const pointerDownOnMeshRef = useRef(false); + const mouseIsDownRef = useRef(false); + const startCameraQuatRef = useRef(new THREE.Quaternion()); + const startCameraPosRef = useRef(new THREE.Vector3()); + + // Cache mesh list to avoid expensive scene.traverse() on every mouse event. + // Invalidated when geometryKey changes (new geometry loaded/unloaded). + const cachedMeshesRef = useRef([]); + const cachedMeshKeyRef = useRef(undefined); + const sceneRef = useRef(scene); + sceneRef.current = scene; + const geometryKeyRef = useRef(geometryKey); + geometryKeyRef.current = geometryKey; + + // Cache detectSnapPoints results keyed by (mesh.id, faceIndex) to avoid + // running the expensive geometry pipeline on every mouse move over the same face. + const snapCacheRef = useRef(new Map()); + + const getCachedMeshes = useRef((): THREE.Mesh[] => { + const currentKey = geometryKeyRef.current; + if (currentKey === cachedMeshKeyRef.current) { + return cachedMeshesRef.current; + } + + const meshes: THREE.Mesh[] = []; + sceneRef.current.traverse((object) => { + if (object instanceof THREE.Mesh && object.visible && !object.userData['isMeasurementUi']) { + meshes.push(object as THREE.Mesh); + } + }); + cachedMeshesRef.current = meshes; + cachedMeshKeyRef.current = currentKey; + snapCacheRef.current.clear(); + return meshes; + }).current; + + useEffect(() => { + if (!isMeasureActive) { + return undefined; + } + + const handleMouseMove = (event: MouseEvent): void => { + const rect = gl.domElement.getBoundingClientRect(); + mouseRef.current.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; + mouseRef.current.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + + raycasterRef.current.setFromCamera(mouseRef.current, camera); + + const meshes = getCachedMeshes(); + const intersects = raycasterRef.current.intersectObjects(meshes, true); + const firstIntersection = intersects[0]; + + let allSnapPoints: SnapPoint[] = []; + if (firstIntersection?.object) { + const topMesh = firstIntersection.object as THREE.Mesh; + const cacheKey = `${topMesh.id}:${firstIntersection.faceIndex ?? -1}`; + const cached = snapCacheRef.current.get(cacheKey); + if (cached) { + allSnapPoints = cached; + } else { + allSnapPoints = detectSnapPoints(topMesh, raycasterRef.current); + snapCacheRef.current.set(cacheKey, allSnapPoints); + } + + lastSnapPointsRef.current = allSnapPoints; + } else if (lastSnapPointsRef.current?.length) { + allSnapPoints = lastSnapPointsRef.current; + } + + setHoveredSnapPoints(allSnapPoints); + + const closest = findClosestSnapPoint(allSnapPoints, { + mousePos: mouseRef.current, + camera, + canvas: gl.domElement, + snapDistancePx: snapDistance, + snapPointBufferPx: 15, + }); + setActiveSnapPoint(closest); + + if (closest) { + setMousePosition(closest.position); + } else if (firstIntersection) { + setMousePosition(firstIntersection.point); + } else if (lastSnapPointsRef.current?.[0]) { + setMousePosition(lastSnapPointsRef.current[0].position); + } + }; + + const handlePointerDown = (event: MouseEvent): void => { + if (event.button === 0 || event.button === 2) { + startCameraQuatRef.current.copy(camera.quaternion); + startCameraPosRef.current.copy(camera.position); + mouseIsDownRef.current = true; + } + + if (event.button !== 0) { + return; + } + + raycasterRef.current.setFromCamera(mouseRef.current, camera); + + const meshes = getCachedMeshes(); + const intersects = raycasterRef.current.intersectObjects(meshes, true); + pointerDownOnMeshRef.current = intersects.length > 0 || Boolean(activeSnapPointRef.current); + }; + + const handlePointerUp = (event: MouseEvent): void => { + if (event.button === 2) { + if (mouseIsDownRef.current) { + const endQuat = camera.quaternion.clone(); + const endPos = camera.position.clone(); + const dot = Math.abs(startCameraQuatRef.current.dot(endQuat)); + const angle = 2 * Math.acos(Math.min(1, Math.max(-1, dot))); + const rotated = angle > 0.01; + const translated = startCameraPosRef.current.distanceTo(endPos) > 1e-3; + + if (!rotated && !translated && currentStartRef.current) { + measureStore.getState().cancelMeasurement(); + } + } + + pointerDownOnMeshRef.current = false; + mouseIsDownRef.current = false; + return; + } + + if (event.button !== 0) { + return; + } + + if (mouseIsDownRef.current) { + const endQuat = camera.quaternion.clone(); + const endPos = camera.position.clone(); + + const dot = Math.abs(startCameraQuatRef.current.dot(endQuat)); + const angle = 2 * Math.acos(Math.min(1, Math.max(-1, dot))); + const rotated = angle > 0.001; + + const translated = startCameraPosRef.current.distanceTo(endPos) > 1e-3; + + if (rotated || translated) { + pointerDownOnMeshRef.current = false; + mouseIsDownRef.current = false; + return; + } + } + + if (!pointerDownOnMeshRef.current && !activeSnapPointRef.current) { + pointerDownOnMeshRef.current = false; + return; + } + + raycasterRef.current.setFromCamera(mouseRef.current, camera); + + const meshes = getCachedMeshes(); + const intersects = raycasterRef.current.intersectObjects(meshes, true); + if (intersects.length === 0 && !activeSnapPointRef.current) { + pointerDownOnMeshRef.current = false; + return; + } + + const point = activeSnapPointRef.current?.position ?? intersects[0]?.point; + if (!point) { + pointerDownOnMeshRef.current = false; + return; + } + + const pointArray: [number, number, number] = [point.x, point.y, point.z]; + + if (currentStartRef.current) { + const startVec = new THREE.Vector3(...currentStartRef.current); + const endVec = new THREE.Vector3(...pointArray); + const zeroLengthEpsilon = 1e-4; + if (startVec.distanceTo(endVec) <= zeroLengthEpsilon) { + pointerDownOnMeshRef.current = false; + mouseIsDownRef.current = false; + return; + } + + measureStore.getState().completeMeasurement(pointArray); + } else { + measureStore.getState().startMeasurement(pointArray); + } + + pointerDownOnMeshRef.current = false; + mouseIsDownRef.current = false; + }; + + const handleContextMenu = (event: MouseEvent): void => { + event.preventDefault(); + }; + + gl.domElement.addEventListener('mousemove', handleMouseMove); + gl.domElement.addEventListener('pointerdown', handlePointerDown); + gl.domElement.addEventListener('pointerup', handlePointerUp); + gl.domElement.addEventListener('contextmenu', handleContextMenu); + + return () => { + gl.domElement.removeEventListener('mousemove', handleMouseMove); + gl.domElement.removeEventListener('pointerdown', handlePointerDown); + gl.domElement.removeEventListener('pointerup', handlePointerUp); + gl.domElement.removeEventListener('contextmenu', handleContextMenu); + }; + }, [camera, gl, scene, snapDistance, isMeasureActive, measureStore, getCachedMeshes]); + + const visibleMeasurements = isMeasureActive ? measurements : measurements.filter((m) => m.isPinned); + + const currentStartVec3 = useMemo( + () => (currentStart ? new THREE.Vector3(...currentStart) : undefined), + [currentStart], + ); + + return ( + + {isMeasureActive + ? hoveredSnapPoints.map((snapPoint) => { + const key = `snap-${snapPoint.position.x}-${snapPoint.position.y}-${snapPoint.position.z}`; + return ( + + ); + }) + : null} + + {isMeasureActive && currentStartVec3 ? ( + + ) : null} + + {isMeasureActive && currentStartVec3 && mousePosition ? ( + + ) : null} + + {visibleMeasurements.map((measurement) => ( + + ))} + + ); +} + +type SnapPointIndicatorProps = { + readonly position: THREE.Vector3; + readonly isActive: boolean; + readonly camera: THREE.Camera; +}; + +function SnapPointIndicator({ position, isActive, camera }: SnapPointIndicatorProps): React.JSX.Element { + const outerRef = useRef(null); + const innerRef = useRef(null); + + const borderSize = isActive ? 0.05 : 0.04; + const innerSize = isActive ? 0.04 : 0.03; + const height = 0.05; + const segments = 32; + + useFrame(() => { + const scale = calculateScaleFromCamera(position, camera); + + _snapDirection.subVectors(camera.position, position).normalize(); + _snapQuaternion.setFromUnitVectors(_snapUp.set(0, 1, 0), _snapDirection); + + if (outerRef.current) { + outerRef.current.quaternion.copy(_snapQuaternion); + outerRef.current.scale.set(scale * 500, scale * 500, scale * 500); + } + + if (innerRef.current) { + innerRef.current.quaternion.copy(_snapQuaternion); + innerRef.current.scale.set(scale * 500, scale * 500, scale * 500); + } + }); + + return ( + + + + + + + + + + + ); +} + +type MeasurementLineProps = { + readonly id?: string; + readonly start: THREE.Vector3 | readonly [number, number, number]; + readonly end: THREE.Vector3 | readonly [number, number, number]; + readonly distance?: number; + readonly lengthFactor?: number; + readonly lengthSymbol?: string; + readonly isPreview?: boolean; + readonly isExternallyHovered?: boolean; + readonly isPinned?: boolean; + readonly coneHeight?: number; + readonly coneRadius?: number; + readonly cylinderRadius?: number; + readonly textSize?: number; + readonly textDepth?: number; + readonly labelHeight?: number; + readonly labelPadding?: number; + readonly labelCornerRadius?: number; + readonly labelDepth?: number; + readonly labelCharWidth?: number; + readonly decimals?: number; + // eslint-disable-next-line react/boolean-prop-naming -- Three.js component API convention + readonly enableUnits?: boolean; + readonly materials?: + | { + readonly backgroundMaterial: THREE.Material; + readonly textMaterial: THREE.Material; + readonly coneMaterial: THREE.Material; + } + | { + readonly backgroundColor: THREE.Color; + readonly textColor: THREE.Color; + readonly coneColor: THREE.Color; + }; +}; + +function MeasurementLine({ + id, + start, + end, + distance, + lengthFactor = 1, + lengthSymbol = 'mm', + isPreview = false, + isExternallyHovered = false, + isPinned = false, + coneHeight = 80, + coneRadius = 10, + cylinderRadius = 2, + textSize = 40, + textDepth = 2, + labelHeight = 80, + labelPadding = 50, + labelCornerRadius = 20, + labelDepth = 1, + labelCharWidth = 24, + decimals = 1, + enableUnits = true, + materials, +}: MeasurementLineProps): React.JSX.Element { + const { camera } = useThree(); + const stores = useContext(CadStoreContext); + if (!stores) { + throw new Error('MeasurementLine must be used within a CadStoreProvider'); + } + + const { measureStore } = stores; + + const startVec = useMemo(() => (start instanceof THREE.Vector3 ? start : new THREE.Vector3(...start)), [start]); + const endVec = useMemo(() => (end instanceof THREE.Vector3 ? end : new THREE.Vector3(...end)), [end]); + + const labelGroupRef = useRef(null); + const lineGroupRef = useRef(null); + const cylinderMeshRef = useRef(null); + const startConeMeshRef = useRef(null); + const endConeMeshRef = useRef(null); + const [isLabelHovered, setIsLabelHovered] = useState(false); + const isHovered = isLabelHovered || isExternallyHovered; + + const derivedMaterials = useMemo(() => { + if (materials && 'backgroundMaterial' in materials && 'textMaterial' in materials && 'coneMaterial' in materials) { + return { + backgroundMaterial: materials.backgroundMaterial, + textMaterial: materials.textMaterial, + coneMaterial: materials.coneMaterial, + }; + } + + const matcapTexture = matcapMaterial(); + + const baseMaterial = new THREE.MeshMatcapMaterial({ + matcap: matcapTexture, + depthTest: false, + depthWrite: false, + transparent: true, + side: THREE.DoubleSide, + fog: false, + toneMapped: false, + }); + const basicMaterial = new THREE.MeshBasicMaterial({ + color: materials?.backgroundColor ?? 0xff_ff_ff, + depthTest: false, + depthWrite: false, + transparent: true, + side: THREE.DoubleSide, + fog: false, + toneMapped: false, + }); + + const backgroundMaterial = basicMaterial.clone(); + backgroundMaterial.color.set(materials?.backgroundColor ?? 0xff_ff_ff); + + const textMaterial = baseMaterial.clone(); + textMaterial.color.set(materials?.textColor ?? 0x00_00_00); + + const coneMaterial = baseMaterial.clone(); + coneMaterial.color.set(materials?.coneColor ?? 0x00_00_00); + + return { backgroundMaterial, textMaterial, coneMaterial }; + }, [materials]); + + const pinMatcapTexture = useMemo(() => matcapMaterial(), []); + + useEffect(() => { + if (materials && 'coneMaterial' in materials) { + return; + } + + const coneColor = isHovered ? 0x00_ff_00 : materials && 'coneColor' in materials ? materials.coneColor : 0x00_00_00; + (derivedMaterials.coneMaterial as THREE.MeshMatcapMaterial).color.set(coneColor); + }, [isHovered, derivedMaterials, materials]); + + const midpoint = useMemo( + () => new THREE.Vector3().addVectors(startVec, endVec).multiplyScalar(0.5), + [startVec, endVec], + ); + + const calculatedDistance = distance ?? startVec.distanceTo(endVec); + const distanceInMm = calculatedDistance / lengthFactor; + const numericText = distanceInMm.toFixed(decimals); + const unitsText = enableUnits ? lengthSymbol : ''; + const labelText = `${numericText}${enableUnits ? ` ${unitsText}` : ''}`; + + const unitContainerChars = 3; + const backgroundCharsLength = numericText.length + (enableUnits ? 1 + unitContainerChars : 0); + const backgroundPlaceholderText = '0'.repeat(Math.max(1, backgroundCharsLength)); + + const textGeometry = useMemo( + // eslint-disable-next-line new-cap -- Three.js convention + () => LabelTextGeometry({ text: labelText, size: textSize, depth: textDepth }), + [labelText, textSize, textDepth], + ); + + const backgroundGeometry = useMemo( + () => + // eslint-disable-next-line new-cap -- Three.js convention + LabelBackgroundGeometry({ + text: backgroundPlaceholderText, + characterWidth: labelCharWidth, + padding: labelPadding, + height: labelHeight, + radius: labelCornerRadius, + depth: labelDepth, + }), + [backgroundPlaceholderText, labelCharWidth, labelPadding, labelHeight, labelCornerRadius, labelDepth], + ); + + const backgroundOutlineGeometry = useMemo( + () => + // eslint-disable-next-line new-cap -- Three.js convention + LabelBackgroundGeometry({ + text: backgroundPlaceholderText, + characterWidth: labelCharWidth, + padding: labelPadding + 5, + height: labelHeight + 10, + radius: labelCornerRadius + 5, + depth: labelDepth, + }), + [backgroundPlaceholderText, labelCharWidth, labelPadding, labelHeight, labelCornerRadius, labelDepth], + ); + + const scaleRef = useRef(1); + + const lineDirection = useMemo(() => new THREE.Vector3().subVectors(endVec, startVec).normalize(), [startVec, endVec]); + + useFrame(() => { + const scale = calculateScaleFromCamera(midpoint, camera); + scaleRef.current = scale; + + if (labelGroupRef.current) { + _baseQuat.setFromUnitVectors(_currentNormal.set(1, 0, 0), lineDirection); + + _currentNormal.set(0, 0, 1).applyQuaternion(_baseQuat); + const axisRotation = computeAxisRotationForCamera({ + axis: lineDirection, + position: midpoint, + camera, + referenceUp: _currentNormal, + }); + + _finalQuat.multiplyQuaternions(axisRotation, _baseQuat); + + _labelNormal.set(0, 0, 1).applyQuaternion(_finalQuat).normalize(); + _labelUp.set(0, 1, 0).applyQuaternion(_finalQuat).normalize(); + + _cameraUp.set(0, 1, 0).applyQuaternion(camera.quaternion).normalize(); + _cameraUpProjected.copy(_cameraUp).addScaledVector(_labelNormal, -_cameraUp.dot(_labelNormal)).normalize(); + + if (_labelUp.dot(_cameraUpProjected) < 0) { + _flipQuat.setFromAxisAngle(_labelNormal, Math.PI); + _finalQuat.copy(_axisRotation.multiplyQuaternions(_flipQuat, _finalQuat)); + } + + labelGroupRef.current.quaternion.copy(_finalQuat); + labelGroupRef.current.scale.setScalar(scale * (isHovered ? 1.2 : 1)); + labelGroupRef.current.position.copy(midpoint); + } + + _lineDir.subVectors(endVec, startVec).normalize(); + + const coneHeightScaled = coneHeight * scale; + const coneRadiusScaled = coneRadius * scale; + const cylinderRadiusScaled = cylinderRadius * scale; + + const effectiveCone = isPreview ? 0 : coneHeightScaled; + const cylinderHeight = Math.max(0.0001, lineDistance - 2 * effectiveCone); + + if (cylinderMeshRef.current) { + cylinderMeshRef.current.scale.set(cylinderRadiusScaled, cylinderHeight, cylinderRadiusScaled); + } + + _coneOffset.copy(_lineDir).multiplyScalar(coneHeightScaled / 2); + if (startConeMeshRef.current) { + startConeMeshRef.current.scale.set(coneRadiusScaled, coneHeightScaled, coneRadiusScaled); + startConeMeshRef.current.position.copy(startVec).add(_coneOffset); + } + + if (endConeMeshRef.current) { + endConeMeshRef.current.scale.set(coneRadiusScaled, coneHeightScaled, coneRadiusScaled); + endConeMeshRef.current.position.copy(endVec).sub(_coneOffset); + } + }); + + const lineDistance = useMemo(() => startVec.distanceTo(endVec), [startVec, endVec]); + const { startQuaternion, endQuaternion, cylinderQuaternion } = useMemo(() => { + const up = new THREE.Vector3(0, 1, 0); + const startQ = new THREE.Quaternion().setFromUnitVectors(up, lineDirection.clone().negate()); + const endQ = new THREE.Quaternion().setFromUnitVectors(up, lineDirection); + const cylinderQ = new THREE.Quaternion().setFromUnitVectors(up, lineDirection); + return { startQuaternion: startQ, endQuaternion: endQ, cylinderQuaternion: cylinderQ }; + }, [lineDirection]); + + return ( + + + + + + + + {!isPreview && ( + + + + + )} + + {!isPreview && ( + + + + + )} + + + {!isPreview && ( + + { + event.stopPropagation(); + setIsLabelHovered(true); + if (id) { + measureStore.getState().setHoveredMeasurement(id); + } + }} + onPointerLeave={(event) => { + event.stopPropagation(); + setIsLabelHovered(false); + measureStore.getState().setHoveredMeasurement(undefined); + }} + > + {(() => { + const totalChars = backgroundPlaceholderText.length; + const baseWidth = totalChars * labelCharWidth + 2 * labelPadding; + const buttonDiameter = 2 * labelCharWidth; + const hitWidth = baseWidth + buttonDiameter + Math.max(5, labelPadding * 0.2); + const hitHeight = labelHeight + 2 * labelPadding; + return ( + <> + + + + ); + })()} + + + + + + + + + + + + + + + + {id && isHovered ? ( + { + const totalChars = backgroundPlaceholderText.length; + const width = totalChars * labelCharWidth + 2 * labelPadding; + const buttonDiameter = 2 * labelCharWidth; + const offsetX = width / 2 - buttonDiameter / 2 - Math.max(5, labelPadding * 0.2); + const offsetY = 0; + return [offsetX, offsetY, 0]; + })()} + renderOrder={3} + userData={{ isMeasurementUi: true }} + > + { + event.stopPropagation(); + setIsLabelHovered(true); + if (id) { + measureStore.getState().setHoveredMeasurement(id); + } + }} + onPointerOut={(event) => { + event.stopPropagation(); + }} + onPointerDown={(event) => { + if (event.nativeEvent.button === 0 && id) { + measureStore.getState().togglePinned(id); + } + + event.stopPropagation(); + }} + > + + + + + { + event.stopPropagation(); + }} + onPointerOut={(event) => { + event.stopPropagation(); + }} + > + + + + { + event.stopPropagation(); + }} + onPointerOut={(event) => { + event.stopPropagation(); + }} + > + + + + + ) : null} + + )} + + ); +} From 3b133ebf81530eed9566372c1196b2877fe0c67a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 25 Feb 2026 10:54:36 +0000 Subject: [PATCH 11/14] feat(three): add high-level Scene, CadCanvas, CadViewer, and presets components - scene.tsx: composition of Stage, Controls, UpDirectionHandler - cad-canvas.tsx: R3F Canvas wrapper with sensible GL defaults - cad-viewer.tsx: zero-config component mapping GLTF to GltfMesh - presets.ts: default, minimal, and full viewer preset factories - Export all new components from react index Co-authored-by: richard --- packages/three/src/react/cad-canvas.tsx | 104 ++++++++++++++++++++++++ packages/three/src/react/cad-viewer.tsx | 52 ++++++++++++ packages/three/src/react/index.ts | 4 + packages/three/src/react/presets.ts | 57 +++++++++++++ packages/three/src/react/scene.tsx | 60 ++++++++++++++ 5 files changed, 277 insertions(+) create mode 100644 packages/three/src/react/cad-canvas.tsx create mode 100644 packages/three/src/react/cad-viewer.tsx create mode 100644 packages/three/src/react/presets.ts create mode 100644 packages/three/src/react/scene.tsx diff --git a/packages/three/src/react/cad-canvas.tsx b/packages/three/src/react/cad-canvas.tsx new file mode 100644 index 000000000..a6546de28 --- /dev/null +++ b/packages/three/src/react/cad-canvas.tsx @@ -0,0 +1,104 @@ +import type { CanvasProps } from '@react-three/fiber'; +import { Canvas } from '@react-three/fiber'; +import { Scene } from './scene.js'; +import { SceneOverlay } from './scene-overlay.js'; +import { PostProcessing } from './post-processing.js'; +import { Grid } from './grid.js'; +import { AxesHelper } from './axes-helper.js'; +import type { StageOptions } from './stage.js'; +import { CadStoreProvider } from './stores/store-context.js'; +import type { ViewerStore, MeasureStore, SectionViewStore } from './stores/index.js'; + +type CadCanvasProperties = CanvasProps & { + readonly enableGizmo?: boolean; + readonly enableGrid?: boolean; + readonly enableAxes?: boolean; + readonly enableZoom?: boolean; + readonly enablePan?: boolean; + readonly enableDamping?: boolean; + readonly upDirection?: 'x' | 'y' | 'z'; + readonly enableCentering?: boolean; + readonly stageOptions?: StageOptions; + readonly zoomSpeed?: number; + readonly gizmoContainer?: HTMLElement | string; + readonly className?: string; + readonly geometryKey?: string; + readonly onSceneRadiusChange?: (radius: number) => void; + readonly onResetCamera?: () => void; + readonly onContextLost?: () => void; + readonly viewerStore?: ViewerStore; + readonly measureStore?: MeasureStore; + readonly sectionViewStore?: SectionViewStore; +}; + +export function CadCanvas({ + children, + enableGizmo = false, + enableGrid = false, + enableAxes = false, + enableZoom = true, + enablePan = true, + enableDamping = true, + upDirection = 'z', + enableCentering = false, + className, + stageOptions, + zoomSpeed = 2, + gizmoContainer, + geometryKey, + onSceneRadiusChange, + onResetCamera, + onContextLost, + viewerStore, + measureStore, + sectionViewStore, + ...canvasProps +}: CadCanvasProperties): React.JSX.Element { + const dpr = Math.min(globalThis.devicePixelRatio ?? 1, 2); + + return ( + + { + gl.toneMappingExposure = 1; + const canvas = gl.domElement; + canvas.addEventListener('webglcontextlost', (event) => { + event.preventDefault(); + onContextLost?.(); + }); + }} + {...canvasProps} + > + + {children} + + + + {enableAxes ? : undefined} + {enableGrid ? : undefined} + + + + ); +} diff --git a/packages/three/src/react/cad-viewer.tsx b/packages/three/src/react/cad-viewer.tsx new file mode 100644 index 000000000..06cccf47c --- /dev/null +++ b/packages/three/src/react/cad-viewer.tsx @@ -0,0 +1,52 @@ +import { memo } from 'react'; +import { CadCanvas } from './cad-canvas.js'; +import { GltfMesh } from './gltf-mesh.js'; +import type { StageOptions } from './stage.js'; +import type { ViewerStore, MeasureStore, SectionViewStore } from './stores/index.js'; + +type CadViewerProperties = { + readonly gltf: Uint8Array | ReadonlyArray>; + readonly enableGizmo?: boolean; + readonly enableGrid?: boolean; + readonly enableAxes?: boolean; + readonly enableZoom?: boolean; + readonly enablePan?: boolean; + readonly enableDamping?: boolean; + readonly enableMatcap?: boolean; + readonly enableSurfaces?: boolean; + readonly enableLines?: boolean; + readonly upDirection?: 'x' | 'y' | 'z'; + readonly enableCentering?: boolean; + readonly stageOptions?: StageOptions; + readonly zoomSpeed?: number; + readonly gizmoContainer?: HTMLElement | string; + readonly className?: string; + readonly onContextLost?: () => void; + readonly viewerStore?: ViewerStore; + readonly measureStore?: MeasureStore; + readonly sectionViewStore?: SectionViewStore; +}; + +export const CadViewer = memo(function CadViewer({ + gltf, + enableMatcap = false, + enableSurfaces = true, + enableLines = true, + ...canvasProps +}: CadViewerProperties): React.JSX.Element { + const gltfArray = Array.isArray(gltf) ? gltf : [gltf]; + + return ( + + {gltfArray.map((file, index) => ( + + ))} + + ); +}); diff --git a/packages/three/src/react/index.ts b/packages/three/src/react/index.ts index be4ea8d53..cfe8fc501 100644 --- a/packages/three/src/react/index.ts +++ b/packages/three/src/react/index.ts @@ -25,6 +25,10 @@ export { Grid } from './grid.js'; export { ViewportGizmoCube } from './viewport-gizmo.js'; export { MeasureTool } from './measure-tool.js'; export { Controls } from './controls.js'; +export { Scene } from './scene.js'; +export { CadCanvas } from './cad-canvas.js'; +export { CadViewer } from './cad-viewer.js'; +export { presets, type CadViewerPreset } from './presets.js'; // Zustand stores export { diff --git a/packages/three/src/react/presets.ts b/packages/three/src/react/presets.ts new file mode 100644 index 000000000..c5c524bcc --- /dev/null +++ b/packages/three/src/react/presets.ts @@ -0,0 +1,57 @@ +type CadViewerPreset = { + enableGrid: boolean; + enableAxes: boolean; + enableGizmo: boolean; + enableZoom: boolean; + enablePan: boolean; + enableDamping: boolean; + enableCentering: boolean; + upDirection: 'x' | 'y' | 'z'; +}; + +function createDefaultPreset(): CadViewerPreset { + return { + enableGrid: true, + enableAxes: false, + enableGizmo: false, + enableZoom: true, + enablePan: true, + enableDamping: true, + enableCentering: false, + upDirection: 'z', + }; +} + +function createMinimalPreset(): CadViewerPreset { + return { + enableGrid: false, + enableAxes: false, + enableGizmo: false, + enableZoom: true, + enablePan: true, + enableDamping: false, + enableCentering: false, + upDirection: 'z', + }; +} + +function createFullPreset(): CadViewerPreset { + return { + enableGrid: true, + enableAxes: true, + enableGizmo: true, + enableZoom: true, + enablePan: true, + enableDamping: true, + enableCentering: true, + upDirection: 'z', + }; +} + +export const presets = { + default: createDefaultPreset, + minimal: createMinimalPreset, + full: createFullPreset, +} as const; + +export type { CadViewerPreset }; diff --git a/packages/three/src/react/scene.tsx b/packages/three/src/react/scene.tsx new file mode 100644 index 000000000..fde9bc114 --- /dev/null +++ b/packages/three/src/react/scene.tsx @@ -0,0 +1,60 @@ +import type { ReactNode } from 'react'; +import type { StageOptions } from './stage.js'; +import { Stage } from './stage.js'; +import { Controls } from './controls.js'; +import { UpDirectionHandler } from './up-direction-handler.js'; + +type SceneProperties = { + readonly children: ReactNode; + readonly enableGizmo?: boolean; + readonly enableDamping?: boolean; + readonly enableZoom?: boolean; + readonly enablePan?: boolean; + readonly upDirection?: 'x' | 'y' | 'z'; + readonly stageOptions?: StageOptions; + readonly enableCentering?: boolean; + readonly zoomSpeed: number; + readonly gizmoContainer?: HTMLElement | string; + readonly geometryKey?: string; + readonly onSceneRadiusChange?: (radius: number) => void; + readonly onResetCamera?: () => void; +}; + +export function Scene({ + children, + enableGizmo = false, + enableDamping = false, + enableZoom = false, + enablePan = false, + upDirection = 'z', + stageOptions, + enableCentering = false, + zoomSpeed, + gizmoContainer, + geometryKey, + onSceneRadiusChange, + onResetCamera, +}: SceneProperties): React.JSX.Element { + return ( + <> + + + + {children} + + + ); +} From 42d9e604964ad484b53b47c045b788b8b70318db Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 25 Feb 2026 11:04:33 +0000 Subject: [PATCH 12/14] refactor: update UI app to import from @taucad/three and @taucad/three/react - cad-viewer.tsx: import GltfMesh/CadCanvas from @taucad/three/react - cad-preview.tsx: import StageOptions from @taucad/three/react - screenshot-capability.machine.ts: import materials/utils from @taucad/three - screenshot-capability.utils.test.ts: same import updates - actor-bridge.tsx: import updateCameraFov from @taucad/three, add zustand sync - three-context.tsx: wrap CadCanvas from @taucad/three/react with WebGL tracking Co-authored-by: richard --- apps/ui/app/components/cad-preview.tsx | 2 +- .../components/geometry/cad/cad-viewer.tsx | 24 +++- .../geometry/graphics/three/actor-bridge.tsx | 40 ++++-- .../geometry/graphics/three/three-context.tsx | 95 ++++-------- .../machines/screenshot-capability.machine.ts | 10 +- .../screenshot-capability.utils.test.ts | 8 +- apps/ui/package.json | 26 +--- packages/three/src/index.ts | 54 ++++++- packages/three/src/react/index.ts | 55 ++++--- pnpm-lock.yaml | 135 +++++++++++++++++- 10 files changed, 304 insertions(+), 145 deletions(-) diff --git a/apps/ui/app/components/cad-preview.tsx b/apps/ui/app/components/cad-preview.tsx index f35c7d1fe..9e2423bf3 100644 --- a/apps/ui/app/components/cad-preview.tsx +++ b/apps/ui/app/components/cad-preview.tsx @@ -5,7 +5,7 @@ import { Loader } from '#components/ui/loader.js'; import { GraphicsProvider } from '#hooks/use-graphics.js'; import { useCadPreview } from '#hooks/use-cad-preview.js'; import { cn } from '#utils/ui.utils.js'; -import type { StageOptions } from '#components/geometry/graphics/three/stage.js'; +import type { StageOptions } from '@taucad/three/react'; /** * Visual rendering settings for the CAD preview viewer. diff --git a/apps/ui/app/components/geometry/cad/cad-viewer.tsx b/apps/ui/app/components/geometry/cad/cad-viewer.tsx index 28306dc29..d9b430238 100644 --- a/apps/ui/app/components/geometry/cad/cad-viewer.tsx +++ b/apps/ui/app/components/geometry/cad/cad-viewer.tsx @@ -1,17 +1,29 @@ import { memo } from 'react'; import type { Geometry } from '@taucad/types'; -import { GltfMesh } from '#components/geometry/graphics/three/react/gltf-mesh.js'; -import { ThreeProvider } from '#components/geometry/graphics/three/three-context.js'; -import type { ThreeViewerProperties } from '#components/geometry/graphics/three/three-context.js'; +import { GltfMesh, CadCanvas } from '@taucad/three/react'; +import type { StageOptions } from '@taucad/three/react'; import { SvgViewer } from '#components/geometry/graphics/svg/svg-viewer.js'; import { WebglErrorBoundary } from '#components/geometry/cad/webgl-error-boundary.js'; import { WebglErrorFallback } from '#components/geometry/cad/webgl-fallback.js'; -type CadViewerProperties = ThreeViewerProperties & { +type CadViewerProperties = { readonly geometries: Geometry[]; readonly enableSurfaces?: boolean; readonly enableLines?: boolean; readonly enableMatcap?: boolean; + readonly enableGizmo?: boolean; + readonly enableGrid?: boolean; + readonly enableAxes?: boolean; + readonly enableZoom?: boolean; + readonly enablePan?: boolean; + readonly enableDamping?: boolean; + readonly upDirection?: 'x' | 'y' | 'z'; + readonly className?: string; + readonly enableCentering?: boolean; + readonly stageOptions?: StageOptions; + readonly zoomSpeed?: number; + readonly gizmoContainer?: HTMLElement | string; + readonly enablePostProcessing?: boolean; }; export const CadViewer = memo( @@ -33,7 +45,7 @@ export const CadViewer = memo( return ( }> - + {geometries.map((geometry) => { switch (geometry.format) { case 'gltf': { @@ -62,7 +74,7 @@ export const CadViewer = memo( } } })} - + ); }, diff --git a/apps/ui/app/components/geometry/graphics/three/actor-bridge.tsx b/apps/ui/app/components/geometry/graphics/three/actor-bridge.tsx index 234a7f4a4..8e76b1f4e 100644 --- a/apps/ui/app/components/geometry/graphics/three/actor-bridge.tsx +++ b/apps/ui/app/components/geometry/graphics/three/actor-bridge.tsx @@ -3,24 +3,39 @@ import type { ReactNode } from 'react'; import { useThree } from '@react-three/fiber'; import { useActorRef } from '@xstate/react'; import type { OrbitControls } from 'three/addons'; +import { updateCameraFov } from '@taucad/three'; +import { useViewerStore } from '@taucad/three/react'; import { controlsListenerMachine } from '#machines/controls-listener.machine.js'; -import { updateCameraFov } from '#components/geometry/graphics/three/utils/camera.utils.js'; import { useGraphics, useGraphicsSelector, useScreenshotCapability } from '#hooks/use-graphics.js'; /** - * Component that bridges Three.js context with XState actors - * Sets up screenshot capability, controls listeners, and FOV updates - * Acts as the integration layer between Three.js and the graphics state machine + * Bridges Three.js context with XState actors and syncs graphics machine + * state to zustand viewer stores. Sets up screenshot capability, controls + * listeners, and FOV updates. */ export function ActorBridge(): ReactNode { const { gl, scene, camera, controls, invalidate } = useThree(); const screenshotCapabilityActor = useScreenshotCapability(); const graphicsActor = useGraphics(); - // Subscribe to camera FOV angle from graphics actor const cameraFovAngle = useGraphicsSelector((state) => state.context.cameraFovAngle); + const enableGrid = useGraphicsSelector((state) => state.context.enableGrid); + const enableAxes = useGraphicsSelector((state) => state.context.enableAxes); + const enableMatcap = useGraphicsSelector((state) => state.context.enableMatcap); + const enableSurfaces = useGraphicsSelector((state) => state.context.enableSurfaces); + const enableLines = useGraphicsSelector((state) => state.context.enableLines); + const enableGizmo = useGraphicsSelector((state) => state.context.enableGizmo); + const enablePostProcessing = useGraphicsSelector((state) => state.context.enablePostProcessing); + + const setFieldOfView = useViewerStore((s) => s.setFieldOfView); + const setEnableGrid = useViewerStore((s) => s.setEnableGrid); + const setEnableAxes = useViewerStore((s) => s.setEnableAxes); + const setEnableMatcap = useViewerStore((s) => s.setEnableMatcap); + const setEnableSurfaces = useViewerStore((s) => s.setEnableSurfaces); + const setEnableLines = useViewerStore((s) => s.setEnableLines); + const setEnableGizmo = useViewerStore((s) => s.setEnableGizmo); + const setEnablePostProcessing = useViewerStore((s) => s.setEnablePostProcessing); - // Setup screenshot capability useEffect(() => { screenshotCapabilityActor.send({ type: 'registerCapture', @@ -34,13 +49,20 @@ export function ActorBridge(): ReactNode { }; }, [gl, scene, camera, screenshotCapabilityActor]); - // Update camera FOV when angle changes, without resetting position - // This preserves user's zoom and viewing angle while updating the FOV useEffect(() => { updateCameraFov({ camera, cameraFovAngle, invalidate }); }, [cameraFovAngle, camera, invalidate]); - // Setup controls listener + // Sync xstate graphics machine state → zustand viewer store + useEffect(() => { setFieldOfView(cameraFovAngle); }, [cameraFovAngle, setFieldOfView]); + useEffect(() => { setEnableGrid(enableGrid); }, [enableGrid, setEnableGrid]); + useEffect(() => { setEnableAxes(enableAxes); }, [enableAxes, setEnableAxes]); + useEffect(() => { setEnableMatcap(enableMatcap); }, [enableMatcap, setEnableMatcap]); + useEffect(() => { setEnableSurfaces(enableSurfaces); }, [enableSurfaces, setEnableSurfaces]); + useEffect(() => { setEnableLines(enableLines); }, [enableLines, setEnableLines]); + useEffect(() => { setEnableGizmo(enableGizmo); }, [enableGizmo, setEnableGizmo]); + useEffect(() => { setEnablePostProcessing(enablePostProcessing); }, [enablePostProcessing, setEnablePostProcessing]); + useActorRef(controlsListenerMachine, { input: { graphicsActorRef: graphicsActor, diff --git a/apps/ui/app/components/geometry/graphics/three/three-context.tsx b/apps/ui/app/components/geometry/graphics/three/three-context.tsx index c073d4be2..24c8b3d2b 100644 --- a/apps/ui/app/components/geometry/graphics/three/three-context.tsx +++ b/apps/ui/app/components/geometry/graphics/three/three-context.tsx @@ -1,12 +1,6 @@ -import type { CanvasProps } from '@react-three/fiber'; -import { Canvas } from '@react-three/fiber'; import { useCallback, useEffect, useState } from 'react'; -import { Scene } from '#components/geometry/graphics/three/scene.js'; -import { SceneOverlay } from '#components/geometry/graphics/three/scene-overlay.js'; -import { PostProcessing } from '#components/geometry/graphics/three/post-processing.js'; -import type { StageOptions } from '#components/geometry/graphics/three/stage.js'; -import { Grid } from '#components/geometry/graphics/three/grid.js'; -import { AxesHelper } from '#components/geometry/graphics/three/react/axes-helper.js'; +import { CadCanvas } from '@taucad/three/react'; +import type { StageOptions } from '@taucad/three/react'; import { ActorBridge } from '#components/geometry/graphics/three/actor-bridge.js'; import { cn } from '#utils/ui.utils.js'; import { useWebglContextRef } from '#hooks/use-webgl-context-tracker.js'; @@ -28,7 +22,9 @@ export type ThreeViewerProperties = { readonly gizmoContainer?: HTMLElement | string; }; -export type ThreeContextProperties = CanvasProps & ThreeViewerProperties; +export type ThreeContextProperties = ThreeViewerProperties & { + readonly children?: React.ReactNode; +}; export function ThreeProvider({ children, @@ -44,23 +40,17 @@ export function ThreeProvider({ stageOptions, zoomSpeed = 2, gizmoContainer, - ...properties }: ThreeContextProperties): React.JSX.Element { - const dpr = Math.min(globalThis.devicePixelRatio, 2); - const [isCanvasReady, setIsCanvasReady] = useState(false); const [isContextLost, setIsContextLost] = useState(false); // Read the actor snapshot once at mount to decide whether we can create a // new WebGL context. This is intentionally NON-reactive -- we never - // subscribe to count changes. A reactive subscription in the parent - // (CadViewer) or here would cause an infinite re-render loop because - // acquire/release events (sent during effect mount/cleanup) would - // synchronously flip `isAtLimit`, triggering mount -> acquire -> re-render -> - // unmount -> release -> re-render -> mount ... ad infinitum. + // subscribe to count changes. A reactive subscription would cause an + // infinite re-render loop because acquire/release events would + // synchronously flip `isAtLimit`. // // `webglRef` is `undefined` when no `` is - // mounted above this component (e.g. single-viewer pages like the landing - // page, converter, preview, etc.). In that case we skip tracking entirely. + // mounted above this component (e.g. single-viewer pages). const webglRef = useWebglContextRef(); // eslint-disable-next-line react/hook-use-state -- one-time snapshot, setter intentionally unused @@ -84,14 +74,9 @@ export function ThreeProvider({ }; }, [webglRef, isOverLimit]); - // Remount the Canvas to recover from context loss or to retry after the - // WebGL context limit was reached. Changing the key forces React to - // unmount the old instance and mount a fresh one that re-evaluates the - // snapshot. const [canvasKey, setCanvasKey] = useState(0); const handleRetry = useCallback(() => { setIsContextLost(false); - setIsCanvasReady(false); setCanvasKey((previous) => previous + 1); }, []); @@ -104,54 +89,26 @@ export function ThreeProvider({ } return ( - { - // Neutral ACES exposure -- depth contrast comes from AO and targeted directional lights. - gl.toneMappingExposure = 1; - - // Listen for WebGL context loss events on the underlying canvas element. - // preventDefault() tells the browser we intend to handle restoration ourselves. - const canvas = gl.domElement; - canvas.addEventListener('webglcontextlost', (event) => { - event.preventDefault(); - setIsContextLost(true); - }); - - setIsCanvasReady(true); + enableGizmo={enableGizmo} + enableGrid={enableGrid} + enableAxes={enableAxes} + enableZoom={enableZoom} + enablePan={enablePan} + enableDamping={enableDamping} + upDirection={upDirection} + enableCentering={enableCentering} + stageOptions={stageOptions} + zoomSpeed={zoomSpeed} + gizmoContainer={gizmoContainer} + onContextLost={() => { + setIsContextLost(true); }} - {...properties} > - - {children} - - - - {enableAxes ? : null} - {enableGrid ? : null} - - {isCanvasReady ? : null} - + {children} + + ); } diff --git a/apps/ui/app/machines/screenshot-capability.machine.ts b/apps/ui/app/machines/screenshot-capability.machine.ts index 571c77e97..601416acc 100644 --- a/apps/ui/app/machines/screenshot-capability.machine.ts +++ b/apps/ui/app/machines/screenshot-capability.machine.ts @@ -5,11 +5,11 @@ import type { ScreenshotOptions, CameraAngle, CompositeScreenshotOptions } from import { applyMatcapToClonedScene, disposeClonedSceneMaterials, -} from '#components/geometry/graphics/three/materials/gltf-matcap.js'; -import { ensureMatcapTextureLoaded } from '#components/geometry/graphics/three/materials/matcap-material.js'; -import { calculateFovDistanceCompensation } from '#components/geometry/graphics/three/utils/math.utils.js'; -import { computeViewFittingZoom } from '#components/geometry/graphics/three/utils/camera.utils.js'; -import { defaultStageOptions } from '#components/geometry/graphics/three/stage.js'; + ensureMatcapTextureLoaded, + calculateFovDistanceCompensation, + computeViewFittingZoom, +} from '@taucad/three'; +import { defaultStageOptions } from '@taucad/three/react'; // Capture mode discriminator type CaptureMode = 'threejs' | 'svg'; diff --git a/apps/ui/app/machines/screenshot-capability.utils.test.ts b/apps/ui/app/machines/screenshot-capability.utils.test.ts index a0e2cf930..cd4425ad3 100644 --- a/apps/ui/app/machines/screenshot-capability.utils.test.ts +++ b/apps/ui/app/machines/screenshot-capability.utils.test.ts @@ -4,10 +4,10 @@ import { calculateOptimalGrid } from '#machines/screenshot-capability.machine.js import { applyMatcapToClonedScene, disposeClonedSceneMaterials, -} from '#components/geometry/graphics/three/materials/gltf-matcap.js'; -import { calculateFovDistanceCompensation } from '#components/geometry/graphics/three/utils/math.utils.js'; -import { computeViewFittingZoom } from '#components/geometry/graphics/three/utils/camera.utils.js'; -import { defaultStageOptions } from '#components/geometry/graphics/three/stage.js'; + calculateFovDistanceCompensation, + computeViewFittingZoom, +} from '@taucad/three'; +import { defaultStageOptions } from '@taucad/three/react'; describe('calculateOptimalGrid', () => { describe('edge cases', () => { diff --git a/apps/ui/package.json b/apps/ui/package.json index b3743983f..db2393c15 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -1,25 +1 @@ -{ - "name": "@taucad/ui", - "version": "0.0.1", - "type": "module", - "private": true, - "dependencies": { - "@react-router/node": "catalog:", - "@taucad/api-extractor": "workspace:*", - "@taucad/converter": "workspace:*", - "@taucad/json-schema": "workspace:*", - "@taucad/tau-examples": "workspace:*", - "@taucad/types": "workspace:*", - "@taucad/units": "workspace:*", - "@taucad/chat": "workspace:*", - "@taucad/utils": "workspace:*", - "@taucad/kernels": "workspace:*", - "isbot": "catalog:" - }, - "imports": { - "#*": "./app/*" - }, - "files": [ - "build" - ] -} +{"name":"@taucad/ui","version":"0.0.1","type":"module","private":true,"dependencies":{"@react-router/node":"catalog:","@taucad/api-extractor":"workspace:*","@taucad/converter":"workspace:*","@taucad/json-schema":"workspace:*","@taucad/tau-examples":"workspace:*","@taucad/three":"workspace:*","@taucad/types":"workspace:*","@taucad/units":"workspace:*","@taucad/chat":"workspace:*","@taucad/utils":"workspace:*","@taucad/kernels":"workspace:*","isbot":"catalog:"},"imports":{"#*":"./app/*"},"files":["build"]} diff --git a/packages/three/src/index.ts b/packages/three/src/index.ts index a9e2e75fe..15c4277d4 100644 --- a/packages/three/src/index.ts +++ b/packages/three/src/index.ts @@ -1,2 +1,52 @@ -// @taucad/three - Core Three.js utilities (no React dependency) -export * from '#screenshot/index.js'; +/** + * @taucad/three - Core Three.js utilities for CAD rendering. + * + * This entry point exports non-React utilities: materials, geometries, + * camera/lighting math, screenshot capture, and vanilla controls. + * For React components see `@taucad/three/react`. + * + * @packageDocumentation + */ + +// ── Screenshot ──────────────────────────────────────────────────────────────── + +export { captureScreenshot } from '#screenshot/capture-screenshot.js'; +export { createCompositeImage, calculateOptimalGrid } from '#screenshot/create-composite-image.js'; +export type { + CameraAngle, + CompositeScreenshotOptions, + ScreenshotOptions, + ScreenshotOutputOptions, +} from '#screenshot/types.js'; + +// ── Camera utilities ────────────────────────────────────────────────────────── + +export { + resetCamera, + updateCameraFov, + computeViewFittingZoom, +} from '#utils/camera.utils.js'; + +// ── Math utilities ──────────────────────────────────────────────────────────── + +export { + calculateFovDistanceCompensation, +} from '#utils/math.utils.js'; + +// ── Lighting utilities ──────────────────────────────────────────────────────── + +export { + applyLightingForCamera, +} from '#utils/lights.utils.js'; + +// ── Color utilities ─────────────────────────────────────────────────────────── + +export { adjustHexColorBrightness } from '#utils/color.utils.js'; + +// ── Materials ───────────────────────────────────────────────────────────────── + +export { matcapMaterial, ensureMatcapTextureLoaded } from '#materials/matcap-material.js'; +export { createStripedMaterial } from '#materials/striped-material.js'; +export { applyMatcap, applyMatcapToClonedScene, disposeClonedSceneMaterials } from '#materials/gltf-matcap.js'; +export { applyFatLineSegments, updateLineMaterialResolution } from '#materials/gltf-edges.js'; +export { infiniteGridMaterial } from '#materials/infinite-grid-material.js'; diff --git a/packages/three/src/react/index.ts b/packages/three/src/react/index.ts index cfe8fc501..d1b943617 100644 --- a/packages/three/src/react/index.ts +++ b/packages/three/src/react/index.ts @@ -1,11 +1,37 @@ -// @taucad/three/react - React components, hooks, and stores -export { AxesHelper } from './axes-helper.js'; +/** + * @taucad/three/react - React components, hooks, and stores for CAD rendering. + * + * Provides a complete set of React Three Fiber components for rendering + * CAD geometry with measurement, section view, and viewport gizmo features. + * All state is managed via zustand stores. + * + * @packageDocumentation + */ + +// ── High-level components ───────────────────────────────────────────────────── + +export { CadViewer } from './cad-viewer.js'; +export { CadCanvas } from './cad-canvas.js'; +export { presets, type CadViewerPreset } from './presets.js'; + +// ── Scene composition ───────────────────────────────────────────────────────── + +export { Scene } from './scene.js'; +export { Stage, defaultStageOptions, type StageOptions } from './stage.js'; export { GltfMesh } from './gltf-mesh.js'; -export { InfiniteGrid } from './infinite-grid.js'; export { Lights } from './lights.js'; +export { Grid } from './grid.js'; +export { AxesHelper } from './axes-helper.js'; +export { PostProcessing } from './post-processing.js'; +export { InfiniteGrid } from './infinite-grid.js'; export { SceneOverlay } from './scene-overlay.js'; -export { SectionView } from './section-view.js'; -export type { CutterProperties } from './section-view.js'; +export { Controls } from './controls.js'; +export { UpDirectionHandler } from './up-direction-handler.js'; + +// ── Interactive features ────────────────────────────────────────────────────── + +export { MeasureTool } from './measure-tool.js'; +export { SectionView, type CutterProperties } from './section-view.js'; export { SectionViewControls, type AvailablePlane, @@ -14,23 +40,17 @@ export { type UpDirection, } from './section-view-controls.js'; export { TransformControls, type TransformControlsProps } from './transform-controls-drei.js'; -export { Stage, defaultStageOptions, type StageOptions } from './stage.js'; -export { UpDirectionHandler } from './up-direction-handler.js'; +export { ViewportGizmoCube } from './viewport-gizmo.js'; + +// ── Hooks ───────────────────────────────────────────────────────────────────── + export { useCameraFraming } from './hooks/use-camera-framing.js'; export { useCameraReset } from './use-camera-reset.js'; export { useGeometryBounds } from './hooks/use-geometry-bounds.js'; export { useSectionView, type SectionViewResult } from './hooks/use-section-view.js'; -export { PostProcessing } from './post-processing.js'; -export { Grid } from './grid.js'; -export { ViewportGizmoCube } from './viewport-gizmo.js'; -export { MeasureTool } from './measure-tool.js'; -export { Controls } from './controls.js'; -export { Scene } from './scene.js'; -export { CadCanvas } from './cad-canvas.js'; -export { CadViewer } from './cad-viewer.js'; -export { presets, type CadViewerPreset } from './presets.js'; -// Zustand stores +// ── Stores ──────────────────────────────────────────────────────────────────── + export { createViewerStore, createMeasureStore, @@ -41,6 +61,7 @@ export { useMeasureStore, useSectionViewStore, } from './stores/index.js'; + export type { ViewerStore, ViewerStoreOptions, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64d3d82f9..dec8af4a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1065,6 +1065,9 @@ importers: '@taucad/tau-examples': specifier: workspace:* version: link:../../libs/tau-examples + '@taucad/three': + specifier: workspace:* + version: link:../../packages/three '@taucad/types': specifier: workspace:* version: link:../../libs/types @@ -1230,7 +1233,7 @@ importers: version: 0.20201231.0 vitest: specifier: '>=2.0.0' - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/ui@4.0.9(vitest@4.0.9))(happy-dom@16.8.1)(jiti@2.6.1)(jsdom@26.0.0(canvas@3.2.1))(less@4.5.1)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.64.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/ui@4.0.9)(happy-dom@16.8.1)(jiti@2.6.1)(jsdom@26.0.0(canvas@3.2.1))(less@4.5.1)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.64.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) ws: specifier: 'catalog:' version: 8.19.0 @@ -1251,6 +1254,37 @@ importers: specifier: 'catalog:' version: 1.7.0(next@15.5.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3)) + packages/three: + dependencies: + '@react-three/drei': + specifier: '>=10.0.0' + version: 10.7.7(@react-three/fiber@9.5.0(@types/react@19.0.12)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.179.1))(@types/react@19.0.12)(@types/three@0.178.1)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.179.1) + '@react-three/fiber': + specifier: '>=9.0.0' + version: 9.5.0(@types/react@19.0.12)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.179.1) + '@react-three/postprocessing': + specifier: '>=3.0.0' + version: 3.0.4(@react-three/fiber@9.5.0(@types/react@19.0.12)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.179.1))(@types/three@0.178.1)(react@19.2.4)(three@0.179.1) + react: + specifier: '>=19.0.0' + version: 19.2.4 + react-dom: + specifier: '>=19.0.0' + version: 19.2.4(react@19.2.4) + three: + specifier: '>=0.170.0' + version: 0.179.1 + three-viewport-gizmo: + specifier: https://codeload.github.com/taucad/three-viewport-gizmo/tar.gz/d5ae66010e7f5f067931aaeb6a74880a73d23b5d + version: https://codeload.github.com/taucad/three-viewport-gizmo/tar.gz/d5ae66010e7f5f067931aaeb6a74880a73d23b5d(three@0.179.1) + zustand: + specifier: ^5.0.0 + version: 5.0.11(@types/react@19.0.12)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + devDependencies: + '@types/three': + specifier: ^0.178.1 + version: 0.178.1 + packages: '@adobe/css-tools@4.3.3': @@ -22532,6 +22566,39 @@ snapshots: - '@types/three' - immer + '@react-three/drei@10.7.7(@react-three/fiber@9.5.0(@types/react@19.0.12)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.179.1))(@types/react@19.0.12)(@types/three@0.178.1)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.179.1)': + dependencies: + '@babel/runtime': 7.28.6 + '@mediapipe/tasks-vision': 0.10.17 + '@monogrid/gainmap-js': 3.4.0(three@0.179.1) + '@react-three/fiber': 9.5.0(@types/react@19.0.12)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.179.1) + '@use-gesture/react': 10.3.1(react@19.2.4) + camera-controls: 3.1.2(three@0.179.1) + cross-env: 7.0.3 + detect-gpu: 5.0.70 + glsl-noise: 0.0.0 + hls.js: 1.6.15 + maath: 0.10.8(@types/three@0.178.1)(three@0.179.1) + meshline: 3.3.1(three@0.179.1) + react: 19.2.4 + stats-gl: 2.4.2(@types/three@0.178.1)(three@0.179.1) + stats.js: 0.17.0 + suspend-react: 0.1.3(react@19.2.4) + three: 0.179.1 + three-mesh-bvh: 0.8.3(three@0.179.1) + three-stdlib: 2.36.1(three@0.179.1) + troika-three-text: 0.52.4(three@0.179.1) + tunnel-rat: 0.1.2(@types/react@19.0.12)(immer@11.1.4)(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.4) + utility-types: 3.11.0 + zustand: 5.0.11(@types/react@19.0.12)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - '@types/react' + - '@types/three' + - immer + '@react-three/fiber@9.5.0(@types/react@19.0.12)(immer@10.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.179.1)': dependencies: '@babel/runtime': 7.28.6 @@ -22552,6 +22619,26 @@ snapshots: - '@types/react' - immer + '@react-three/fiber@9.5.0(@types/react@19.0.12)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.179.1)': + dependencies: + '@babel/runtime': 7.28.6 + '@types/webxr': 0.5.24 + base64-js: 1.5.1 + buffer: 6.0.3 + its-fine: 2.0.0(@types/react@19.0.12)(react@19.2.4) + react: 19.2.4 + react-use-measure: 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + scheduler: 0.27.0 + suspend-react: 0.1.3(react@19.2.4) + three: 0.179.1 + use-sync-external-store: 1.6.0(react@19.2.4) + zustand: 5.0.11(@types/react@19.0.12)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - '@types/react' + - immer + '@react-three/postprocessing@3.0.4(@react-three/fiber@9.5.0(@types/react@19.0.12)(immer@10.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.179.1))(@types/three@0.178.1)(react@19.2.4)(three@0.179.1)': dependencies: '@react-three/fiber': 9.5.0(@types/react@19.0.12)(immer@10.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.179.1) @@ -22563,6 +22650,17 @@ snapshots: transitivePeerDependencies: - '@types/three' + '@react-three/postprocessing@3.0.4(@react-three/fiber@9.5.0(@types/react@19.0.12)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.179.1))(@types/three@0.178.1)(react@19.2.4)(three@0.179.1)': + dependencies: + '@react-three/fiber': 9.5.0(@types/react@19.0.12)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.179.1) + maath: 0.6.0(@types/three@0.178.1)(three@0.179.1) + n8ao: 1.10.1(postprocessing@6.38.3(three@0.179.1))(three@0.179.1) + postprocessing: 6.38.3(three@0.179.1) + react: 19.2.4 + three: 0.179.1 + transitivePeerDependencies: + - '@types/three' + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.0.12)(react@19.2.4)(redux@5.0.1))(react@19.2.4)': dependencies: '@standard-schema/spec': 1.1.0 @@ -24367,7 +24465,7 @@ snapshots: '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) '@vanilla-extract/babel-plugin-debug-ids': 1.2.2 '@vanilla-extract/css': 1.18.0(babel-plugin-macros@3.1.0) - esbuild: 0.17.6 + esbuild: 0.18.20 eval: 0.1.8 find-up: 5.0.0 javascript-stringify: 2.1.0 @@ -25029,7 +25127,7 @@ snapshots: axios@1.7.9(debug@4.4.3): dependencies: follow-redirects: 1.15.11(debug@4.4.3) - form-data: 4.0.0 + form-data: 4.0.5 proxy-from-env: 1.1.0 transitivePeerDependencies: - debug @@ -25248,7 +25346,7 @@ snapshots: bytes: 3.1.2 content-type: 1.0.5 debug: 4.4.3 - http-errors: 2.0.0 + http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 qs: 6.15.0 @@ -27707,7 +27805,7 @@ snapshots: escape-html: 1.0.3 on-finished: 2.4.1 parseurl: 1.3.3 - statuses: 2.0.1 + statuses: 2.0.2 transitivePeerDependencies: - supports-color optional: true @@ -33274,7 +33372,7 @@ snapshots: depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 - path-to-regexp: 8.2.0 + path-to-regexp: 8.3.0 transitivePeerDependencies: - supports-color optional: true @@ -34601,6 +34699,14 @@ snapshots: - immer - react + tunnel-rat@0.1.2(@types/react@19.0.12)(immer@11.1.4)(react@19.2.4): + dependencies: + zustand: 4.5.7(@types/react@19.0.12)(immer@11.1.4)(react@19.2.4) + transitivePeerDependencies: + - '@types/react' + - immer + - react + turbo-stream@2.4.0: {} turbo-stream@2.4.1: {} @@ -35276,7 +35382,7 @@ snapshots: typescript: 5.9.3 vitest: 4.0.9(@types/debug@4.1.12)(@types/node@24.0.15)(@vitest/ui@4.0.9)(happy-dom@16.8.1)(jiti@2.6.1)(jsdom@26.0.0(canvas@3.2.1))(less@4.5.1)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.64.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/ui@4.0.9(vitest@4.0.9))(happy-dom@16.8.1)(jiti@2.6.1)(jsdom@26.0.0(canvas@3.2.1))(less@4.5.1)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.64.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/ui@4.0.9)(happy-dom@16.8.1)(jiti@2.6.1)(jsdom@26.0.0(canvas@3.2.1))(less@4.5.1)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.64.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.64.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) @@ -35816,6 +35922,14 @@ snapshots: immer: 10.2.0 react: 19.2.4 + zustand@4.5.7(@types/react@19.0.12)(immer@11.1.4)(react@19.2.4): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.0.12 + immer: 11.1.4 + react: 19.2.4 + zustand@5.0.11(@types/react@19.0.12)(immer@10.2.0)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): optionalDependencies: '@types/react': 19.0.12 @@ -35823,4 +35937,11 @@ snapshots: react: 19.2.4 use-sync-external-store: 1.6.0(react@19.2.4) + zustand@5.0.11(@types/react@19.0.12)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): + optionalDependencies: + '@types/react': 19.0.12 + immer: 11.1.4 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + zwitch@2.0.4: {} From ceba407255572b604ba1e6a0a8c1f425adcbb7c0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 25 Feb 2026 11:10:06 +0000 Subject: [PATCH 13/14] fix(three): replace ?raw SVG imports with inlined svg-data module for publishable build Co-authored-by: richard --- packages/three/src/controls/transform-controls.ts | 3 +-- packages/three/src/icons/svg-data.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 packages/three/src/icons/svg-data.ts diff --git a/packages/three/src/controls/transform-controls.ts b/packages/three/src/controls/transform-controls.ts index 9b8ff2278..9d2c49b6d 100644 --- a/packages/three/src/controls/transform-controls.ts +++ b/packages/three/src/controls/transform-controls.ts @@ -32,8 +32,7 @@ import { MeshMatcapMaterial, LineDashedMaterial, } from 'three'; -import translationArrowSvg from '#icons/translation-arrow.svg?raw'; -import rotationArrowSvg from '#icons/rotation-arrow.svg?raw'; +import { translationArrowSvg, rotationArrowSvg } from '#icons/svg-data.js'; import { SvgGeometry } from '#geometries/svg-geometry.js'; import { matcapMaterial } from '#materials/matcap-material.js'; import { FontGeometry } from '#geometries/font-geometry.js'; diff --git a/packages/three/src/icons/svg-data.ts b/packages/three/src/icons/svg-data.ts new file mode 100644 index 000000000..7a1e8238a --- /dev/null +++ b/packages/three/src/icons/svg-data.ts @@ -0,0 +1,9 @@ +export const translationArrowSvg = ` + +`; + +export const rotationArrowSvg = ` + +`; From e782423f05d2ab4ec6c1a21fc12084041d6d84de Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Mar 2026 03:30:56 +0000 Subject: [PATCH 14/14] refactor(ui): delete old three/ directory and move app-specific bridge to cad/ - Move actor-bridge.tsx and webgl-context-lost-fallback.tsx to cad/ - Refactor CadViewer to integrate ActorBridge and WebGL context tracking directly, removing the now-unused ThreeProvider wrapper - Delete entire apps/ui/app/components/geometry/graphics/three/ directory (11,739 lines) - all components now live in @taucad/three package Co-authored-by: richard --- .../{graphics/three => cad}/actor-bridge.tsx | 0 .../components/geometry/cad/cad-viewer.tsx | 90 +- .../webgl-context-lost-fallback.tsx | 0 .../geometry/graphics/three/controls.tsx | 116 - .../three/controls/transform-controls.ts | 2096 ----------------- .../three/controls/viewport-gizmo-axes.tsx | 128 - .../controls/viewport-gizmo-cube-axes.ts | 133 -- .../three/controls/viewport-gizmo-cube.tsx | 201 -- .../three/controls/viewport-gizmo-onshape.tsx | 198 -- .../three/geometries/circle-geometry.ts | 27 - .../three/geometries/font-geometry.ts | 16 - .../three/geometries/geist-mono.typeface.json | 1 - .../three/geometries/label-geometry.ts | 45 - .../geometries/rounded-rectangle-geometry.ts | 61 - .../graphics/three/geometries/svg-geometry.ts | 53 - .../geometry/graphics/three/grid.tsx | 37 - .../three/icons/rotate-icon-single.svg | 14 - .../graphics/three/icons/rotate-icon.svg | 11 - .../graphics/three/icons/rotation-arrow.svg | 3 - .../three/icons/translation-arrow.svg | 5 - .../graphics/three/materials/gltf-edges.ts | 380 --- .../graphics/three/materials/gltf-matcap.ts | 132 -- .../three/materials/infinite-grid-material.ts | 348 --- .../three/materials/matcap-material.ts | 42 - .../three/materials/striped-material.ts | 133 -- .../graphics/three/post-processing.tsx | 39 - .../graphics/three/react/axes-helper.tsx | 109 - .../graphics/three/react/gltf-mesh.tsx | 335 --- .../graphics/three/react/infinite-grid.tsx | 76 - .../geometry/graphics/three/react/lights.tsx | 231 -- .../graphics/three/react/measure-tool.tsx | 901 ------- .../three/react/section-view-controls.tsx | 648 ----- .../graphics/three/react/section-view.tsx | 279 --- .../three/react/transform-controls-drei.tsx | 201 -- .../geometry/graphics/three/scene-overlay.tsx | 77 - .../geometry/graphics/three/scene.tsx | 48 - .../geometry/graphics/three/stage.tsx | 109 - .../geometry/graphics/three/three-context.tsx | 114 - .../graphics/three/up-direction-handler.tsx | 64 - .../graphics/three/use-camera-framing.test.ts | 275 --- .../graphics/three/use-camera-framing.ts | 125 - .../graphics/three/use-camera-reset.tsx | 111 - .../graphics/three/use-geometry-bounds.ts | 142 -- .../graphics/three/use-section-view.ts | 107 - .../graphics/three/utils/camera.utils.test.ts | 646 ----- .../graphics/three/utils/camera.utils.ts | 343 --- .../graphics/three/utils/gizmo.utils.ts | 135 -- .../graphics/three/utils/lights.utils.test.ts | 762 ------ .../graphics/three/utils/lights.utils.ts | 314 --- .../graphics/three/utils/math.utils.test.ts | 383 --- .../graphics/three/utils/math.utils.ts | 195 -- .../graphics/three/utils/rotation.utils.ts | 69 - .../three/utils/snap-detection.utils.ts | 697 ------ .../graphics/three/utils/spatial.utils.ts | 18 - 54 files changed, 84 insertions(+), 11739 deletions(-) rename apps/ui/app/components/geometry/{graphics/three => cad}/actor-bridge.tsx (100%) rename apps/ui/app/components/geometry/{graphics/three => cad}/webgl-context-lost-fallback.tsx (100%) delete mode 100644 apps/ui/app/components/geometry/graphics/three/controls.tsx delete mode 100644 apps/ui/app/components/geometry/graphics/three/controls/transform-controls.ts delete mode 100644 apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-axes.tsx delete mode 100644 apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-cube-axes.ts delete mode 100644 apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-cube.tsx delete mode 100644 apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-onshape.tsx delete mode 100644 apps/ui/app/components/geometry/graphics/three/geometries/circle-geometry.ts delete mode 100644 apps/ui/app/components/geometry/graphics/three/geometries/font-geometry.ts delete mode 100644 apps/ui/app/components/geometry/graphics/three/geometries/geist-mono.typeface.json delete mode 100644 apps/ui/app/components/geometry/graphics/three/geometries/label-geometry.ts delete mode 100644 apps/ui/app/components/geometry/graphics/three/geometries/rounded-rectangle-geometry.ts delete mode 100644 apps/ui/app/components/geometry/graphics/three/geometries/svg-geometry.ts delete mode 100644 apps/ui/app/components/geometry/graphics/three/grid.tsx delete mode 100644 apps/ui/app/components/geometry/graphics/three/icons/rotate-icon-single.svg delete mode 100644 apps/ui/app/components/geometry/graphics/three/icons/rotate-icon.svg delete mode 100644 apps/ui/app/components/geometry/graphics/three/icons/rotation-arrow.svg delete mode 100644 apps/ui/app/components/geometry/graphics/three/icons/translation-arrow.svg delete mode 100644 apps/ui/app/components/geometry/graphics/three/materials/gltf-edges.ts delete mode 100644 apps/ui/app/components/geometry/graphics/three/materials/gltf-matcap.ts delete mode 100644 apps/ui/app/components/geometry/graphics/three/materials/infinite-grid-material.ts delete mode 100644 apps/ui/app/components/geometry/graphics/three/materials/matcap-material.ts delete mode 100644 apps/ui/app/components/geometry/graphics/three/materials/striped-material.ts delete mode 100644 apps/ui/app/components/geometry/graphics/three/post-processing.tsx delete mode 100644 apps/ui/app/components/geometry/graphics/three/react/axes-helper.tsx delete mode 100644 apps/ui/app/components/geometry/graphics/three/react/gltf-mesh.tsx delete mode 100644 apps/ui/app/components/geometry/graphics/three/react/infinite-grid.tsx delete mode 100644 apps/ui/app/components/geometry/graphics/three/react/lights.tsx delete mode 100644 apps/ui/app/components/geometry/graphics/three/react/measure-tool.tsx delete mode 100644 apps/ui/app/components/geometry/graphics/three/react/section-view-controls.tsx delete mode 100644 apps/ui/app/components/geometry/graphics/three/react/section-view.tsx delete mode 100644 apps/ui/app/components/geometry/graphics/three/react/transform-controls-drei.tsx delete mode 100644 apps/ui/app/components/geometry/graphics/three/scene-overlay.tsx delete mode 100644 apps/ui/app/components/geometry/graphics/three/scene.tsx delete mode 100644 apps/ui/app/components/geometry/graphics/three/stage.tsx delete mode 100644 apps/ui/app/components/geometry/graphics/three/three-context.tsx delete mode 100644 apps/ui/app/components/geometry/graphics/three/up-direction-handler.tsx delete mode 100644 apps/ui/app/components/geometry/graphics/three/use-camera-framing.test.ts delete mode 100644 apps/ui/app/components/geometry/graphics/three/use-camera-framing.ts delete mode 100644 apps/ui/app/components/geometry/graphics/three/use-camera-reset.tsx delete mode 100644 apps/ui/app/components/geometry/graphics/three/use-geometry-bounds.ts delete mode 100644 apps/ui/app/components/geometry/graphics/three/use-section-view.ts delete mode 100644 apps/ui/app/components/geometry/graphics/three/utils/camera.utils.test.ts delete mode 100644 apps/ui/app/components/geometry/graphics/three/utils/camera.utils.ts delete mode 100644 apps/ui/app/components/geometry/graphics/three/utils/gizmo.utils.ts delete mode 100644 apps/ui/app/components/geometry/graphics/three/utils/lights.utils.test.ts delete mode 100644 apps/ui/app/components/geometry/graphics/three/utils/lights.utils.ts delete mode 100644 apps/ui/app/components/geometry/graphics/three/utils/math.utils.test.ts delete mode 100644 apps/ui/app/components/geometry/graphics/three/utils/math.utils.ts delete mode 100644 apps/ui/app/components/geometry/graphics/three/utils/rotation.utils.ts delete mode 100644 apps/ui/app/components/geometry/graphics/three/utils/snap-detection.utils.ts delete mode 100644 apps/ui/app/components/geometry/graphics/three/utils/spatial.utils.ts diff --git a/apps/ui/app/components/geometry/graphics/three/actor-bridge.tsx b/apps/ui/app/components/geometry/cad/actor-bridge.tsx similarity index 100% rename from apps/ui/app/components/geometry/graphics/three/actor-bridge.tsx rename to apps/ui/app/components/geometry/cad/actor-bridge.tsx diff --git a/apps/ui/app/components/geometry/cad/cad-viewer.tsx b/apps/ui/app/components/geometry/cad/cad-viewer.tsx index d9b430238..894531b42 100644 --- a/apps/ui/app/components/geometry/cad/cad-viewer.tsx +++ b/apps/ui/app/components/geometry/cad/cad-viewer.tsx @@ -1,10 +1,14 @@ -import { memo } from 'react'; +import { memo, useCallback, useEffect, useState } from 'react'; import type { Geometry } from '@taucad/types'; import { GltfMesh, CadCanvas } from '@taucad/three/react'; import type { StageOptions } from '@taucad/three/react'; import { SvgViewer } from '#components/geometry/graphics/svg/svg-viewer.js'; import { WebglErrorBoundary } from '#components/geometry/cad/webgl-error-boundary.js'; -import { WebglErrorFallback } from '#components/geometry/cad/webgl-fallback.js'; +import { WebglErrorFallback, WebglLimitFallback } from '#components/geometry/cad/webgl-fallback.js'; +import { WebglContextLostFallback } from '#components/geometry/cad/webgl-context-lost-fallback.js'; +import { ActorBridge } from '#components/geometry/cad/actor-bridge.js'; +import { cn } from '#utils/ui.utils.js'; +import { useWebglContextRef } from '#hooks/use-webgl-context-tracker.js'; type CadViewerProperties = { readonly geometries: Geometry[]; @@ -23,7 +27,6 @@ type CadViewerProperties = { readonly stageOptions?: StageOptions; readonly zoomSpeed?: number; readonly gizmoContainer?: HTMLElement | string; - readonly enablePostProcessing?: boolean; }; export const CadViewer = memo( @@ -32,11 +35,11 @@ export const CadViewer = memo( enableSurfaces = true, enableLines = true, enableMatcap = false, + className, ...properties }: CadViewerProperties): React.JSX.Element => { const svgGeometries = geometries.filter((geometry) => geometry.format === 'svg'); - // If there are any SVG geometries, we render them in a SVG viewer if (svgGeometries.length > 0) { return ( @@ -45,7 +48,7 @@ export const CadViewer = memo( return ( }> - + {geometries.map((geometry) => { switch (geometry.format) { case 'gltf': { @@ -74,8 +77,83 @@ export const CadViewer = memo( } } })} - + ); }, ); + +type CadViewerCanvasProperties = { + readonly children: React.ReactNode; + readonly enableGizmo?: boolean; + readonly enableGrid?: boolean; + readonly enableAxes?: boolean; + readonly enableZoom?: boolean; + readonly enablePan?: boolean; + readonly enableDamping?: boolean; + readonly upDirection?: 'x' | 'y' | 'z'; + readonly className?: string; + readonly enableCentering?: boolean; + readonly stageOptions?: StageOptions; + readonly zoomSpeed?: number; + readonly gizmoContainer?: HTMLElement | string; +}; + +function CadViewerCanvas({ + children, + className, + ...properties +}: CadViewerCanvasProperties): React.JSX.Element { + const [isContextLost, setIsContextLost] = useState(false); + + const webglRef = useWebglContextRef(); + + // eslint-disable-next-line react/hook-use-state -- one-time snapshot, setter intentionally unused + const [isOverLimit] = useState(() => { + if (!webglRef) { + return false; + } + + const snap = webglRef.getSnapshot(); + return snap.context.count >= snap.context.limit; + }); + + useEffect(() => { + if (!webglRef || isOverLimit) { + return; + } + + webglRef.send({ type: 'acquire' }); + return () => { + webglRef.send({ type: 'release' }); + }; + }, [webglRef, isOverLimit]); + + const [canvasKey, setCanvasKey] = useState(0); + const handleRetry = useCallback(() => { + setIsContextLost(false); + setCanvasKey((previous) => previous + 1); + }, []); + + if (isOverLimit) { + return ; + } + + if (isContextLost) { + return ; + } + + return ( + { + setIsContextLost(true); + }} + {...properties} + > + {children} + + + ); +} diff --git a/apps/ui/app/components/geometry/graphics/three/webgl-context-lost-fallback.tsx b/apps/ui/app/components/geometry/cad/webgl-context-lost-fallback.tsx similarity index 100% rename from apps/ui/app/components/geometry/graphics/three/webgl-context-lost-fallback.tsx rename to apps/ui/app/components/geometry/cad/webgl-context-lost-fallback.tsx diff --git a/apps/ui/app/components/geometry/graphics/three/controls.tsx b/apps/ui/app/components/geometry/graphics/three/controls.tsx deleted file mode 100644 index bf3a508a8..000000000 --- a/apps/ui/app/components/geometry/graphics/three/controls.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { OrbitControls } from '@react-three/drei'; -import React from 'react'; -import type * as THREE from 'three'; -import { ViewportGizmoCube } from '#components/geometry/graphics/three/controls/viewport-gizmo-cube.js'; -import { SectionViewControls } from '#components/geometry/graphics/three/react/section-view-controls.js'; -import { MeasureTool } from '#components/geometry/graphics/three/react/measure-tool.js'; -import { useGraphics, useGraphicsSelector } from '#hooks/use-graphics.js'; - -type ControlsProperties = { - /** - * @description Whether to enable the gizmo for the viewport. - */ - readonly enableGizmo: boolean; - /** - * @description Whether to enable damping for the camera. - */ - readonly enableDamping: boolean; - /** - * @description Whether to enable zooming for the camera. - */ - readonly enableZoom: boolean; - /** - * @description Whether to enable panning for the camera. - */ - readonly enablePan: boolean; - /** - * @description The speed of the camera zoom. - */ - readonly zoomSpeed: number; - /** - * A container element or selector to append the gizmo to. - */ - readonly gizmoContainer?: HTMLElement | string; -}; - -export const Controls = React.memo(function ({ - enableGizmo, - enableDamping, - enableZoom, - enablePan, - zoomSpeed, - gizmoContainer, -}: ControlsProperties) { - const graphicsActor = useGraphics(); - const isActive = useGraphicsSelector((state) => state.context.isSectionViewActive); - const selectedPlaneId = useGraphicsSelector((state) => state.context.selectedSectionViewId); - const rotation = useGraphicsSelector((state) => state.context.sectionViewRotation); - const pivot = useGraphicsSelector((state) => state.context.sectionViewPivot); - const availablePlanes = useGraphicsSelector((state) => state.context.availableSectionViews); - const planeName = useGraphicsSelector((state) => state.context.planeName); - const hoveredSectionViewId = useGraphicsSelector((state) => state.context.hoveredSectionViewId); - const upDirection = useGraphicsSelector((state) => state.context.upDirection); - - // Handlers to send events to xstate - const handleSelectPlane = (planeId: 'xy' | 'xz' | 'yz' | 'yx' | 'zx' | 'zy'): void => { - const id = planeId.toLowerCase() as 'xy' | 'xz' | 'yz' | 'yx' | 'zx' | 'zy'; - const isInverse = id === 'yx' || id === 'zx' || id === 'zy'; - const base: 'xy' | 'xz' | 'yz' = ((): 'xy' | 'xz' | 'yz' => { - if (id === 'xy' || id === 'yx') { - return 'xy'; - } - - if (id === 'xz' || id === 'zx') { - return 'xz'; - } - - return 'yz'; - })(); - const newDir: 1 | -1 = isInverse ? -1 : 1; - graphicsActor.send({ type: 'selectSectionView', payload: base }); - graphicsActor.send({ type: 'setSectionViewDirection', payload: newDir }); - }; - - const handleSetRotation = (eulerRotation: THREE.Euler): void => { - graphicsActor.send({ - type: 'setSectionViewRotation', - payload: [eulerRotation.x, eulerRotation.y, eulerRotation.z], - }); - }; - - const handleSetPivot = (value: [number, number, number]): void => { - graphicsActor.send({ type: 'setSectionViewPivot', payload: value }); - }; - - const handleHover = (planeId: 'xy' | 'xz' | 'yz' | 'yx' | 'zx' | 'zy' | undefined): void => { - graphicsActor.send({ type: 'setHoveredSectionView', payload: planeId }); - }; - - return ( - <> - - - - {enableGizmo ? : null} - - ); -}); diff --git a/apps/ui/app/components/geometry/graphics/three/controls/transform-controls.ts b/apps/ui/app/components/geometry/graphics/three/controls/transform-controls.ts deleted file mode 100644 index fff8ae203..000000000 --- a/apps/ui/app/components/geometry/graphics/three/controls/transform-controls.ts +++ /dev/null @@ -1,2096 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-call -- TODO: fix types here */ -/* eslint-disable max-lines -- This is a port of the original transform-controls.ts file */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment -- TODO: fix types here */ -/* eslint-disable @typescript-eslint/naming-convention -- This is a port of the original transform-controls.ts file */ -/* eslint-disable new-cap -- This is a port of the original transform-controls.ts file */ -/* eslint-disable @typescript-eslint/class-literal-property-style -- This is a port of the original transform-controls.ts file */ -/* eslint-disable max-depth -- This is a port of the original transform-controls.ts file */ -/* eslint-disable complexity -- This is a port of the original transform-controls.ts file */ -import type { OrthographicCamera, PerspectiveCamera, Intersection, Camera, Vector2 } from 'three'; -import { - Material, - BoxGeometry, - BufferGeometry, - Color, - CylinderGeometry, - DoubleSide, - Euler, - Float32BufferAttribute, - Line, - LineBasicMaterial, - Matrix4, - Mesh, - MeshBasicMaterial, - Object3D, - OctahedronGeometry, - PlaneGeometry, - Quaternion, - Raycaster, - SphereGeometry, - TorusGeometry, - Vector3, - MeshMatcapMaterial, - LineDashedMaterial, -} from 'three'; -import translationArrowSvg from '#components/geometry/graphics/three/icons/translation-arrow.svg?raw'; -import rotationArrowSvg from '#components/geometry/graphics/three/icons/rotation-arrow.svg?raw'; -import { SvgGeometry } from '#components/geometry/graphics/three/geometries/svg-geometry.js'; -import { matcapMaterial } from '#components/geometry/graphics/three/materials/matcap-material.js'; -import { FontGeometry } from '#components/geometry/graphics/three/geometries/font-geometry.js'; -import { RoundedRectangleGeometry } from '#components/geometry/graphics/three/geometries/rounded-rectangle-geometry.js'; -import { CircleGeometry } from '#components/geometry/graphics/three/geometries/circle-geometry.js'; - -export type TransformControlsPointerObject = { - x: number; - y: number; - button: number; -}; - -class TransformControls extends Object3D { - public readonly isTransformControls = true; - - public override visible = false; - - private domElement: HTMLElement | undefined; - - private readonly raycaster = new Raycaster(); - - private gizmo: TransformControlsGizmo; - private plane: TransformControlsPlane; - - private readonly tempVector = new Vector3(); - private readonly tempVector2 = new Vector3(); - private readonly tempQuaternion = new Quaternion(); - private readonly unit = { - X: new Vector3(1, 0, 0), - Y: new Vector3(0, 1, 0), - Z: new Vector3(0, 0, 1), - }; - - private readonly pointStart = new Vector3(); - private readonly pointEnd = new Vector3(); - private readonly offset = new Vector3(); - private readonly rotationAxis = new Vector3(); - private readonly startNorm = new Vector3(); - private readonly endNorm = new Vector3(); - private rotationAngle = 0; - - private readonly cameraPosition = new Vector3(); - private readonly cameraQuaternion = new Quaternion(); - private readonly cameraScale = new Vector3(); - - private readonly parentPosition = new Vector3(); - private readonly parentQuaternion = new Quaternion(); - private readonly parentQuaternionInv = new Quaternion(); - private readonly parentScale = new Vector3(); - - private readonly worldPositionStart = new Vector3(); - private readonly worldQuaternionStart = new Quaternion(); - private readonly worldScaleStart = new Vector3(); - - private readonly worldPosition = new Vector3(); - private readonly worldQuaternion = new Quaternion(); - private readonly worldQuaternionInv = new Quaternion(); - private readonly worldScale = new Vector3(); - - private readonly eye = new Vector3(); - - private readonly positionStart = new Vector3(); - private readonly quaternionStart = new Quaternion(); - private readonly scaleStart = new Vector3(); - - private readonly camera: TCamera; - private object: Object3D | undefined; - private readonly enabled: boolean = true; - private axis: string | undefined = undefined; - private mode: 'translate' | 'rotate' | 'scale' = 'translate'; - private translationSnap: number | undefined = undefined; - private rotationSnap: number | undefined = undefined; - private scaleSnap: number | undefined = undefined; - private space = 'world'; - private size = 1; - private dragging = false; - private readonly showX = true; - private readonly showY = true; - private readonly showZ = true; - - // Events - private readonly changeEvent = { type: 'change' }; - private readonly pointerDownEvent = { type: 'pointerDown', mode: this.mode }; - private readonly pointerUpEvent = { type: 'pointerUp', mode: this.mode }; - private readonly objectChangeEvent = { type: 'objectChange' }; - - public constructor(camera: TCamera, domElement: HTMLElement | undefined) { - super(); - - this.domElement = domElement; - this.camera = camera; - - this.gizmo = new TransformControlsGizmo(); - this.add(this.gizmo); - - this.plane = new TransformControlsPlane(); - this.add(this.plane); - - // Defined getter, setter and store for a property - const defineProperty = (propName: string, defaultValue: TValue): void => { - let propValue = defaultValue; - - Object.defineProperty(this, propName, { - get() { - return propValue ?? defaultValue; - }, - - set(value) { - if (propValue !== value) { - propValue = value; - this.plane[propName] = value; - this.gizmo[propName] = value; - - this.dispatchEvent({ type: propName + '-changed', value }); - this.dispatchEvent(this.changeEvent); - } - }, - }); - - // @ts-expect-error -- custom controls event, needs augmentation - this[propName] = defaultValue; - // @ts-expect-error -- custom controls event, needs augmentation - this.plane[propName] = defaultValue; - // @ts-expect-error -- custom controls event, needs augmentation - this.gizmo[propName] = defaultValue; - }; - - defineProperty('camera', this.camera); - defineProperty('object', this.object); - defineProperty('enabled', this.enabled); - defineProperty('axis', this.axis); - defineProperty('mode', this.mode); - defineProperty('translationSnap', this.translationSnap); - defineProperty('rotationSnap', this.rotationSnap); - defineProperty('scaleSnap', this.scaleSnap); - defineProperty('space', this.space); - defineProperty('size', this.size); - defineProperty('dragging', this.dragging); - defineProperty('showX', this.showX); - defineProperty('showY', this.showY); - defineProperty('showZ', this.showZ); - defineProperty('worldPosition', this.worldPosition); - defineProperty('worldPositionStart', this.worldPositionStart); - defineProperty('worldQuaternion', this.worldQuaternion); - defineProperty('worldQuaternionStart', this.worldQuaternionStart); - defineProperty('cameraPosition', this.cameraPosition); - defineProperty('cameraQuaternion', this.cameraQuaternion); - defineProperty('pointStart', this.pointStart); - defineProperty('pointEnd', this.pointEnd); - defineProperty('rotationAxis', this.rotationAxis); - defineProperty('rotationAngle', this.rotationAngle); - defineProperty('eye', this.eye); - - // Connect events - if (domElement !== undefined) { - this.connect(domElement); - } - } - - // Set current object - public override attach = (object: Object3D): this => { - this.object = object; - this.visible = true; - - return this; - }; - - // Detatch from object - public detach = (): this => { - this.object = undefined; - this.visible = false; - this.axis = undefined; - - return this; - }; - - // Reset - public reset = (): this => { - if (!this.enabled) { - return this; - } - - if (this.dragging && this.object !== undefined) { - this.object.position.copy(this.positionStart); - this.object.quaternion.copy(this.quaternionStart); - this.object.scale.copy(this.scaleStart); - // @ts-expect-error -- custom controls event, needs augmentation - this.dispatchEvent(this.changeEvent); - // @ts-expect-error -- custom controls event, needs augmentation - this.dispatchEvent(this.objectChangeEvent); - this.pointStart.copy(this.pointEnd); - } - - return this; - }; - - public override updateMatrixWorld = (): void => { - if (this.object !== undefined) { - this.object.updateMatrixWorld(); - - if (this.object.parent === null) { - console.error('TransformControls: The attached 3D object must be a part of the scene graph.'); - } else { - this.object.parent.matrixWorld.decompose(this.parentPosition, this.parentQuaternion, this.parentScale); - } - - this.object.matrixWorld.decompose(this.worldPosition, this.worldQuaternion, this.worldScale); - - this.parentQuaternionInv.copy(this.parentQuaternion).invert(); - this.worldQuaternionInv.copy(this.worldQuaternion).invert(); - } - - this.camera.updateMatrixWorld(); - this.camera.matrixWorld.decompose(this.cameraPosition, this.cameraQuaternion, this.cameraScale); - - this.eye.copy(this.cameraPosition).sub(this.worldPosition).normalize(); - - super.updateMatrixWorld(); - }; - - public getMode = (): TransformControls['mode'] => this.mode; - - public setMode = (mode: TransformControls['mode']): void => { - this.mode = mode; - }; - - public setTranslationSnap = (translationSnap: number): void => { - this.translationSnap = translationSnap; - }; - - public setRotationSnap = (rotationSnap: number): void => { - this.rotationSnap = rotationSnap; - }; - - public setScaleSnap = (scaleSnap: number): void => { - this.scaleSnap = scaleSnap; - }; - - public setSize = (size: number): void => { - this.size = size; - }; - - public setSpace = (space: string): void => { - this.space = space; - }; - - public update = (): void => { - console.warn( - 'THREE.TransformControls: update function has no more functionality and therefore has been deprecated.', - ); - }; - - public connect = (domElement: HTMLElement): void => { - if ((domElement as unknown) === document) { - console.error( - 'THREE.OrbitControls: "document" should not be used as the target "domElement". Please use "renderer.domElement" instead.', - ); - } - - this.domElement = domElement; - - this.domElement.addEventListener('pointerdown', this.onPointerDown); - this.domElement.addEventListener('pointermove', this.onPointerHover); - this.domElement.ownerDocument.addEventListener('pointerup', this.onPointerUp); - }; - - public dispose = (): void => { - this.domElement?.removeEventListener('pointerdown', this.onPointerDown); - this.domElement?.removeEventListener('pointermove', this.onPointerHover); - this.domElement?.ownerDocument.removeEventListener('pointermove', this.onPointerMove); - this.domElement?.ownerDocument.removeEventListener('pointerup', this.onPointerUp); - - this.traverse((child) => { - const mesh = child as Mesh; - if (mesh.geometry instanceof BufferGeometry) { - mesh.geometry.dispose(); - } - - if (mesh.material instanceof Material) { - mesh.material.dispose(); - } - }); - }; - - private readonly intersectObjectWithRay = ( - object: Object3D, - raycaster: Raycaster, - includeInvisible?: boolean, - ): false | Intersection => { - const allIntersections = raycaster.intersectObject(object, true); - - for (const allIntersection of allIntersections) { - if (allIntersection.object.visible || includeInvisible) { - return allIntersection; - } - } - - return false; - }; - - private readonly pointerHover = (pointer: TransformControlsPointerObject): void => { - if (this.object === undefined || this.dragging) { - return; - } - - this.raycaster.setFromCamera(pointer as unknown as Vector2, this.camera); - - const intersect = this.intersectObjectWithRay(this.gizmo.picker[this.mode], this.raycaster); - - this.axis = intersect ? intersect.object.name : undefined; - }; - - private readonly pointerDown = (pointer: TransformControlsPointerObject): void => { - if (this.object === undefined || this.dragging || pointer.button !== 0) { - return; - } - - if (this.axis !== undefined) { - this.raycaster.setFromCamera(pointer as unknown as Vector2, this.camera); - - const planeIntersect = this.intersectObjectWithRay(this.plane, this.raycaster, true); - - if (planeIntersect) { - let { space } = this; - - if (this.mode === 'scale') { - space = 'local'; - } else if (this.axis === 'E' || this.axis === 'XYZE' || this.axis === 'XYZ') { - space = 'world'; - } - - if (space === 'local' && this.mode === 'rotate') { - const snap = this.rotationSnap; - - if (this.axis === 'X' && snap) { - this.object.rotation.x = Math.round(this.object.rotation.x / snap) * snap; - } - - if (this.axis === 'Y' && snap) { - this.object.rotation.y = Math.round(this.object.rotation.y / snap) * snap; - } - - if (this.axis === 'Z' && snap) { - this.object.rotation.z = Math.round(this.object.rotation.z / snap) * snap; - } - } - - this.object.updateMatrixWorld(); - - if (this.object.parent) { - this.object.parent.updateMatrixWorld(); - } - - this.positionStart.copy(this.object.position); - this.quaternionStart.copy(this.object.quaternion); - this.scaleStart.copy(this.object.scale); - - this.object.matrixWorld.decompose(this.worldPositionStart, this.worldQuaternionStart, this.worldScaleStart); - - this.pointStart.copy(planeIntersect.point).sub(this.worldPositionStart); - } - - this.dragging = true; - this.pointerDownEvent.mode = this.mode; - // @ts-expect-error -- custom controls event, needs augmentation - this.dispatchEvent(this.pointerDownEvent); - } - }; - - private readonly pointerMove = (pointer: TransformControlsPointerObject): void => { - const { axis } = this; - const { mode } = this; - const { object } = this; - let { space } = this; - - if (mode === 'scale') { - space = 'local'; - } else if (axis === 'E' || axis === 'XYZE' || axis === 'XYZ') { - space = 'world'; - } - - if (object === undefined || axis === undefined || !this.dragging || pointer.button !== -1) { - return; - } - - this.raycaster.setFromCamera(pointer as unknown as Vector2, this.camera); - - const planeIntersect = this.intersectObjectWithRay(this.plane, this.raycaster, true); - - if (!planeIntersect) { - return; - } - - this.pointEnd.copy(planeIntersect.point).sub(this.worldPositionStart); - - switch (mode) { - case 'translate': { - // Apply translate - - this.offset.copy(this.pointEnd).sub(this.pointStart); - - if (space === 'local' && axis !== 'XYZ') { - this.offset.applyQuaternion(this.worldQuaternionInv); - } - - if (!axis.includes('X')) { - this.offset.x = 0; - } - - if (!axis.includes('Y')) { - this.offset.y = 0; - } - - if (!axis.includes('Z')) { - this.offset.z = 0; - } - - if (space === 'local' && axis !== 'XYZ') { - this.offset.applyQuaternion(this.quaternionStart).divide(this.parentScale); - } else { - this.offset.applyQuaternion(this.parentQuaternionInv).divide(this.parentScale); - } - - object.position.copy(this.offset).add(this.positionStart); - - // Apply translation snap - - if (this.translationSnap) { - if (space === 'local') { - object.position.applyQuaternion(this.tempQuaternion.copy(this.quaternionStart).invert()); - - if (axis.search('X') !== -1) { - object.position.x = Math.round(object.position.x / this.translationSnap) * this.translationSnap; - } - - if (axis.search('Y') !== -1) { - object.position.y = Math.round(object.position.y / this.translationSnap) * this.translationSnap; - } - - if (axis.search('Z') !== -1) { - object.position.z = Math.round(object.position.z / this.translationSnap) * this.translationSnap; - } - - object.position.applyQuaternion(this.quaternionStart); - } - - if (space === 'world') { - if (object.parent) { - object.position.add(this.tempVector.setFromMatrixPosition(object.parent.matrixWorld)); - } - - if (axis.search('X') !== -1) { - object.position.x = Math.round(object.position.x / this.translationSnap) * this.translationSnap; - } - - if (axis.search('Y') !== -1) { - object.position.y = Math.round(object.position.y / this.translationSnap) * this.translationSnap; - } - - if (axis.search('Z') !== -1) { - object.position.z = Math.round(object.position.z / this.translationSnap) * this.translationSnap; - } - - if (object.parent) { - object.position.sub(this.tempVector.setFromMatrixPosition(object.parent.matrixWorld)); - } - } - } - - break; - } - - case 'scale': { - if (axis.search('XYZ') === -1) { - this.tempVector.copy(this.pointStart); - this.tempVector2.copy(this.pointEnd); - - this.tempVector.applyQuaternion(this.worldQuaternionInv); - this.tempVector2.applyQuaternion(this.worldQuaternionInv); - - this.tempVector2.divide(this.tempVector); - - if (axis.search('X') === -1) { - this.tempVector2.x = 1; - } - - if (axis.search('Y') === -1) { - this.tempVector2.y = 1; - } - - if (axis.search('Z') === -1) { - this.tempVector2.z = 1; - } - } else { - let d = this.pointEnd.length() / this.pointStart.length(); - - if (this.pointEnd.dot(this.pointStart) < 0) { - d *= -1; - } - - this.tempVector2.set(d, d, d); - } - - // Apply scale - - object.scale.copy(this.scaleStart).multiply(this.tempVector2); - - if (this.scaleSnap && this.object) { - if (axis.search('X') !== -1) { - this.object.scale.x = Math.round(object.scale.x / this.scaleSnap) * this.scaleSnap || this.scaleSnap; - } - - if (axis.search('Y') !== -1) { - object.scale.y = Math.round(object.scale.y / this.scaleSnap) * this.scaleSnap || this.scaleSnap; - } - - if (axis.search('Z') !== -1) { - object.scale.z = Math.round(object.scale.z / this.scaleSnap) * this.scaleSnap || this.scaleSnap; - } - } - - break; - } - - case 'rotate': { - this.offset.copy(this.pointEnd).sub(this.pointStart); - - // Normalize rotation sensitivity by camera distance AND perspective FOV so drag - // speed feels consistent across different FOV values. - const cameraDistance = this.worldPosition.distanceTo( - this.tempVector.setFromMatrixPosition(this.camera.matrixWorld), - ); - const cameraMaybePerspective = this.camera as unknown as PerspectiveCamera; - let fovFactor = 1; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- type guard - if (cameraMaybePerspective.isPerspectiveCamera) { - fovFactor = Math.tan((cameraMaybePerspective.fov * Math.PI) / 360); - if (!Number.isFinite(fovFactor) || fovFactor === 0) { - fovFactor = 1; - } - } - - const ROTATION_SPEED = 20 / (cameraDistance * fovFactor); - - switch (axis) { - case 'E': { - this.rotationAxis.copy(this.eye); - this.rotationAngle = this.pointEnd.angleTo(this.pointStart); - - this.startNorm.copy(this.pointStart).normalize(); - this.endNorm.copy(this.pointEnd).normalize(); - - this.rotationAngle *= this.endNorm.cross(this.startNorm).dot(this.eye) < 0 ? 1 : -1; - - break; - } - - case 'XYZE': { - this.rotationAxis.copy(this.offset).cross(this.eye).normalize(); - this.rotationAngle = - this.offset.dot(this.tempVector.copy(this.rotationAxis).cross(this.eye)) * ROTATION_SPEED; - - break; - } - - case 'X': - case 'Y': - case 'Z': { - this.rotationAxis.copy(this.unit[axis]); - - this.tempVector.copy(this.unit[axis]); - - if (space === 'local') { - this.tempVector.applyQuaternion(this.worldQuaternion); - } - - this.rotationAngle = this.offset.dot(this.tempVector.cross(this.eye).normalize()) * ROTATION_SPEED; - - break; - } - // No default - } - - // Apply rotation snap - - if (this.rotationSnap) { - this.rotationAngle = Math.round(this.rotationAngle / this.rotationSnap) * this.rotationSnap; - } - - // Apply rotate - if (space === 'local' && axis !== 'E' && axis !== 'XYZE') { - object.quaternion.copy(this.quaternionStart); - object.quaternion - .multiply(this.tempQuaternion.setFromAxisAngle(this.rotationAxis, this.rotationAngle)) - .normalize(); - } else { - this.rotationAxis.applyQuaternion(this.parentQuaternionInv); - object.quaternion.copy(this.tempQuaternion.setFromAxisAngle(this.rotationAxis, this.rotationAngle)); - object.quaternion.multiply(this.quaternionStart).normalize(); - } - - break; - } - // No default - } - - // @ts-expect-error -- custom controls event, needs augmentation - this.dispatchEvent(this.changeEvent); - // @ts-expect-error -- custom controls event, needs augmentation - this.dispatchEvent(this.objectChangeEvent); - }; - - private readonly pointerUp = (pointer: TransformControlsPointerObject): void => { - if (pointer.button !== 0) { - return; - } - - if (this.dragging && this.axis !== undefined) { - this.pointerUpEvent.mode = this.mode; - // @ts-expect-error -- custom controls event, needs augmentation - this.dispatchEvent(this.pointerUpEvent); - } - - this.dragging = false; - this.axis = undefined; - }; - - private readonly getPointer = (event: Event): TransformControlsPointerObject => { - if (this.domElement?.ownerDocument.pointerLockElement) { - return { - x: 0, - y: 0, - button: (event as MouseEvent).button, - }; - } - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- type guard - const pointer = (event as TouchEvent).changedTouches - ? (event as TouchEvent).changedTouches[0]! - : (event as MouseEvent); - - const rect = this.domElement!.getBoundingClientRect(); - - return { - x: ((pointer.clientX - rect.left) / rect.width) * 2 - 1, - y: (-(pointer.clientY - rect.top) / rect.height) * 2 + 1, - button: (event as MouseEvent).button, - }; - }; - - private readonly onPointerHover = (event: Event): void => { - if (!this.enabled) { - return; - } - - switch ((event as PointerEvent).pointerType) { - case 'mouse': - case 'pen': { - this.pointerHover(this.getPointer(event)); - break; - } - } - }; - - private readonly onPointerDown = (event: Event): void => { - if (!this.enabled || !this.domElement) { - return; - } - - this.domElement.style.touchAction = 'none'; // Disable touch scroll - this.domElement.ownerDocument.addEventListener('pointermove', this.onPointerMove); - this.pointerHover(this.getPointer(event)); - this.pointerDown(this.getPointer(event)); - }; - - private readonly onPointerMove = (event: Event): void => { - if (!this.enabled) { - return; - } - - this.pointerMove(this.getPointer(event)); - }; - - private readonly onPointerUp = (event: Event): void => { - if (!this.enabled || !this.domElement) { - return; - } - - this.domElement.style.touchAction = ''; - this.domElement.ownerDocument.removeEventListener('pointermove', this.onPointerMove); - - this.pointerUp(this.getPointer(event)); - }; -} - -type TransformControlsGizmoPrivateGizmos = { - ['translate']: Object3D; - ['scale']: Object3D; - ['rotate']: Object3D; - ['visible']: boolean; -}; - -class TransformControlsGizmo extends Object3D { - public override type = 'TransformControlsGizmo'; - public isTransformControlsGizmo = true; - public picker: TransformControlsGizmoPrivateGizmos; - - private readonly tempVector = new Vector3(0, 0, 0); - private readonly tempEuler = new Euler(); - private readonly alignVector = new Vector3(0, 1, 0); - private readonly zeroVector = new Vector3(0, 0, 0); - private readonly lookAtMatrix = new Matrix4(); - private readonly tempQuaternion = new Quaternion(); - private readonly tempQuaternion2 = new Quaternion(); - private readonly identityQuaternion = new Quaternion(); - - private readonly unitX = new Vector3(1, 0, 0); - private readonly unitY = new Vector3(0, 1, 0); - private readonly unitZ = new Vector3(0, 0, 1); - - private readonly gizmo: TransformControlsGizmoPrivateGizmos; - private readonly helper: TransformControlsGizmoPrivateGizmos; - - // These are set from parent class TransformControls - private readonly rotationAxis = new Vector3(); - - private readonly cameraPosition = new Vector3(); - - private readonly worldPositionStart = new Vector3(); - private readonly worldQuaternionStart = new Quaternion(); - - private readonly worldPosition = new Vector3(); - private readonly worldQuaternion = new Quaternion(); - - private readonly eye = new Vector3(); - - private readonly camera: PerspectiveCamera | OrthographicCamera = undefined!; - private readonly enabled: boolean = true; - private readonly axis: string | undefined = undefined; - private readonly mode: 'translate' | 'rotate' | 'scale' = 'translate'; - private readonly space: 'world' | 'local' = 'world'; - private readonly size = 1; - private readonly dragging: boolean = false; - private readonly showX: boolean = true; - private readonly showY: boolean = true; - private readonly showZ: boolean = true; - - public constructor() { - super(); - - const matcapTexture = matcapMaterial(); - - const gizmoMaterial = new MeshMatcapMaterial({ - matcap: matcapTexture, - depthTest: false, - depthWrite: false, - transparent: true, - side: DoubleSide, - fog: false, - toneMapped: false, - }); - - const gizmoLineMaterial = new LineBasicMaterial({ - depthTest: false, - depthWrite: false, - transparent: true, - linewidth: 1, - fog: false, - toneMapped: false, - }); - - // Helper dotted line configuration (inline defaults) - const helperDashSize = 2; - const helperGapSize = 1.5; - - const helperLineDashedMaterial = new LineDashedMaterial({ - depthTest: false, - depthWrite: false, - transparent: true, - linewidth: 1, - fog: false, - toneMapped: false, - dashSize: helperDashSize, - gapSize: helperGapSize, - color: 0x00_00_00, - }); - - // Make unique material for each axis/color - const matInvisible = gizmoMaterial.clone(); - matInvisible.opacity = 0.15; - - const matHelper = gizmoMaterial.clone(); - matHelper.color.set(0x00_00_00); - matHelper.opacity = 0.5; - - const matLabelBackground = gizmoMaterial.clone(); - matLabelBackground.color.set(0xff_ff_ff); - matLabelBackground.visible = false; // TODO: Show label text, update text as transform changes - - const matLabelText = gizmoMaterial.clone(); - matLabelText.color.set(0x00_00_00); - matLabelText.visible = false; // TODO: Show label text, update text as transform changes - - const matRed = gizmoMaterial.clone(); - matRed.color.set(0xef_44_44); - - const matGreen = gizmoMaterial.clone(); - matGreen.color.set(0x22_c5_5e); - - const matBlue = gizmoMaterial.clone(); - matBlue.color.set(0x3b_82_f6); - - const matWhiteTransparent = gizmoMaterial.clone(); - matWhiteTransparent.opacity = 0.25; - - const matYellowTransparent = matWhiteTransparent.clone(); - matYellowTransparent.color.set(0xff_ff_00); - - const matCyanTransparent = matWhiteTransparent.clone(); - matCyanTransparent.color.set(0x00_ff_ff); - - const matMagentaTransparent = matWhiteTransparent.clone(); - matMagentaTransparent.color.set(0xff_00_ff); - - const matYellow = gizmoMaterial.clone(); - matYellow.color.set(0xff_ff_00); - - const matLineRed = gizmoLineMaterial.clone(); - matLineRed.color.set(0xef_44_44); - - const matLineGreen = gizmoLineMaterial.clone(); - matLineGreen.color.set(0x22_c5_5e); - - const matLineBlue = gizmoLineMaterial.clone(); - matLineBlue.color.set(0x3b_82_f6); - - const matLineCyan = gizmoLineMaterial.clone(); - matLineCyan.color.set(0x00_ff_ff); - - const matLineMagenta = gizmoLineMaterial.clone(); - matLineMagenta.color.set(0xff_00_ff); - - const matLineYellow = gizmoLineMaterial.clone(); - matLineYellow.color.set(0xff_ff_00); - - const matLineGray = gizmoLineMaterial.clone(); - matLineGray.color.set(0x78_78_78); - - const matLineYellowTransparent = matLineYellow.clone(); - matLineYellowTransparent.opacity = 0.25; - - // Reusable geometry - - const arrowDepth = 100; - const textDepth = 25; - const boxDepth = 200; - - const scaleHandleGeometry = new BoxGeometry(0.125, 0.125, 0.125); - - const translationArrowGeometry = SvgGeometry({ svg: translationArrowSvg, depth: arrowDepth }); - const rotationArrowGeometry = SvgGeometry({ svg: rotationArrowSvg, depth: arrowDepth }); - - const fontGeometryTranslation = FontGeometry({ text: '24 mm', depth: textDepth, size: 300 }); - const fontGeometryRotationX = FontGeometry({ text: '45°', depth: textDepth, size: 400 }); - const fontGeometryRotationY = FontGeometry({ text: '15°', depth: textDepth, size: 400 }); - const fontGeometryRotationZ = FontGeometry({ text: '67°', depth: textDepth, size: 400 }); - const roundedBoxGeometry = RoundedRectangleGeometry({ - width: 1500, - height: 700, - radius: 200, - smoothness: 16, - depth: 100, - }); - - const lineGeometry = new BufferGeometry(); - lineGeometry.setAttribute('position', new Float32BufferAttribute([0, 0, 0, 1, 0, 0], 3)); - - // Special geometry for transform helper. If scaled with position vector it spans from [0,0,0] to position - - const TranslateHelperGeometry = (): BufferGeometry => { - const geometry = new BufferGeometry(); - - geometry.setAttribute('position', new Float32BufferAttribute([0, 0, 0, 1, 1, 1], 3)); - - return geometry; - }; - - // Gizmo definitions - custom hierarchy definitions for setupGizmo() function - - const gizmoTranslationScaleFactor = 0.000_25; - const pickerTranslationScaleFactor = 0.000_25; - - const gizmoTranslationScale = [ - gizmoTranslationScaleFactor, - gizmoTranslationScaleFactor, - gizmoTranslationScaleFactor, - ]; - const pickerTranslationScale = [ - pickerTranslationScaleFactor, - pickerTranslationScaleFactor, - pickerTranslationScaleFactor, - ]; - const gizmoMeshOffset = 0.3; - const gizmoMeshTranslationTextOffset = 0.7; - - // Rotation text and box offsets - const gizmoRotationScaleFactor = 0.000_15; - const gizmoRotationScaleFactorZ = gizmoTranslationScaleFactor; - const pickerRotationScaleFactor = 0.0003; - const gizmoMeshRotationTextOffset = 1.2; - const gizmoTextBoxOffset = ((boxDepth + textDepth) / 2) * gizmoRotationScaleFactor; - const gizmoRotationScale = [gizmoRotationScaleFactor, gizmoRotationScaleFactor, gizmoRotationScaleFactorZ]; - const pickerRotationScale = [ - // - pickerRotationScaleFactor, - pickerRotationScaleFactor, - pickerRotationScaleFactor, - ]; - - // Order is: - // 1. The Object3D to render - // 2. The position of the object - // 3. The rotation of the object - // 4. The scale of the object - // 5. The name of the object - const gizmoTranslate = { - X: [ - [ - new Mesh(translationArrowGeometry, matRed), - [gizmoMeshOffset, 0, 0], - [Math.PI / 2, 0, -Math.PI / 2], - gizmoTranslationScale, - 'fwd-handle', - ], - [ - new Mesh(translationArrowGeometry, matRed), - [-gizmoMeshOffset, 0, 0], - [Math.PI / 2, 0, Math.PI / 2], - gizmoTranslationScale, - 'bwd-handle', - ], - [ - new Mesh(fontGeometryTranslation, matLabelText), - [gizmoMeshTranslationTextOffset, gizmoTextBoxOffset, 0], - [Math.PI / 2, Math.PI, Math.PI], - gizmoTranslationScale, - 'fwd-label', - ], - [ - new Mesh(roundedBoxGeometry, matLabelBackground), - [gizmoMeshTranslationTextOffset, 0, 0], - [Math.PI / 2, 0, 0], - gizmoTranslationScale, - 'fwd-label', - ], - [ - new Mesh(fontGeometryTranslation, matLabelText), - [-gizmoMeshTranslationTextOffset, gizmoTextBoxOffset, 0], - [Math.PI / 2, 0, Math.PI], - gizmoTranslationScale, - 'bwd-label', - ], - [ - new Mesh(roundedBoxGeometry, matLabelBackground), - [-gizmoMeshTranslationTextOffset, 0, 0], - [Math.PI / 2, 0, Math.PI], - gizmoTranslationScale, - 'bwd-label', - ], - ], - Y: [ - [ - new Mesh(translationArrowGeometry, matGreen), - [0, gizmoMeshOffset, 0], - undefined, - gizmoTranslationScale, - 'fwd-handle', - ], - [ - new Mesh(translationArrowGeometry, matGreen), - [0, -gizmoMeshOffset, 0], - [Math.PI, 0, 0], - gizmoTranslationScale, - 'bwd-handle', - ], - [ - new Mesh(fontGeometryTranslation, matLabelText), - [0, gizmoMeshTranslationTextOffset, gizmoTextBoxOffset], - [0, 0, Math.PI / 2], - gizmoTranslationScale, - 'fwd-label', - ], - [ - new Mesh(roundedBoxGeometry, matLabelBackground), - [0, gizmoMeshTranslationTextOffset, 0], - [0, 0, Math.PI / 2], - gizmoTranslationScale, - 'fwd-label', - ], - [ - new Mesh(fontGeometryTranslation, matLabelText), - [0, -gizmoMeshTranslationTextOffset, gizmoTextBoxOffset], - [Math.PI, 0, Math.PI / 2], - gizmoTranslationScale, - 'bwd-label', - ], - [ - new Mesh(roundedBoxGeometry, matLabelBackground), - [0, -gizmoMeshTranslationTextOffset, 0], - [0, 0, Math.PI / 2], - gizmoTranslationScale, - 'bwd-label', - ], - ], - Z: [ - [ - new Mesh(translationArrowGeometry, matBlue), - [0, 0, gizmoMeshOffset], - [Math.PI / 2, 0, 0], - gizmoTranslationScale, - 'fwd-handle', - ], - [ - new Mesh(translationArrowGeometry, matBlue), - [0, 0, -gizmoMeshOffset], - [-Math.PI / 2, 0, 0], - gizmoTranslationScale, - 'bwd-handle', - ], - [ - new Mesh(fontGeometryTranslation, matLabelText), - [0, gizmoTextBoxOffset, gizmoMeshTranslationTextOffset], - [Math.PI / 2, Math.PI, 0], - gizmoTranslationScale, - 'fwd-label', - ], - [ - new Mesh(roundedBoxGeometry, matLabelBackground), - [0, 0, gizmoMeshTranslationTextOffset], - [-Math.PI / 2, 0, 0], - gizmoTranslationScale, - 'fwd-label', - ], - [ - new Mesh(fontGeometryTranslation, matLabelText), - [0, gizmoTextBoxOffset, -gizmoMeshTranslationTextOffset], - [Math.PI / 2, 0, Math.PI], - gizmoTranslationScale, - 'bwd-label', - ], - [ - new Mesh(roundedBoxGeometry, matLabelBackground), - [0, 0, -gizmoMeshTranslationTextOffset], - [Math.PI / 2, 0, Math.PI], - gizmoTranslationScale, - 'bwd-label', - ], - ], - XYZ: [[new Mesh(new OctahedronGeometry(0.1, 0), matWhiteTransparent.clone()), [0, 0, 0], [0, 0, 0]]], - XY: [ - [new Mesh(new PlaneGeometry(0.295, 0.295), matYellowTransparent.clone()), [0.15, 0.15, 0]], - [new Line(lineGeometry, matLineYellow), [0.18, 0.3, 0], undefined, [0.125, 1, 1]], - [new Line(lineGeometry, matLineYellow), [0.3, 0.18, 0], [0, 0, Math.PI / 2], [0.125, 1, 1]], - ], - YZ: [ - [new Mesh(new PlaneGeometry(0.295, 0.295), matCyanTransparent.clone()), [0, 0.15, 0.15], [0, Math.PI / 2, 0]], - [new Line(lineGeometry, matLineCyan), [0, 0.18, 0.3], [0, 0, Math.PI / 2], [0.125, 1, 1]], - [new Line(lineGeometry, matLineCyan), [0, 0.3, 0.18], [0, -Math.PI / 2, 0], [0.125, 1, 1]], - ], - XZ: [ - [ - new Mesh(new PlaneGeometry(0.295, 0.295), matMagentaTransparent.clone()), - [0.15, 0, 0.15], - [-Math.PI / 2, 0, 0], - ], - [new Line(lineGeometry, matLineMagenta), [0.18, 0, 0.3], undefined, [0.125, 1, 1]], - [new Line(lineGeometry, matLineMagenta), [0.3, 0, 0.18], [0, -Math.PI / 2, 0], [0.125, 1, 1]], - ], - }; - - const pickerTranslate = { - X: [ - [ - new Mesh(translationArrowGeometry, matInvisible), - [gizmoMeshOffset, 0, 0], - [Math.PI / 2, 0, -Math.PI / 2], - pickerTranslationScale, - 'fwd-picker', - ], - [ - new Mesh(translationArrowGeometry, matInvisible), - [-gizmoMeshOffset, 0, 0], - [0, 0, Math.PI / 2], - pickerTranslationScale, - 'bwd-picker', - ], - ], - Y: [ - [ - new Mesh(translationArrowGeometry, matGreen), - [0, gizmoMeshOffset, 0], - undefined, - pickerTranslationScale, - 'fwd-picker', - ], - [ - new Mesh(translationArrowGeometry, matGreen), - [0, -gizmoMeshOffset, 0], - [Math.PI, 0, 0], - pickerTranslationScale, - 'bwd-picker', - ], - ], - Z: [ - [ - new Mesh(translationArrowGeometry, matBlue), - [0, 0, gizmoMeshOffset], - [Math.PI / 2, 0, 0], - pickerTranslationScale, - 'fwd-picker', - ], - [ - new Mesh(translationArrowGeometry, matBlue), - [0, 0, -gizmoMeshOffset], - [-Math.PI / 2, 0, 0], - pickerTranslationScale, - 'bwd-picker', - ], - ], - XYZ: [[new Mesh(new OctahedronGeometry(0.2, 0), matInvisible)]], - XY: [[new Mesh(new PlaneGeometry(0.4, 0.4), matInvisible), [0.2, 0.2, 0]]], - YZ: [[new Mesh(new PlaneGeometry(0.4, 0.4), matInvisible), [0, 0.2, 0.2], [0, Math.PI / 2, 0]]], - XZ: [[new Mesh(new PlaneGeometry(0.4, 0.4), matInvisible), [0.2, 0, 0.2], [-Math.PI / 2, 0, 0]]], - }; - - // Dashed translate helper line - const helperTranslateDeltaLine = new Line(TranslateHelperGeometry(), helperLineDashedMaterial.clone()); - helperTranslateDeltaLine.computeLineDistances(); - - const helperTranslate = { - START: [[new Mesh(new OctahedronGeometry(0.02, 2), matHelper), undefined, undefined, undefined, 'helper']], - END: [[new Mesh(new OctahedronGeometry(0.02, 2), matHelper), undefined, undefined, undefined, 'helper']], - DELTA: [[helperTranslateDeltaLine, undefined, undefined, undefined, 'helper']], - }; - - const gizmoRotate = { - X: [ - [new Line(CircleGeometry({ radius: 1, arc: 0.1, arcOffset: (Math.PI / 4) * 1.625 }), matLineRed)], - [new Mesh(rotationArrowGeometry, matRed), [0, 0, 1], [0, Math.PI / 2, Math.PI / 2], gizmoRotationScale], - [ - new Mesh(fontGeometryRotationX, matLabelText), - [gizmoTextBoxOffset, 0, gizmoMeshRotationTextOffset], - [Math.PI / 2, Math.PI / 2, 0], - gizmoRotationScale, - 'rotation-label', - ], - [ - new Mesh(roundedBoxGeometry, matLabelBackground), - [0, 0, gizmoMeshRotationTextOffset], - [Math.PI / 2, Math.PI / 2, 0], - gizmoRotationScale, - 'rotation-label', - ], - ], - Y: [ - [ - new Line(CircleGeometry({ radius: 1, arc: 0.1, arcOffset: (Math.PI / 4) * 1.625 }), matLineGreen), - undefined, - [0, 0, -Math.PI / 2], - ], - [new Mesh(rotationArrowGeometry, matGreen), [0, 0, 1], [Math.PI / 2, 0, 0], gizmoRotationScale], - [ - new Mesh(fontGeometryRotationY, matLabelText), - [0, -gizmoTextBoxOffset, gizmoMeshRotationTextOffset], - [Math.PI / 2, 0, 0], - gizmoRotationScale, - 'rotation-label', - ], - [ - new Mesh(roundedBoxGeometry, matLabelBackground), - [0, 0, gizmoMeshRotationTextOffset], - [Math.PI / 2, 0, 0], - gizmoRotationScale, - 'rotation-label', - ], - ], - Z: [ - [ - new Line(CircleGeometry({ radius: 1, arc: 0.1, arcOffset: (Math.PI / 4) * 1.625 }), matLineBlue), - undefined, - [0, Math.PI / 2, 0], - ], - [new Mesh(rotationArrowGeometry, matBlue), [1, 0, 0], [0, 0, -Math.PI / 2], gizmoRotationScale], - [ - new Mesh(fontGeometryRotationZ, matLabelText), - [gizmoMeshRotationTextOffset, 0, gizmoTextBoxOffset], - [0, 0, Math.PI / 2], - gizmoRotationScale, - 'rotation-label', - ], - [ - new Mesh(roundedBoxGeometry, matLabelBackground), - [gizmoMeshRotationTextOffset, 0, 0], - [0, 0, Math.PI / 2], - gizmoRotationScale, - 'rotation-label', - ], - ], - E: [ - [new Line(CircleGeometry({ radius: 1.25, arc: 1 }), matLineYellowTransparent), undefined, [0, Math.PI / 2, 0]], - [ - new Mesh(new CylinderGeometry(0.03, 0, 0.15, 4, 1, false), matLineYellowTransparent), - [1.17, 0, 0], - [0, 0, -Math.PI / 2], - [1, 1, 0.001], - ], - [ - new Mesh(new CylinderGeometry(0.03, 0, 0.15, 4, 1, false), matLineYellowTransparent), - [-1.17, 0, 0], - [0, 0, Math.PI / 2], - [1, 1, 0.001], - ], - [ - new Mesh(new CylinderGeometry(0.03, 0, 0.15, 4, 1, false), matLineYellowTransparent), - [0, -1.17, 0], - [Math.PI, 0, 0], - [1, 1, 0.001], - ], - [ - new Mesh(new CylinderGeometry(0.03, 0, 0.15, 4, 1, false), matLineYellowTransparent), - [0, 1.17, 0], - [0, 0, 0], - [1, 1, 0.001], - ], - ], - XYZE: [[new Line(CircleGeometry({ radius: 1, arc: 1 }), matLineGray), undefined, [0, Math.PI / 2, 0]]], - }; - - // Dashed rotate helper long axis - const helperRotateAxisLine = new Line(lineGeometry, helperLineDashedMaterial.clone()); - helperRotateAxisLine.computeLineDistances(); - - const helperRotate = { - AXIS: [[helperRotateAxisLine, [-1e3, 0, 0], undefined, [1e6, 1, 1], 'helper']], - }; - - const pickerRotate = { - X: [[new Mesh(rotationArrowGeometry, matRed), [0, 0, 1], [0, Math.PI / 2, Math.PI / 2], pickerRotationScale]], - Y: [[new Mesh(rotationArrowGeometry, matGreen), [0, 0, 1], [Math.PI / 2, 0, 0], pickerRotationScale]], - Z: [[new Mesh(rotationArrowGeometry, matBlue), [1, 0, 0], [0, 0, -Math.PI / 2], pickerRotationScale]], - // X: [[new Mesh(new TorusGeometry(1, 0.1, 4, 24), matInvisible), [0, 0, 0], [0, -Math.PI / 2, -Math.PI / 2]]], - // Y: [[new Mesh(new TorusGeometry(1, 0.1, 4, 24), matInvisible), [0, 0, 0], [Math.PI / 2, 0, 0]]], - // Z: [[new Mesh(new TorusGeometry(1, 0.1, 4, 24), matInvisible), [0, 0, 0], [0, 0, -Math.PI / 2]]], - E: [[new Mesh(new TorusGeometry(1.25, 0.1, 2, 24), matInvisible)]], - XYZE: [[new Mesh(new SphereGeometry(0.7, 10, 8), matInvisible)]], - }; - - const gizmoScale = { - X: [ - [new Mesh(scaleHandleGeometry, matRed), [0.8, 0, 0], [0, 0, -Math.PI / 2]], - [new Line(lineGeometry, matLineRed), undefined, undefined, [0.8, 1, 1]], - ], - Y: [ - [new Mesh(scaleHandleGeometry, matGreen), [0, 0.8, 0]], - [new Line(lineGeometry, matLineGreen), undefined, [0, 0, Math.PI / 2], [0.8, 1, 1]], - ], - Z: [ - [new Mesh(scaleHandleGeometry, matBlue), [0, 0, 0.8], [Math.PI / 2, 0, 0]], - [new Line(lineGeometry, matLineBlue), undefined, [0, -Math.PI / 2, 0], [0.8, 1, 1]], - ], - XY: [ - [new Mesh(scaleHandleGeometry, matYellowTransparent), [0.85, 0.85, 0], undefined, [2, 2, 0.2]], - [new Line(lineGeometry, matLineYellow), [0.855, 0.98, 0], undefined, [0.125, 1, 1]], - [new Line(lineGeometry, matLineYellow), [0.98, 0.855, 0], [0, 0, Math.PI / 2], [0.125, 1, 1]], - ], - YZ: [ - [new Mesh(scaleHandleGeometry, matCyanTransparent), [0, 0.85, 0.85], undefined, [0.2, 2, 2]], - [new Line(lineGeometry, matLineCyan), [0, 0.855, 0.98], [0, 0, Math.PI / 2], [0.125, 1, 1]], - [new Line(lineGeometry, matLineCyan), [0, 0.98, 0.855], [0, -Math.PI / 2, 0], [0.125, 1, 1]], - ], - XZ: [ - [new Mesh(scaleHandleGeometry, matMagentaTransparent), [0.85, 0, 0.85], undefined, [2, 0.2, 2]], - [new Line(lineGeometry, matLineMagenta), [0.855, 0, 0.98], undefined, [0.125, 1, 1]], - [new Line(lineGeometry, matLineMagenta), [0.98, 0, 0.855], [0, -Math.PI / 2, 0], [0.125, 1, 1]], - ], - XYZX: [[new Mesh(new BoxGeometry(0.125, 0.125, 0.125), matWhiteTransparent.clone()), [1.1, 0, 0]]], - XYZY: [[new Mesh(new BoxGeometry(0.125, 0.125, 0.125), matWhiteTransparent.clone()), [0, 1.1, 0]]], - XYZZ: [[new Mesh(new BoxGeometry(0.125, 0.125, 0.125), matWhiteTransparent.clone()), [0, 0, 1.1]]], - }; - - const pickerScale = { - X: [[new Mesh(new CylinderGeometry(0.2, 0, 0.8, 4, 1, false), matInvisible), [0.5, 0, 0], [0, 0, -Math.PI / 2]]], - Y: [[new Mesh(new CylinderGeometry(0.2, 0, 0.8, 4, 1, false), matInvisible), [0, 0.5, 0]]], - Z: [[new Mesh(new CylinderGeometry(0.2, 0, 0.8, 4, 1, false), matInvisible), [0, 0, 0.5], [Math.PI / 2, 0, 0]]], - XY: [[new Mesh(scaleHandleGeometry, matInvisible), [0.85, 0.85, 0], undefined, [3, 3, 0.2]]], - YZ: [[new Mesh(scaleHandleGeometry, matInvisible), [0, 0.85, 0.85], undefined, [0.2, 3, 3]]], - XZ: [[new Mesh(scaleHandleGeometry, matInvisible), [0.85, 0, 0.85], undefined, [3, 0.2, 3]]], - XYZX: [[new Mesh(new BoxGeometry(0.2, 0.2, 0.2), matInvisible), [1.1, 0, 0]]], - XYZY: [[new Mesh(new BoxGeometry(0.2, 0.2, 0.2), matInvisible), [0, 1.1, 0]]], - XYZZ: [[new Mesh(new BoxGeometry(0.2, 0.2, 0.2), matInvisible), [0, 0, 1.1]]], - }; - - // Dashed scale helpers for X/Y/Z - const helperScaleXLine = new Line(lineGeometry, helperLineDashedMaterial.clone()); - helperScaleXLine.computeLineDistances(); - const helperScaleYLine = new Line(lineGeometry, helperLineDashedMaterial.clone()); - helperScaleYLine.computeLineDistances(); - const helperScaleZLine = new Line(lineGeometry, helperLineDashedMaterial.clone()); - helperScaleZLine.computeLineDistances(); - - const helperScale = { - X: [[helperScaleXLine, [-1e3, 0, 0], undefined, [1e6, 1, 1], 'helper']], - Y: [[helperScaleYLine, [0, -1e3, 0], [0, 0, Math.PI / 2], [1e6, 1, 1], 'helper']], - Z: [[helperScaleZLine, [0, 0, -1e3], [0, -Math.PI / 2, 0], [1e6, 1, 1], 'helper']], - }; - - // Creates an Object3D with gizmos described in custom hierarchy definition. - // this is nearly impossible to Type so i'm leaving it - const setupGizmo = ( - gizmoMap: Record< - string, - Array<[Mesh, number[] | undefined, number[] | undefined, number[] | undefined, string | undefined]> - >, - ): Object3D => { - const gizmo = new Object3D(); - - // eslint-disable-next-line guard-for-in -- TODO - for (const name in gizmoMap) { - for (let i = gizmoMap[name]!.length; i--; ) { - const object = gizmoMap[name]![i]![0].clone(); - const position = gizmoMap[name]![i]![1]; - const rotation = gizmoMap[name]![i]![2]; - const scale = gizmoMap[name]![i]![3]; - const tag = gizmoMap[name]![i]![4]; - - // Name and tag properties are essential for picking and updating logic. - object.name = name; - // @ts-expect-error -- TODO: augment types or replace mechanism altogether - object.tag = tag; - - if (position) { - object.position.set(position[0]!, position[1]!, position[2]!); - } - - if (rotation) { - object.rotation.set(rotation[0]!, rotation[1]!, rotation[2]!); - } - - if (scale) { - object.scale.set(scale[0]!, scale[1]!, scale[2]!); - } - - object.updateMatrix(); - - const temporaryGeometry = object.geometry.clone(); - temporaryGeometry.applyMatrix4(object.matrix); - object.geometry = temporaryGeometry; - if (object instanceof Line) { - // Ensure dashed materials render correctly after baking transforms - object.computeLineDistances(); - } - - object.renderOrder = Infinity; - - object.position.set(0, 0, 0); - object.rotation.set(0, 0, 0); - object.scale.set(1, 1, 1); - - gizmo.add(object); - } - } - - return gizmo; - }; - - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- TODO: fix typings - this.gizmo = {} as TransformControlsGizmoPrivateGizmos; - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- TODO: fix typings - this.picker = {} as TransformControlsGizmoPrivateGizmos; - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- TODO: fix typings - this.helper = {} as TransformControlsGizmoPrivateGizmos; - - // @ts-expect-error -- fix typings - this.add((this.gizmo.translate = setupGizmo(gizmoTranslate))); - // @ts-expect-error -- fix typings - this.add((this.gizmo.rotate = setupGizmo(gizmoRotate))); - // @ts-expect-error -- fix typings - this.add((this.gizmo.scale = setupGizmo(gizmoScale))); - // @ts-expect-error -- fix typings - this.add((this.picker.translate = setupGizmo(pickerTranslate))); - // @ts-expect-error -- fix typings - this.add((this.picker.rotate = setupGizmo(pickerRotate))); - // @ts-expect-error -- fix typings - this.add((this.picker.scale = setupGizmo(pickerScale))); - // @ts-expect-error -- fix typings - this.add((this.helper.translate = setupGizmo(helperTranslate))); - // @ts-expect-error -- fix typings - this.add((this.helper.rotate = setupGizmo(helperRotate))); - // @ts-expect-error -- fix typings - this.add((this.helper.scale = setupGizmo(helperScale))); - - // Pickers should be hidden always - - this.picker.translate.visible = false; - this.picker.rotate.visible = false; - this.picker.scale.visible = false; - } - - // UpdateMatrixWorld will update transformations and appearance of individual handles - public override updateMatrixWorld = (): void => { - let { space } = this; - - if (this.mode === 'scale') { - space = 'local'; // Scale always oriented to local rotation - } - - const quaternion = space === 'local' ? this.worldQuaternion : this.identityQuaternion; - - // Show only gizmos for current transform mode - - this.gizmo.translate.visible = this.mode === 'translate'; - this.gizmo.rotate.visible = this.mode === 'rotate'; - this.gizmo.scale.visible = this.mode === 'scale'; - - this.helper.translate.visible = this.mode === 'translate'; - this.helper.rotate.visible = this.mode === 'rotate'; - this.helper.scale.visible = this.mode === 'scale'; - - const handles: Array = [ - ...this.picker[this.mode].children, - ...this.gizmo[this.mode].children, - ...this.helper[this.mode].children, - ]; - - for (const handle of handles) { - // Hide aligned to camera - - handle.visible = true; - handle.rotation.set(0, 0, 0); - handle.position.copy(this.worldPosition); - - let factor; - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- camera is possible perspective or orthographic - if ((this.camera as OrthographicCamera).isOrthographicCamera) { - factor = - ((this.camera as OrthographicCamera).top - (this.camera as OrthographicCamera).bottom) / - (this.camera as OrthographicCamera).zoom; - } else { - factor = - this.worldPosition.distanceTo(this.cameraPosition) * - Math.min((1.9 * Math.tan((Math.PI * (this.camera as PerspectiveCamera).fov) / 360)) / this.camera.zoom, 7); - } - - handle.scale.set(1, 1, 1).multiplyScalar((factor * this.size) / 7); - - // TODO: simplify helpers and consider decoupling from gizmo - - if (handle.tag === 'helper') { - handle.visible = false; - - switch (handle.name) { - case 'AXIS': { - // During hover (not dragging) anchor to current world position; - // once dragging, keep the captured start position - handle.position.copy(this.dragging ? this.worldPositionStart : this.worldPosition); - handle.visible = Boolean(this.axis); - - if (this.axis === 'X') { - this.tempQuaternion.setFromEuler(this.tempEuler.set(0, 0, 0)); - handle.quaternion.copy(quaternion).multiply(this.tempQuaternion); - - if (Math.abs(this.alignVector.copy(this.unitX).applyQuaternion(quaternion).dot(this.eye)) > 0.9) { - handle.visible = false; - } - } - - if (this.axis === 'Y') { - this.tempQuaternion.setFromEuler(this.tempEuler.set(0, 0, Math.PI / 2)); - handle.quaternion.copy(quaternion).multiply(this.tempQuaternion); - - if (Math.abs(this.alignVector.copy(this.unitY).applyQuaternion(quaternion).dot(this.eye)) > 0.9) { - handle.visible = false; - } - } - - if (this.axis === 'Z') { - this.tempQuaternion.setFromEuler(this.tempEuler.set(0, Math.PI / 2, 0)); - handle.quaternion.copy(quaternion).multiply(this.tempQuaternion); - - if (Math.abs(this.alignVector.copy(this.unitZ).applyQuaternion(quaternion).dot(this.eye)) > 0.9) { - handle.visible = false; - } - } - - // Dynamically size and center the axis helper near the gizmo so dash density - // stays consistent on screen and isn't skewed by extreme world lengths. - // Local line geometry runs along +X; we align quaternion above per-axis. - const axisWorldDir = new Vector3(1, 0, 0).applyQuaternion(handle.quaternion).normalize(); - const axisHelperLength = Math.max(factor * 10, 1); // World units, camera-relative - const halfLength = axisHelperLength * 0.5; - - // Center around pivot - handle.position - .copy(this.dragging ? this.worldPositionStart : this.worldPosition) - .addScaledVector(axisWorldDir, -halfLength); - - // Stretch the 1-unit line to the desired length - handle.scale.set(axisHelperLength, 1, 1); - - const dashedMaterial = (handle as Line).material as LineDashedMaterial; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- ensure existence - if (dashedMaterial) { - // Compensate for object scale so dashes stay constant in world units, - // then scale dash/gap with camera factor so they stay constant on screen. - dashedMaterial.scale = axisHelperLength; - - type DashedWithUserData = LineDashedMaterial & { - userData: { baseDashSize?: number; baseGapSize?: number }; - }; - const dashed = dashedMaterial as DashedWithUserData; - dashed.userData.baseDashSize ??= dashed.dashSize; - dashed.userData.baseGapSize ??= dashed.gapSize; - - const baseDash = dashed.userData.baseDashSize ?? dashed.dashSize; - const baseGap = dashed.userData.baseGapSize ?? dashed.gapSize; - - const dashZoomScale = Math.max(factor / 200, 0.001); - dashed.dashSize = baseDash * dashZoomScale; - dashed.gapSize = baseGap * dashZoomScale; - dashed.needsUpdate = true; - } - - if (this.axis === 'XYZE') { - this.tempQuaternion.setFromEuler(this.tempEuler.set(0, Math.PI / 2, 0)); - this.alignVector.copy(this.rotationAxis); - handle.quaternion.setFromRotationMatrix( - this.lookAtMatrix.lookAt(this.zeroVector, this.alignVector, this.unitY), - ); - handle.quaternion.multiply(this.tempQuaternion); - handle.visible = this.dragging; - } - - if (this.axis === 'E') { - handle.visible = false; - } - - break; - } - - case 'START': { - handle.position.copy(this.worldPositionStart); - handle.visible = this.dragging; - - // Keep start marker a constant on-screen size based on its own distance to the camera - let startFactor: number; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- camera can be ortho or perspective - if ((this.camera as OrthographicCamera).isOrthographicCamera) { - startFactor = - ((this.camera as OrthographicCamera).top - (this.camera as OrthographicCamera).bottom) / - (this.camera as OrthographicCamera).zoom; - } else { - startFactor = - handle.position.distanceTo(this.cameraPosition) * - Math.min( - (1.9 * Math.tan((Math.PI * (this.camera as PerspectiveCamera).fov) / 360)) / this.camera.zoom, - 7, - ); - } - - handle.scale.set(1, 1, 1).multiplyScalar((startFactor * this.size) / 7); - - break; - } - - case 'END': { - handle.position.copy(this.worldPosition); - handle.visible = this.dragging; - - // Keep end marker a constant on-screen size based on its own distance to the camera - let endFactor: number; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- camera can be ortho or perspective - if ((this.camera as OrthographicCamera).isOrthographicCamera) { - endFactor = - ((this.camera as OrthographicCamera).top - (this.camera as OrthographicCamera).bottom) / - (this.camera as OrthographicCamera).zoom; - } else { - endFactor = - handle.position.distanceTo(this.cameraPosition) * - Math.min( - (1.9 * Math.tan((Math.PI * (this.camera as PerspectiveCamera).fov) / 360)) / this.camera.zoom, - 7, - ); - } - - handle.scale.set(1, 1, 1).multiplyScalar((endFactor * this.size) / 7); - - break; - } - - case 'DELTA': { - handle.position.copy(this.worldPositionStart); - handle.quaternion.copy(this.worldQuaternionStart); - this.tempVector - .set(1e-10, 1e-10, 1e-10) - .add(this.worldPositionStart) - .sub(this.worldPosition) - .multiplyScalar(-1); - this.tempVector.applyQuaternion(this.worldQuaternionStart.clone().invert()); - handle.scale.copy(this.tempVector); - // Keep dash size constant in world units: adjust material scale by geometric stretch - // Base line geometry for translate helper is from (0,0,0) to (1,1,1), whose length is sqrt(3) - const baseLength = Math.sqrt(3); - const worldLength = this.tempVector.length(); - const scaleFactor = worldLength / baseLength || 1; - const dashedMaterial = (handle as Line).material as LineDashedMaterial; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- ensure the cast worked - if (dashedMaterial && typeof dashedMaterial.scale === 'number') { - dashedMaterial.scale = scaleFactor; - // Also make the dash/gap appear constant in screen-space like rotation helper, - // but anchor the zoom scale at drag start so dash lengths don't change while moving. - type DashedWithUserData = LineDashedMaterial & { - userData: { - baseDashSize?: number; - baseGapSize?: number; - dashZoomScaleAtStart?: number; - }; - }; - const dashed = dashedMaterial as DashedWithUserData; - dashed.userData.baseDashSize ??= dashed.dashSize; - dashed.userData.baseGapSize ??= dashed.gapSize; - - if (dashed.userData.dashZoomScaleAtStart === undefined) { - // Compute zoom factor anchored at the drag start position - let startFactor: number; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- camera can be ortho or perspective - if ((this.camera as OrthographicCamera).isOrthographicCamera) { - startFactor = - ((this.camera as OrthographicCamera).top - (this.camera as OrthographicCamera).bottom) / - (this.camera as OrthographicCamera).zoom; - } else { - startFactor = - this.worldPositionStart.distanceTo(this.cameraPosition) * - Math.min( - (1.9 * Math.tan((Math.PI * (this.camera as PerspectiveCamera).fov) / 360)) / this.camera.zoom, - 7, - ); - } - - dashed.userData.dashZoomScaleAtStart = Math.max(startFactor / 200, 0.001); - } - - const baseDash = dashed.userData.baseDashSize ?? dashed.dashSize; - const baseGap = dashed.userData.baseGapSize ?? dashed.gapSize; - const dashZoom = dashed.userData.dashZoomScaleAtStart ?? 1; - dashed.dashSize = baseDash * dashZoom; - dashed.gapSize = baseGap * dashZoom; - dashed.needsUpdate = true; - - // If dragging has ended, clear the cached zoom scale and restore base sizes - if (!this.dragging) { - dashed.userData.dashZoomScaleAtStart = undefined; - dashed.dashSize = dashed.userData.baseDashSize ?? dashed.dashSize; - dashed.gapSize = dashed.userData.baseGapSize ?? dashed.gapSize; - dashed.needsUpdate = true; - } - } - - handle.visible = this.dragging; - - break; - } - - default: { - handle.quaternion.copy(quaternion); - - if (this.dragging) { - handle.position.copy(this.worldPositionStart); - } else { - handle.position.copy(this.worldPosition); - } - - if (this.axis) { - handle.visible = this.axis.search(handle.name) !== -1; - } - } - } - - // If updating helper, skip rest of the loop - continue; - } - - // Align handles to current local or world rotation - - handle.quaternion.copy(quaternion); - - if (this.mode === 'translate' || this.mode === 'scale') { - // Ensure translation arrows face the user by twisting around their axis using the camera orientation - if (this.mode === 'translate') { - const hasFwd = handle.tag?.includes('fwd') ?? false; - const hasBwd = handle.tag?.includes('bwd') ?? false; - const isAxisArrow = (hasFwd || hasBwd) && (handle.name === 'X' || handle.name === 'Y' || handle.name === 'Z'); - if (isAxisArrow) { - // Calculate the camera rotation relative to the handle axis - const handleAxis = handle.name === 'X' ? this.unitX : handle.name === 'Y' ? this.unitY : this.unitZ; - const handleAxisWorld = this.alignVector.copy(handleAxis).applyQuaternion(quaternion).normalize(); - - // Project the camera direction (eye vector) onto the plane perpendicular to the handle axis - // This gives us the direction towards the camera, constrained to rotation around the axis - const eyeProjected = this.tempVector - .copy(this.eye) - .addScaledVector(handleAxisWorld, -this.eye.dot(handleAxisWorld)) - .normalize(); - - // Get a reference "up" vector perpendicular to the handle axis - // We use the current handle's local Y-axis as the reference - const referenceAxis = handle.name === 'X' ? this.unitY : handle.name === 'Y' ? this.unitZ : this.unitY; - const handleUpWorld = new Vector3().copy(referenceAxis).applyQuaternion(quaternion).normalize(); - - // Project the reference up vector onto the same plane - const upProjected = handleUpWorld - .addScaledVector(handleAxisWorld, -handleUpWorld.dot(handleAxisWorld)) - .normalize(); - - // Calculate the signed angle between the projected vectors - const cosAngle = upProjected.dot(eyeProjected); - const crossProduct = new Vector3().crossVectors(upProjected, eyeProjected); - const sinAngle = crossProduct.dot(handleAxisWorld); - const cameraRotationRelative = Math.atan2(sinAngle, cosAngle); - - handle.rotateOnAxis(handleAxis, cameraRotationRelative); - } - } - // Hide translate and scale axis facing the camera - - const AXIS_HIDE_TRESHOLD = 1; - const PLANE_HIDE_TRESHOLD = 0.2; - const AXIS_FLIP_TRESHOLD = 0; - - if ( - (handle.name === 'X' || handle.name === 'XYZX') && - Math.abs(this.alignVector.copy(this.unitX).applyQuaternion(quaternion).dot(this.eye)) > AXIS_HIDE_TRESHOLD - ) { - handle.scale.set(1e-10, 1e-10, 1e-10); - handle.visible = false; - } - - if ( - (handle.name === 'Y' || handle.name === 'XYZY') && - Math.abs(this.alignVector.copy(this.unitY).applyQuaternion(quaternion).dot(this.eye)) > AXIS_HIDE_TRESHOLD - ) { - handle.scale.set(1e-10, 1e-10, 1e-10); - handle.visible = false; - } - - if ( - (handle.name === 'Z' || handle.name === 'XYZZ') && - Math.abs(this.alignVector.copy(this.unitZ).applyQuaternion(quaternion).dot(this.eye)) > AXIS_HIDE_TRESHOLD - ) { - handle.scale.set(1e-10, 1e-10, 1e-10); - handle.visible = false; - } - - if ( - handle.name === 'XY' && - Math.abs(this.alignVector.copy(this.unitZ).applyQuaternion(quaternion).dot(this.eye)) < PLANE_HIDE_TRESHOLD - ) { - handle.scale.set(1e-10, 1e-10, 1e-10); - handle.visible = false; - } - - if ( - handle.name === 'YZ' && - Math.abs(this.alignVector.copy(this.unitX).applyQuaternion(quaternion).dot(this.eye)) < PLANE_HIDE_TRESHOLD - ) { - handle.scale.set(1e-10, 1e-10, 1e-10); - handle.visible = false; - } - - if ( - handle.name === 'XZ' && - Math.abs(this.alignVector.copy(this.unitY).applyQuaternion(quaternion).dot(this.eye)) < PLANE_HIDE_TRESHOLD - ) { - handle.scale.set(1e-10, 1e-10, 1e-10); - handle.visible = false; - } - - // Flip translate and scale axis ocluded behind another axis - - if (handle.name.search('X') !== -1) { - if (this.alignVector.copy(this.unitX).applyQuaternion(quaternion).dot(this.eye) < AXIS_FLIP_TRESHOLD) { - if (handle.tag?.includes('fwd')) { - handle.visible = false; - } else { - handle.scale.x *= -1; - } - } else if (handle.tag?.includes('bwd')) { - handle.visible = false; - } - } - - if (handle.name.search('Y') !== -1) { - if (this.alignVector.copy(this.unitY).applyQuaternion(quaternion).dot(this.eye) < AXIS_FLIP_TRESHOLD) { - if (handle.tag?.includes('fwd')) { - handle.visible = false; - } else { - handle.scale.y *= -1; - } - } else if (handle.tag?.includes('bwd')) { - handle.visible = false; - } - } - - if (handle.name.search('Z') !== -1) { - if (this.alignVector.copy(this.unitZ).applyQuaternion(quaternion).dot(this.eye) < AXIS_FLIP_TRESHOLD) { - if (handle.tag?.includes('fwd')) { - handle.visible = false; - } else { - handle.scale.z *= -1; - } - } else if (handle.tag?.includes('bwd')) { - handle.visible = false; - } - } - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- exhausive check - } else if (this.mode === 'rotate') { - // Align handles to current local or world rotation - - this.tempQuaternion2.copy(quaternion); - this.alignVector.copy(this.eye).applyQuaternion(this.tempQuaternion.copy(quaternion).invert()); - - if (handle.name.search('E') !== -1) { - handle.quaternion.setFromRotationMatrix(this.lookAtMatrix.lookAt(this.eye, this.zeroVector, this.unitY)); - } - - // Const isLabel = handle.tag?.includes('label'); - // // Calculate the camera rotation relative to the handle axis - // const handleAxis = handle.name === 'X' ? this.unitX : handle.name === 'Y' ? this.unitY : this.unitZ; - // const handleAxisWorld = this.alignVector.copy(handleAxis).applyQuaternion(quaternion).normalize(); - - // // Project the camera direction (eye vector) onto the plane perpendicular to the handle axis - // // This gives us the direction towards the camera, constrained to rotation around the axis - // const eyeProjected = this.tempVector - // .copy(this.eye) - // .addScaledVector(handleAxisWorld, this.eye.dot(handleAxisWorld)) - // .normalize(); - - // // Get a reference "up" vector perpendicular to the handle axis - // // We use the current handle's local Y-axis as the reference - // const referenceAxis = handle.name === 'X' ? this.unitY : handle.name === 'Y' ? this.unitZ : this.unitY; - // const handleUpWorld = new Vector3().copy(referenceAxis).applyQuaternion(quaternion).normalize(); - - // // Project the reference up vector onto the same plane - // const upProjected = handleUpWorld - // .addScaledVector(handleAxisWorld, handleUpWorld.dot(handleAxisWorld)) - // .normalize(); - - // // Calculate the signed angle between the projected vectors - // const cosAngle = upProjected.dot(eyeProjected); - // const crossProduct = new Vector3().crossVectors(upProjected, eyeProjected); - // const sinAngle = crossProduct.dot(handleAxisWorld); - // const cameraRotationRelative = Math.atan2(sinAngle, cosAngle); - - // if (isLabel) { - // handle.rotateOnAxis(handleAxis, cameraRotationRelative); - // } - - if (handle.name === 'X') { - this.tempQuaternion.setFromAxisAngle(this.unitX, Math.atan2(-this.alignVector.y, this.alignVector.z)); - this.tempQuaternion.multiplyQuaternions(this.tempQuaternion2, this.tempQuaternion); - handle.quaternion.copy(this.tempQuaternion); - } - - if (handle.name === 'Y') { - this.tempQuaternion.setFromAxisAngle(this.unitY, Math.atan2(this.alignVector.x, this.alignVector.z)); - this.tempQuaternion.multiplyQuaternions(this.tempQuaternion2, this.tempQuaternion); - handle.quaternion.copy(this.tempQuaternion); - } - - if (handle.name === 'Z') { - this.tempQuaternion.setFromAxisAngle(this.unitZ, Math.atan2(this.alignVector.y, this.alignVector.x)); - this.tempQuaternion.multiplyQuaternions(this.tempQuaternion2, this.tempQuaternion); - handle.quaternion.copy(this.tempQuaternion); - } - } - - // Hide disabled axes - handle.visible &&= !handle.name.includes('X') || this.showX; - handle.visible &&= !handle.name.includes('Y') || this.showY; - handle.visible &&= !handle.name.includes('Z') || this.showZ; - handle.visible &&= !handle.name.includes('E') || (this.showX && this.showY && this.showZ); - - // Highlight selected axis - if (!handle.tag?.includes('label')) { - // @ts-expect-error -- TODO - handle.material.tempOpacity ??= handle.material.opacity; - // @ts-expect-error -- TODO - handle.material.tempColor ??= handle.material.color.clone(); - // @ts-expect-error -- TODO - handle.material.color.copy(handle.material.tempColor); - // @ts-expect-error -- TODO - handle.material.opacity = handle.material.tempOpacity; - - if (!this.enabled) { - // @ts-expect-error -- TODO - handle.material.opacity *= 0.5; - // @ts-expect-error -- TODO - handle.material.color.lerp(new Color(1, 1, 1), 0.5); - } else if (this.axis) { - if (handle.name === this.axis) { - // @ts-expect-error -- TODO - handle.material.opacity = 1; - // @ts-expect-error -- TODO - handle.material.color.lerp(new Color(1, 1, 1), 0.5); - } else if ([...this.axis].includes(handle.name)) { - // @ts-expect-error -- TODO - handle.material.opacity = 1; - // @ts-expect-error -- TODO - handle.material.color.lerp(new Color(1, 1, 1), 0.5); - } else { - // @ts-expect-error -- TODO - handle.material.opacity *= 0.25; - // @ts-expect-error -- TODO - handle.material.color.lerp(new Color(1, 1, 1), 0.5); - } - } - } - } - - super.updateMatrixWorld(); - }; -} - -class TransformControlsPlane extends Mesh { - public override type = 'TransformControlsPlane'; - public isTransformControlsPlane = true; - - private readonly unitX = new Vector3(1, 0, 0); - private readonly unitY = new Vector3(0, 1, 0); - private readonly unitZ = new Vector3(0, 0, 1); - - private readonly tempVector = new Vector3(); - private readonly dirVector = new Vector3(); - private readonly alignVector = new Vector3(); - private readonly tempMatrix = new Matrix4(); - private readonly identityQuaternion = new Quaternion(); - - // These are set from parent class TransformControls - private readonly cameraQuaternion = new Quaternion(); - - private readonly worldPosition = new Vector3(); - private readonly worldQuaternion = new Quaternion(); - - private readonly eye = new Vector3(); - - private readonly axis: string | undefined = undefined; - private readonly mode: 'translate' | 'rotate' | 'scale' = 'translate'; - private readonly space: 'world' | 'local' = 'world'; - - public constructor() { - super( - new PlaneGeometry(100_000, 100_000, 2, 2), - new MeshBasicMaterial({ - visible: false, - wireframe: true, - side: DoubleSide, - transparent: true, - opacity: 0.1, - toneMapped: false, - }), - ); - } - - public override updateMatrixWorld = (): void => { - let { space } = this; - - this.position.copy(this.worldPosition); - - if (this.mode === 'scale') { - space = 'local'; - } // Scale always oriented to local rotation - - this.unitX.set(1, 0, 0).applyQuaternion(space === 'local' ? this.worldQuaternion : this.identityQuaternion); - this.unitY.set(0, 1, 0).applyQuaternion(space === 'local' ? this.worldQuaternion : this.identityQuaternion); - this.unitZ.set(0, 0, 1).applyQuaternion(space === 'local' ? this.worldQuaternion : this.identityQuaternion); - - // Align the plane for current transform mode, axis and space. - - this.alignVector.copy(this.unitY); - - switch (this.mode) { - case 'translate': - case 'scale': { - switch (this.axis) { - case 'X': { - this.alignVector.copy(this.eye).cross(this.unitX); - this.dirVector.copy(this.unitX).cross(this.alignVector); - break; - } - - case 'Y': { - this.alignVector.copy(this.eye).cross(this.unitY); - this.dirVector.copy(this.unitY).cross(this.alignVector); - break; - } - - case 'Z': { - this.alignVector.copy(this.eye).cross(this.unitZ); - this.dirVector.copy(this.unitZ).cross(this.alignVector); - break; - } - - case 'XY': { - this.dirVector.copy(this.unitZ); - break; - } - - case 'YZ': { - this.dirVector.copy(this.unitX); - break; - } - - case 'XZ': { - this.alignVector.copy(this.unitZ); - this.dirVector.copy(this.unitY); - break; - } - - case 'XYZ': - case 'E': { - this.dirVector.set(0, 0, 0); - break; - } - } - - break; - } - - // eslint-disable-next-line unicorn/no-useless-switch-case -- exhaustive check - case 'rotate': - default: { - // Special case for rotate - this.dirVector.set(0, 0, 0); - } - } - - if (this.dirVector.length() === 0) { - // If in rotate mode, make the plane parallel to camera - this.quaternion.copy(this.cameraQuaternion); - } else { - this.tempMatrix.lookAt(this.tempVector.set(0, 0, 0), this.dirVector, this.alignVector); - - this.quaternion.setFromRotationMatrix(this.tempMatrix); - } - - super.updateMatrixWorld(); - }; -} - -export { TransformControls, TransformControlsGizmo, TransformControlsPlane }; diff --git a/apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-axes.tsx b/apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-axes.tsx deleted file mode 100644 index be71853b6..000000000 --- a/apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-axes.tsx +++ /dev/null @@ -1,128 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unnecessary-condition -- TODO: review these types, some are actually required */ -import { useThree, useFrame } from '@react-three/fiber'; -import type { GizmoOptions } from 'three-viewport-gizmo'; -import { ViewportGizmo } from 'three-viewport-gizmo'; -import { useEffect, useCallback, useRef } from 'react'; -import * as THREE from 'three'; -import type { OrbitControls } from 'three/addons'; -import type { ReactNode } from 'react'; -import { useColor } from '#hooks/use-color.js'; -import { useTheme } from '#hooks/use-theme.js'; -import { - resolveGizmoContainer, - createGizmoCanvas, - createGizmoRenderer, - disposeGizmoResources, -} from '#components/geometry/graphics/three/utils/gizmo.utils.js'; - -type ViewportGizmoAxesProps = { - readonly size?: number; - /** - * A container element or selector to append the gizmo to. - * - * When provided, the gizmo will be appended to this container instead of the renderer's parent. - */ - readonly container?: HTMLElement | string; - /** - * Optional dependencies array that will be appended to the effect dependencies. - * When any of these values change, the gizmo will be disposed and recreated. - */ - readonly dependencies?: readonly unknown[]; -}; - -const className = 'viewport-gizmo-axes'; -const emptyDependencies: readonly unknown[] = []; - -export function ViewportGizmoAxes({ - size = 96, - container, - dependencies = emptyDependencies, -}: ViewportGizmoAxesProps): ReactNode { - const camera = useThree((state) => state.camera) as THREE.PerspectiveCamera; - const gl = useThree((state) => state.gl); - const controls = useThree((state) => state.controls) as OrbitControls; - const scene = useThree((state) => state.scene); - const invalidate = useThree((state) => state.invalidate); - - const { serialized } = useColor(); - const { theme } = useTheme(); - - // eslint-disable-next-line @typescript-eslint/no-restricted-types -- React ref - const gizmoRef = useRef(null); - // eslint-disable-next-line @typescript-eslint/no-restricted-types -- React ref - const rendererRef = useRef(null); - - const handleChange = useCallback((): void => { - invalidate(); - }, [invalidate]); - - // Demand-based gizmo rendering: only render when the R3F frame loop fires (on invalidation) - useFrame(() => { - if (rendererRef.current && gizmoRef.current) { - rendererRef.current.toneMapping = THREE.NoToneMapping; - gizmoRef.current.render(); - } - }); - - // Create DOM overlay for gizmo - useEffect(() => { - // Early return if we don't have the required components - if (!camera || !gl || !controls) { - return; - } - - const canvas = createGizmoCanvas(className); - - const containerToUse = resolveGizmoContainer(container, gl.domElement); - if (!containerToUse) { - return; - } - - containerToUse.append(canvas); - - const renderer = createGizmoRenderer(canvas, size); - - // Configure the gizmo options - const gizmoConfig: GizmoOptions = { - type: 'sphere', - placement: 'bottom-right', - size, - resolution: 256, - className, - container: containerToUse, - font: { - weight: 'normal', - family: 'monospace', - }, - offset: { - bottom: 0, - right: 0, - }, - }; - - // Create the gizmo - const gizmo = new ViewportGizmo(camera, renderer, gizmoConfig); - gizmoRef.current = gizmo; - rendererRef.current = renderer; - - // Add event listeners for the gizmo - gizmo.addEventListener('change', handleChange); - - gizmo.scale.multiplyScalar(0.7); - - // Attach the controls to enable proper interaction - gizmo.attachControls(controls); - - // Cleanup function - return () => { - // Clear refs so the useFrame callback cannot operate on disposed objects - gizmoRef.current = null; - rendererRef.current = null; - - disposeGizmoResources({ gizmo, renderer, canvas, handleChange }); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- dependencies array is user-provided for custom recreation triggers - }, [camera, gl, controls, scene, serialized.hex, theme, size, handleChange, container, ...dependencies]); - - return null; -} diff --git a/apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-cube-axes.ts b/apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-cube-axes.ts deleted file mode 100644 index 5a01fa61b..000000000 --- a/apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-cube-axes.ts +++ /dev/null @@ -1,133 +0,0 @@ -import * as THREE from 'three'; -import { Line2, LineGeometry, LineMaterial } from 'three/addons'; -import { gizmoBaseDistance } from '#components/geometry/graphics/three/utils/math.utils.js'; - -export type ViewportGizmoCubeAxesProps = { - readonly axesSize?: number; - /** - * The gizmo canvas size in CSS pixels. Used to set the correct - * `LineMaterial.resolution` so line widths render at the intended pixel size. - */ - readonly rendererSize?: number; - readonly xAxisColor?: string; - readonly yAxisColor?: string; - readonly zAxisColor?: string; - readonly xLabelColor?: string; - readonly yLabelColor?: string; - readonly zLabelColor?: string; - readonly lineOpacity?: number; - readonly lineWidth?: number; -}; - -export const createViewportGizmoCubeAxes = ({ - axesSize = 2.1, - rendererSize = 96, - xAxisColor = 'red', - yAxisColor = 'green', - zAxisColor = 'blue', - xLabelColor = 'red', - yLabelColor = 'green', - zLabelColor = 'blue', - lineOpacity = 0.6, - lineWidth = 1.5, -}: ViewportGizmoCubeAxesProps): THREE.Group => { - const axesLines = [ - { - id: 'x', - points: [new THREE.Vector3(0, 0, 0), new THREE.Vector3(axesSize, 0, 0)], - lineColor: xAxisColor, - labelColor: xLabelColor, - label: 'X', - }, - { - id: 'y', - points: [new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, axesSize, 0)], - lineColor: yAxisColor, - labelColor: yLabelColor, - label: 'Y', - }, - { - id: 'z', - points: [new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, axesSize)], - lineColor: zAxisColor, - labelColor: zLabelColor, - label: 'Z', - }, - ]; - - const axes = new THREE.Group(); - for (const line of axesLines) { - // Convert points to flat array for LineGeometry - const positions = []; - for (const point of line.points) { - positions.push(point.x, point.y, point.z); - } - - const geometry = new LineGeometry(); - geometry.setPositions(positions); - - const material = new LineMaterial({ - color: line.lineColor, - linewidth: lineWidth, - opacity: lineOpacity, - resolution: new THREE.Vector2(rendererSize, rendererSize), - }); - - const lineObject = new Line2(geometry, material); - axes.add(lineObject); - - // Add text label at the end of each axis - const endPoint = line.points[1]!; - - // Create a canvas for the text - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - const textCanvasSize = 64; - canvas.width = textCanvasSize; - canvas.height = textCanvasSize; - - if (context) { - // Set the entire canvas to transparent - context.clearRect(0, 0, canvas.width, canvas.height); - - // Draw the text with smaller font size - context.fillStyle = line.labelColor; - context.font = '100 48px monospace'; - context.textAlign = 'center'; - context.textBaseline = 'middle'; - context.fillText(line.label, textCanvasSize / 2, textCanvasSize / 2); - } - - // Create texture from canvas - const texture = new THREE.CanvasTexture(canvas); - texture.needsUpdate = true; - - // Create a sprite with the texture. - // sizeAttenuation: true makes the sprite size proportional to - // 1 / (distance * tan(fov/2)). Because our FOV-sync distance compensation - // keeps distance * tan(fov/2) constant, the labels maintain a fixed visual - // size regardless of FOV. The scale values are multiplied by - // GIZMO_BASE_DISTANCE to preserve the same appearance at the default FOV. - const spriteMaterial = new THREE.SpriteMaterial({ - map: texture, - sizeAttenuation: true, - depthTest: true, - transparent: true, - }); - - // Set render order to ensure it renders on top - const sprite = new THREE.Sprite(spriteMaterial); - sprite.renderOrder = 3; - sprite.position.copy(endPoint); - sprite.scale.set(0.08 * gizmoBaseDistance, 0.06 * gizmoBaseDistance, 1); - - // Add increased offset to move labels further from line ends - const direction = new THREE.Vector3().subVectors(endPoint, new THREE.Vector3(0, 0, 0)).normalize(); - sprite.position.add(direction.multiplyScalar(0.2)); - - axes.add(sprite); - } - - axes.position.set(-axesSize / 2, -axesSize / 2, -axesSize / 2); - return axes; -}; diff --git a/apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-cube.tsx b/apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-cube.tsx deleted file mode 100644 index 2f5d5f56c..000000000 --- a/apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-cube.tsx +++ /dev/null @@ -1,201 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unnecessary-condition -- TODO: review these types, some are actually required */ -import { useThree, useFrame } from '@react-three/fiber'; -import type { GizmoAxisOptions, GizmoOptions } from 'three-viewport-gizmo'; -import { ViewportGizmo } from 'three-viewport-gizmo'; -import { useEffect, useCallback, useRef } from 'react'; -import * as THREE from 'three'; -import type { OrbitControls } from 'three/addons'; -import type { ReactNode } from 'react'; -import { useColor } from '#hooks/use-color.js'; -import { Theme, useTheme } from '#hooks/use-theme.js'; -import { createViewportGizmoCubeAxes } from '#components/geometry/graphics/three/controls/viewport-gizmo-cube-axes.js'; -import { useGraphicsSelector } from '#hooks/use-graphics.js'; -import { - syncGizmoFov, - resolveGizmoContainer, - createGizmoCanvas, - createGizmoRenderer, - disposeGizmoResources, -} from '#components/geometry/graphics/three/utils/gizmo.utils.js'; - -type ViewportGizmoCubeProps = { - readonly size?: number; - /** - * A container element or selector to append the gizmo to. - * - * When provided, the gizmo will be appended to this container instead of the renderer's parent. - */ - readonly container?: HTMLElement | string; - /** - * Optional dependencies array that will be appended to the effect dependencies. - * When any of these values change, the gizmo will be disposed and recreated. - * Useful for triggering recreation when coordinate systems or other external state changes. - * - * @example - * ```tsx - * - * ``` - */ - readonly dependencies?: readonly unknown[]; -}; - -const className = 'viewport-gizmo-cube'; -const emptyDependencies: readonly unknown[] = []; - -export function ViewportGizmoCube({ - size = 96, - container, - dependencies = emptyDependencies, -}: ViewportGizmoCubeProps): ReactNode { - const camera = useThree((state) => state.camera) as THREE.PerspectiveCamera; - const gl = useThree((state) => state.gl); - const controls = useThree((state) => state.controls) as OrbitControls; - const scene = useThree((state) => state.scene); - const invalidate = useThree((state) => state.invalidate); - - const { serialized } = useColor(); - const { theme } = useTheme(); - - // Subscribe to the viewport FOV from the per-view graphics machine - const cameraFovAngle = useGraphicsSelector((state) => state.context.cameraFovAngle); - - // Keep a ref to the current angle so the creation effect can read it without - // adding cameraFovAngle as a dependency (which would cause expensive recreation) - const cameraFovAngleRef = useRef(cameraFovAngle); - cameraFovAngleRef.current = cameraFovAngle; - - // Ref to the live gizmo instance for the FOV sync effect - // eslint-disable-next-line @typescript-eslint/no-restricted-types -- React ref - const gizmoRef = useRef(null); - // eslint-disable-next-line @typescript-eslint/no-restricted-types -- React ref - const rendererRef = useRef(null); - - const handleChange = useCallback((): void => { - invalidate(); - }, [invalidate]); - - // Create DOM overlay for gizmo - useEffect(() => { - // Early return if we don't have the required components - if (!camera || !gl || !controls) { - return; - } - - const canvas = createGizmoCanvas(className); - - const containerToUse = resolveGizmoContainer(container, gl.domElement); - if (!containerToUse) { - return; - } - - containerToUse.append(canvas); - - const renderer = createGizmoRenderer(canvas, size); - - const faceConfig = { - color: theme === Theme.DARK ? 0x33_33_33 : 0xdd_dd_dd, - labelColor: theme === Theme.DARK ? 0xff_ff_ff : 0x00_00_00, - hover: { - color: serialized.hex, - }, - } as const satisfies GizmoAxisOptions; - const edgeConfig = { - color: theme === Theme.DARK ? 0x55_55_55 : 0xee_ee_ee, - opacity: 1, - hover: { - color: serialized.hex, - }, - } as const satisfies GizmoAxisOptions; - const cornerConfig = { - ...faceConfig, - color: theme === Theme.DARK ? 0x33_33_33 : 0xdd_dd_dd, - hover: { - color: serialized.hex, - }, - } as const satisfies GizmoAxisOptions; - - // Configure the gizmo options - const gizmoConfig: GizmoOptions = { - type: 'rounded-cube', - placement: 'bottom-right', - size, - font: { - weight: 'normal', - family: 'monospace', - }, - radius: 0.3, - offset: { - bottom: 0, - right: 0, - }, - className, - resolution: 256, - container: containerToUse, - corners: cornerConfig, - edges: edgeConfig, - right: faceConfig, - top: faceConfig, - front: faceConfig, - back: faceConfig, - left: faceConfig, - bottom: faceConfig, - }; - - // Create the gizmo - const gizmo = new ViewportGizmo(camera, renderer, gizmoConfig); - gizmoRef.current = gizmo; - rendererRef.current = renderer; - - // Synchronize the gizmo's internal camera FOV with the current viewport FOV - syncGizmoFov(gizmo, cameraFovAngleRef.current); - - // Add event listeners for the gizmo - gizmo.addEventListener('change', handleChange); - - gizmo.scale.multiplyScalar(0.7); - gizmo.add( - createViewportGizmoCubeAxes({ - axesSize: 2.1, - rendererSize: size, - xAxisColor: 'red', - yAxisColor: 'green', - zAxisColor: 'rgb(37, 78, 136)', - xLabelColor: 'red', - yLabelColor: 'green', - zLabelColor: 'rgb(37, 78, 136)', - lineWidth: 2, - }), - ); - - // Attach the controls to enable proper interaction - gizmo.attachControls(controls); - - // Cleanup function - return () => { - // Clear refs so the useFrame and FOV sync effect cannot operate on disposed objects - gizmoRef.current = null; - rendererRef.current = null; - - disposeGizmoResources({ gizmo, renderer, canvas, handleChange }); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- dependencies array is user-provided for custom recreation triggers - }, [camera, gl, controls, scene, serialized.hex, theme, size, handleChange, container, ...dependencies]); - - // Demand-based gizmo rendering: only render when the R3F frame loop fires (on invalidation) - useFrame(() => { - if (rendererRef.current && gizmoRef.current) { - rendererRef.current.toneMapping = THREE.NoToneMapping; - gizmoRef.current.render(); - } - }); - - // Real-time FOV sync: update the gizmo's internal camera when the viewport FOV changes. - // This is a separate effect to avoid expensive gizmo recreation on every slider tick. - useEffect(() => { - if (gizmoRef.current) { - syncGizmoFov(gizmoRef.current, cameraFovAngle); - } - }, [cameraFovAngle]); - - return null; -} diff --git a/apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-onshape.tsx b/apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-onshape.tsx deleted file mode 100644 index 011829d05..000000000 --- a/apps/ui/app/components/geometry/graphics/three/controls/viewport-gizmo-onshape.tsx +++ /dev/null @@ -1,198 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unnecessary-condition -- TODO: review these types, some are actually required */ -import { useThree, useFrame } from '@react-three/fiber'; -import type { GizmoAxisOptions, GizmoOptions } from 'three-viewport-gizmo'; -import { ViewportGizmo } from 'three-viewport-gizmo'; -import { useEffect, useCallback, useRef } from 'react'; -import * as THREE from 'three'; -import type { OrbitControls } from 'three/addons'; -import type { ReactNode } from 'react'; -import { useColor } from '#hooks/use-color.js'; -import { Theme, useTheme } from '#hooks/use-theme.js'; -import { createViewportGizmoCubeAxes } from '#components/geometry/graphics/three/controls/viewport-gizmo-cube-axes.js'; -import { useGraphicsSelector } from '#hooks/use-graphics.js'; -import { - syncGizmoFov, - resolveGizmoContainer, - createGizmoCanvas, - createGizmoRenderer, - disposeGizmoResources, -} from '#components/geometry/graphics/three/utils/gizmo.utils.js'; - -type ViewportGizmoOnshapeProps = { - readonly size?: number; - /** - * A container element or selector to append the gizmo to. - * - * When provided, the gizmo will be appended to this container instead of the renderer's parent. - */ - readonly container?: HTMLElement | string; - /** - * Optional dependencies array that will be appended to the effect dependencies. - * When any of these values change, the gizmo will be disposed and recreated. - */ - readonly dependencies?: readonly unknown[]; -}; - -const className = 'viewport-gizmo-onshape'; -const emptyDependencies: readonly unknown[] = []; - -export function ViewportGizmoOnshape({ - size = 96, - container, - dependencies = emptyDependencies, -}: ViewportGizmoOnshapeProps): ReactNode { - const camera = useThree((state) => state.camera); - const gl = useThree((state) => state.gl); - const controls = useThree((state) => state.controls) as OrbitControls; - const scene = useThree((state) => state.scene); - const invalidate = useThree((state) => state.invalidate); - - const { serialized } = useColor(); - const { theme } = useTheme(); - - // Subscribe to the viewport FOV from the per-view graphics machine - const cameraFovAngle = useGraphicsSelector((state) => state.context.cameraFovAngle); - - // Keep a ref to the current angle so the creation effect can read it without - // adding cameraFovAngle as a dependency (which would cause expensive recreation) - const cameraFovAngleRef = useRef(cameraFovAngle); - cameraFovAngleRef.current = cameraFovAngle; - - // Ref to the live gizmo instance for the FOV sync effect - const gizmoRef = useRef(null); - // eslint-disable-next-line @typescript-eslint/no-restricted-types -- React ref - const rendererRef = useRef(null); - - const handleChange = useCallback((): void => { - invalidate(); - }, [invalidate]); - - // Demand-based gizmo rendering: only render when the R3F frame loop fires (on invalidation) - useFrame(() => { - if (rendererRef.current && gizmoRef.current) { - rendererRef.current.toneMapping = THREE.NoToneMapping; - gizmoRef.current.render(); - } - }); - - // Create DOM overlay for gizmo - useEffect(() => { - // Early return if we don't have the required components - if (!camera || !gl || !controls) { - return; - } - - const canvas = createGizmoCanvas(className); - - const containerToUse = resolveGizmoContainer(container, gl.domElement); - if (!containerToUse) { - return; - } - - containerToUse.append(canvas); - - const renderer = createGizmoRenderer(canvas, size); - - const backgroundColor = theme === Theme.DARK ? 0x44_44_44 : 0xcc_cc_cc; - const faceConfig = { - color: theme === Theme.DARK ? 0x33_33_33 : 0xdd_dd_dd, - labelColor: theme === Theme.DARK ? 0xff_ff_ff : 0x00_00_00, - hover: { - color: serialized.hex, - }, - } as const satisfies GizmoAxisOptions; - const edgeConfig = { - color: theme === Theme.DARK ? 0x55_55_55 : 0xee_ee_ee, - opacity: 1, - hover: { - color: serialized.hex, - }, - } as const satisfies GizmoAxisOptions; - const cornerConfig = { - ...faceConfig, - color: theme === Theme.DARK ? 0x33_33_33 : 0xdd_dd_dd, - hover: { - color: serialized.hex, - }, - } as const satisfies GizmoAxisOptions; - - // Configure the gizmo options - const gizmoConfig: GizmoOptions = { - type: 'cube', - placement: 'bottom-right', - size, - font: { - weight: 'normal', - family: 'monospace', - }, - resolution: 256, - className, - container: containerToUse, - background: { - color: backgroundColor, - hover: { color: backgroundColor }, - }, - offset: { - bottom: 0, - right: 0, - }, - corners: cornerConfig, - edges: edgeConfig, - right: faceConfig, - top: faceConfig, - front: faceConfig, - back: faceConfig, - left: faceConfig, - bottom: faceConfig, - }; - - // Create the gizmo - const gizmo = new ViewportGizmo(camera, renderer, gizmoConfig); - gizmoRef.current = gizmo; - rendererRef.current = renderer; - - // Synchronize the gizmo's internal camera FOV with the current viewport FOV - syncGizmoFov(gizmo, cameraFovAngleRef.current); - - // Add event listeners for the gizmo - gizmo.addEventListener('change', handleChange); - - gizmo.scale.multiplyScalar(0.7); - gizmo.add( - createViewportGizmoCubeAxes({ - axesSize: 2.1, - rendererSize: size, - xAxisColor: 'red', - yAxisColor: 'green', - zAxisColor: 'rgb(37, 78, 136)', - xLabelColor: 'red', - yLabelColor: 'green', - zLabelColor: 'rgb(37, 78, 136)', - lineWidth: 2, - }), - ); - - // Attach the controls to enable proper interaction - gizmo.attachControls(controls); - - // Cleanup function - return () => { - // Clear refs so the useFrame and FOV sync effect cannot operate on disposed objects - gizmoRef.current = null; - rendererRef.current = null; - - disposeGizmoResources({ gizmo, renderer, canvas, handleChange }); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- dependencies array is user-provided for custom recreation triggers - }, [camera, gl, controls, scene, serialized.hex, theme, size, handleChange, container, ...dependencies]); - - // Real-time FOV sync: update the gizmo's internal camera when the viewport FOV changes. - // This is a separate effect to avoid expensive gizmo recreation on every slider tick. - useEffect(() => { - if (gizmoRef.current) { - syncGizmoFov(gizmoRef.current, cameraFovAngle); - } - }, [cameraFovAngle]); - - return null; -} diff --git a/apps/ui/app/components/geometry/graphics/three/geometries/circle-geometry.ts b/apps/ui/app/components/geometry/graphics/three/geometries/circle-geometry.ts deleted file mode 100644 index 123eeb326..000000000 --- a/apps/ui/app/components/geometry/graphics/three/geometries/circle-geometry.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { BufferGeometry, Float32BufferAttribute } from 'three'; - -// eslint-disable-next-line @typescript-eslint/naming-convention -- Three.js naming convention -export const CircleGeometry = ({ - radius, - arc, - arcOffset = 0, -}: { - radius: number; - arc: number; - arcOffset?: number; -}): BufferGeometry => { - const geometry = new BufferGeometry(); - const vertices = []; - - for (let i = 0; i <= 64 * arc; ++i) { - vertices.push( - 0, - Math.cos((i / 32) * Math.PI + arcOffset) * radius, - Math.sin((i / 32) * Math.PI + arcOffset) * radius, - ); - } - - geometry.setAttribute('position', new Float32BufferAttribute(vertices, 3)); - - return geometry; -}; diff --git a/apps/ui/app/components/geometry/graphics/three/geometries/font-geometry.ts b/apps/ui/app/components/geometry/graphics/three/geometries/font-geometry.ts deleted file mode 100644 index 893e15cfd..000000000 --- a/apps/ui/app/components/geometry/graphics/three/geometries/font-geometry.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { BufferGeometry } from 'three'; -import { ExtrudeGeometry } from 'three'; -import { FontLoader } from 'three/examples/jsm/Addons.js'; -import type { FontData } from 'three/examples/jsm/Addons.js'; -import fontTypeface from '#components/geometry/graphics/three/geometries/geist-mono.typeface.json?raw'; - -// eslint-disable-next-line @typescript-eslint/naming-convention -- Three.js naming convention -export const FontGeometry = ({ text, depth, size }: { text: string; depth: number; size: number }): BufferGeometry => { - const loader = new FontLoader(); - const font = loader.parse(JSON.parse(fontTypeface) as FontData); - const shapes = font.generateShapes(text, size); - const geometry = new ExtrudeGeometry(shapes, { depth, bevelEnabled: false }); - geometry.center(); - - return geometry; -}; diff --git a/apps/ui/app/components/geometry/graphics/three/geometries/geist-mono.typeface.json b/apps/ui/app/components/geometry/graphics/three/geometries/geist-mono.typeface.json deleted file mode 100644 index 8fc86e91e..000000000 --- a/apps/ui/app/components/geometry/graphics/three/geometries/geist-mono.typeface.json +++ /dev/null @@ -1 +0,0 @@ -{"glyphs":{"0":{"ha":833,"x_min":69,"x_max":764,"o":"m 417 -22 q 232 40 310 -22 q 112 217 154 101 q 69 492 69 333 q 112 767 69 650 q 233 946 154 883 q 417 1008 311 1008 q 601 946 522 1008 q 722 767 679 883 q 764 492 764 650 q 722 217 764 333 q 601 40 679 101 q 417 -22 524 -22 m 417 94 q 580 201 521 94 q 639 492 639 308 q 604 729 639 632 l 288 153 q 417 94 342 94 m 194 492 q 229 256 194 353 l 546 832 q 417 892 493 892 q 253 784 313 892 q 194 492 194 676 z "},"1":{"ha":833,"x_min":83,"x_max":750,"o":"m 83 114 l 394 114 l 394 731 l 136 731 l 136 833 l 269 833 q 383 868 349 833 q 417 986 417 903 l 514 986 l 514 114 l 750 114 l 750 0 l 83 0 l 83 114 z "},"2":{"ha":833,"x_min":69,"x_max":764,"o":"m 69 0 q 103 192 69 108 q 219 350 136 275 q 447 503 301 425 q 560 572 521 542 q 619 635 600 601 q 638 719 638 669 q 587 844 638 797 q 440 892 536 892 q 276 838 338 892 q 200 683 215 785 l 75 692 q 187 924 92 839 q 440 1008 282 1008 q 613 972 540 1008 q 724 872 686 936 q 763 722 763 807 q 738 592 763 646 q 655 492 713 539 q 492 389 597 444 q 292 248 363 321 q 217 117 221 175 l 764 117 l 764 0 l 69 0 z "},"3":{"ha":833,"x_min":69,"x_max":764,"o":"m 408 -22 q 164 53 251 -22 q 69 254 76 129 l 193 263 q 258 135 201 176 q 408 94 315 94 q 574 138 508 94 q 639 275 639 182 q 578 417 639 369 q 415 465 517 465 l 338 465 l 338 582 l 415 582 q 551 619 499 582 q 604 736 604 657 q 556 852 604 813 q 415 892 507 892 q 217 751 236 892 l 92 760 q 190 941 106 874 q 415 1008 275 1008 q 644 936 560 1008 q 729 742 729 864 q 558 531 729 583 q 709 437 654 504 q 764 275 764 369 q 718 115 764 182 q 592 13 672 49 q 408 -22 511 -22 z "},"4":{"ha":833,"x_min":56,"x_max":778,"o":"m 539 214 l 56 214 l 56 322 l 531 986 l 658 986 l 658 331 l 778 331 l 778 214 l 658 214 l 658 0 l 539 0 l 539 214 m 539 331 l 539 803 l 200 331 l 539 331 z "},"5":{"ha":833,"x_min":83,"x_max":750,"o":"m 414 -22 q 183 53 272 -22 q 83 254 94 129 l 208 263 q 272 138 218 181 q 414 94 326 94 q 568 154 511 94 q 625 317 625 214 q 569 481 625 421 q 417 542 514 542 q 304 513 357 542 q 228 432 251 483 l 100 432 l 165 986 l 690 986 l 690 869 l 275 869 l 239 572 q 326 634 274 613 q 433 656 379 656 q 599 612 526 656 q 710 491 671 568 q 750 317 750 414 q 707 141 750 218 q 588 21 664 64 q 414 -22 511 -22 z "},"6":{"ha":833,"x_min":69,"x_max":764,"o":"m 419 -22 q 161 92 253 -22 q 69 417 69 206 q 161 847 69 685 q 458 1008 253 1008 q 764 775 699 1008 l 639 764 q 578 859 621 826 q 458 892 536 892 q 276 803 344 892 q 194 538 208 714 q 297 629 232 594 q 442 664 361 664 q 610 622 538 664 q 724 504 683 581 q 764 328 764 428 q 721 142 764 221 q 599 20 678 63 q 419 -22 521 -22 m 422 94 q 580 157 521 94 q 639 328 639 219 q 583 490 639 429 q 433 550 526 550 q 266 489 332 550 q 200 328 200 428 q 260 160 200 225 q 422 94 321 94 z "},"7":{"ha":833,"x_min":83,"x_max":750,"o":"m 269 0 q 361 452 269 226 q 619 869 453 678 l 83 869 l 83 986 l 750 986 l 750 878 q 477 456 563 664 q 392 0 392 249 l 269 0 z "},"8":{"ha":833,"x_min":69,"x_max":764,"o":"m 417 -22 q 238 12 317 -22 q 115 112 160 46 q 69 271 69 178 q 120 432 69 365 q 260 531 171 499 q 117 738 117 590 q 155 876 117 814 q 262 973 193 938 q 417 1008 331 1008 q 572 973 503 1008 q 678 876 640 938 q 717 738 717 814 q 574 531 717 590 q 713 432 663 499 q 764 271 764 365 q 719 112 764 178 q 595 12 674 46 q 417 -22 517 -22 m 417 94 q 578 141 518 94 q 639 278 639 188 q 582 417 639 367 q 417 468 525 468 q 251 417 308 468 q 194 278 194 367 q 255 141 194 188 q 417 94 315 94 m 417 579 q 545 619 499 579 q 592 738 592 660 q 544 850 592 808 q 417 892 497 892 q 289 850 336 892 q 242 738 242 808 q 289 619 242 660 q 417 579 336 579 z "},"9":{"ha":833,"x_min":69,"x_max":764,"o":"m 414 1008 q 672 894 581 1008 q 764 569 764 781 q 672 140 764 301 q 375 -22 581 -22 q 69 211 135 -22 l 194 222 q 255 127 213 160 q 375 94 297 94 q 557 183 489 94 q 639 449 625 272 q 537 357 601 392 q 392 322 472 322 q 223 364 296 322 q 110 482 150 406 q 69 658 69 558 q 113 844 69 765 q 234 966 156 924 q 414 1008 313 1008 m 411 892 q 253 829 313 892 q 194 658 194 767 q 251 497 194 557 q 400 436 307 436 q 567 497 501 436 q 633 658 633 558 q 573 826 633 761 q 411 892 513 892 z "}," ":{"ha":833,"x_min":0,"x_max":0,"o":""},"A":{"ha":833,"x_min":36,"x_max":797,"o":"m 336 986 l 497 986 l 797 0 l 675 0 l 594 275 l 239 275 l 158 0 l 36 0 l 336 986 m 561 389 l 417 878 l 272 389 l 561 389 z "},"Á":{"ha":833,"x_min":36,"x_max":797,"o":"m 336 986 l 497 986 l 797 0 l 675 0 l 594 275 l 239 275 l 158 0 l 36 0 l 336 986 m 561 389 l 417 878 l 272 389 l 561 389 m 456 1264 l 586 1264 l 461 1086 l 364 1086 l 456 1264 z "},"Ă":{"ha":833,"x_min":36,"x_max":797,"o":"m 336 986 l 497 986 l 797 0 l 675 0 l 594 275 l 239 275 l 158 0 l 36 0 l 336 986 m 561 389 l 417 878 l 272 389 l 561 389 m 417 1088 q 279 1135 332 1088 q 222 1265 226 1183 l 303 1265 q 417 1174 315 1174 q 531 1265 518 1174 l 611 1265 q 554 1135 607 1183 q 417 1088 501 1088 z "},"Ǎ":{"ha":833,"x_min":36,"x_max":797,"o":"m 336 986 l 497 986 l 797 0 l 675 0 l 594 275 l 239 275 l 158 0 l 36 0 l 336 986 m 561 389 l 417 878 l 272 389 l 561 389 m 319 1264 l 417 1139 l 514 1264 l 611 1264 l 482 1079 l 351 1079 l 222 1264 l 319 1264 z "},"Â":{"ha":833,"x_min":36,"x_max":797,"o":"m 336 986 l 497 986 l 797 0 l 675 0 l 594 275 l 239 275 l 158 0 l 36 0 l 336 986 m 561 389 l 417 878 l 272 389 l 561 389 m 350 1265 l 481 1265 l 610 1081 l 513 1081 l 415 1206 l 318 1081 l 221 1081 l 350 1265 z "},"Ä":{"ha":833,"x_min":36,"x_max":797,"o":"m 336 986 l 497 986 l 797 0 l 675 0 l 594 275 l 239 275 l 158 0 l 36 0 l 336 986 m 561 389 l 417 878 l 272 389 l 561 389 z "},"À":{"ha":833,"x_min":36,"x_max":797,"o":"m 336 986 l 497 986 l 797 0 l 675 0 l 594 275 l 239 275 l 158 0 l 36 0 l 336 986 m 561 389 l 417 878 l 272 389 l 561 389 m 250 1264 l 381 1264 l 472 1086 l 375 1086 l 250 1264 z "},"Ā":{"ha":833,"x_min":36,"x_max":797,"o":"m 336 986 l 497 986 l 797 0 l 675 0 l 594 275 l 239 275 l 158 0 l 36 0 l 336 986 m 561 389 l 417 878 l 272 389 l 561 389 m 229 1218 l 601 1218 l 601 1118 l 229 1118 l 229 1218 z "},"Ą":{"ha":833,"x_min":36,"x_max":831,"o":"m 336 986 l 497 986 l 797 0 l 783 0 l 735 -62 q 701 -117 710 -96 q 692 -156 692 -137 q 733 -197 692 -197 q 768 -192 750 -197 q 797 -178 786 -187 l 831 -256 q 782 -282 814 -272 q 711 -292 750 -292 q 619 -262 653 -292 q 586 -181 586 -232 q 643 -42 586 -112 l 676 0 l 675 0 l 594 275 l 239 275 l 158 0 l 36 0 l 336 986 m 561 389 l 417 878 l 272 389 l 561 389 z "},"Å":{"ha":833,"x_min":36,"x_max":797,"o":"m 336 986 l 497 986 l 797 0 l 675 0 l 594 275 l 239 275 l 158 0 l 36 0 l 336 986 m 561 389 l 417 878 l 272 389 l 561 389 m 417 1064 q 326 1101 363 1064 q 289 1192 289 1138 q 326 1283 289 1246 q 417 1319 363 1319 q 508 1283 471 1319 q 544 1192 544 1246 q 508 1101 544 1138 q 417 1064 471 1064 m 417 1133 q 458 1150 442 1133 q 475 1192 475 1167 q 458 1233 475 1217 q 417 1250 442 1250 q 375 1233 392 1250 q 358 1192 358 1217 q 375 1150 358 1167 q 417 1133 392 1133 z "},"Ã":{"ha":833,"x_min":36,"x_max":797,"o":"m 336 986 l 497 986 l 797 0 l 675 0 l 594 275 l 239 275 l 158 0 l 36 0 l 336 986 m 561 389 l 417 878 l 272 389 l 561 389 m 500 1097 q 403 1139 454 1097 q 363 1168 376 1161 q 331 1175 350 1175 q 269 1088 274 1175 l 197 1088 q 235 1208 199 1165 q 331 1250 272 1250 q 385 1239 361 1250 q 436 1206 410 1228 q 474 1180 458 1188 q 506 1172 489 1172 q 546 1192 532 1172 q 564 1264 560 1213 l 636 1264 q 596 1141 632 1185 q 500 1097 560 1097 z "},"Æ":{"ha":833,"x_min":14,"x_max":808,"o":"m 339 986 l 801 986 l 801 869 l 565 869 l 565 551 l 792 551 l 792 438 l 565 438 l 565 117 l 808 117 l 808 0 l 446 0 l 446 281 l 226 281 l 138 0 l 14 0 l 339 986 m 446 397 l 446 874 l 417 874 l 263 397 l 446 397 z "},"B":{"ha":833,"x_min":125,"x_max":772,"o":"m 125 986 l 403 986 q 647 915 563 986 q 731 717 731 844 q 682 580 731 636 q 554 508 633 524 q 715 433 657 494 q 772 275 772 371 q 683 74 772 147 q 438 0 593 0 l 125 0 l 125 986 m 439 114 q 594 156 540 114 q 647 275 647 199 q 594 400 647 356 q 439 444 540 444 l 244 444 l 244 114 l 439 114 m 408 558 q 606 714 606 558 q 408 872 606 872 l 244 872 l 244 558 l 408 558 z "},"C":{"ha":833,"x_min":53,"x_max":772,"o":"m 419 -22 q 149 115 244 -22 q 53 492 53 253 q 149 871 53 733 q 419 1008 244 1008 q 642 923 550 1008 q 764 685 733 838 l 636 676 q 556 836 613 781 q 419 892 499 892 q 240 788 303 892 q 178 492 178 685 q 240 197 178 300 q 419 94 303 94 q 566 156 507 94 q 646 332 625 217 l 772 325 q 651 70 744 163 q 419 -22 558 -22 z "},"Ć":{"ha":833,"x_min":53,"x_max":772,"o":"m 419 -22 q 149 115 244 -22 q 53 492 53 253 q 149 871 53 733 q 419 1008 244 1008 q 642 923 550 1008 q 764 685 733 838 l 636 676 q 556 836 613 781 q 419 892 499 892 q 240 788 303 892 q 178 492 178 685 q 240 197 178 300 q 419 94 303 94 q 566 156 507 94 q 646 332 625 217 l 772 325 q 651 70 744 163 q 419 -22 558 -22 m 458 1264 l 589 1264 l 464 1086 l 367 1086 l 458 1264 z "},"Č":{"ha":833,"x_min":53,"x_max":772,"o":"m 419 -22 q 149 115 244 -22 q 53 492 53 253 q 149 871 53 733 q 419 1008 244 1008 q 642 923 550 1008 q 764 685 733 838 l 636 676 q 556 836 613 781 q 419 892 499 892 q 240 788 303 892 q 178 492 178 685 q 240 197 178 300 q 419 94 303 94 q 566 156 507 94 q 646 332 625 217 l 772 325 q 651 70 744 163 q 419 -22 558 -22 m 322 1264 l 419 1139 l 517 1264 l 614 1264 l 485 1079 l 354 1079 l 225 1264 l 322 1264 z "},"Ç":{"ha":833,"x_min":53,"x_max":772,"o":"m 428 -306 q 344 -284 381 -306 q 265 -212 308 -262 l 322 -160 q 369 -211 347 -192 q 422 -231 392 -231 q 470 -215 453 -231 q 488 -171 488 -199 q 468 -129 488 -144 q 415 -114 449 -114 q 382 -118 401 -114 l 343 -67 l 385 -21 q 140 128 226 -7 q 53 492 53 264 q 149 871 53 733 q 419 1008 244 1008 q 642 923 550 1008 q 764 685 733 838 l 636 676 q 556 836 613 781 q 419 892 499 892 q 240 788 303 892 q 178 492 178 685 q 240 197 178 300 q 419 94 303 94 q 566 156 507 94 q 646 332 625 217 l 772 325 q 665 83 747 174 q 458 -21 582 -8 l 429 -51 q 526 -86 489 -51 q 564 -176 564 -121 q 524 -268 564 -231 q 428 -306 485 -306 z "},"Ĉ":{"ha":833,"x_min":53,"x_max":772,"o":"m 419 -22 q 149 115 244 -22 q 53 492 53 253 q 149 871 53 733 q 419 1008 244 1008 q 642 923 550 1008 q 764 685 733 838 l 636 676 q 556 836 613 781 q 419 892 499 892 q 240 788 303 892 q 178 492 178 685 q 240 197 178 300 q 419 94 303 94 q 566 156 507 94 q 646 332 625 217 l 772 325 q 651 70 744 163 q 419 -22 558 -22 m 353 1265 l 483 1265 l 613 1081 l 515 1081 l 418 1206 l 321 1081 l 224 1081 l 353 1265 z "},"Ċ":{"ha":833,"x_min":53,"x_max":772,"o":"m 419 -22 q 149 115 244 -22 q 53 492 53 253 q 149 871 53 733 q 419 1008 244 1008 q 642 923 550 1008 q 764 685 733 838 l 636 676 q 556 836 613 781 q 419 892 499 892 q 240 788 303 892 q 178 492 178 685 q 240 197 178 300 q 419 94 303 94 q 566 156 507 94 q 646 332 625 217 l 772 325 q 651 70 744 163 q 419 -22 558 -22 m 357 1238 l 479 1238 l 479 1101 l 357 1101 l 357 1238 z "},"D":{"ha":833,"x_min":125,"x_max":781,"o":"m 125 986 l 358 986 q 581 928 486 986 q 728 759 676 871 q 781 492 781 647 q 728 225 781 336 q 581 57 676 114 q 358 0 486 0 l 125 0 l 125 986 m 351 114 q 576 210 497 114 q 656 492 656 307 q 576 775 656 678 q 351 872 497 872 l 244 872 l 244 114 l 351 114 z "},"Ď":{"ha":833,"x_min":125,"x_max":781,"o":"m 125 986 l 358 986 q 581 928 486 986 q 728 759 676 871 q 781 492 781 647 q 728 225 781 336 q 581 57 676 114 q 358 0 486 0 l 125 0 l 125 986 m 351 114 q 576 210 497 114 q 656 492 656 307 q 576 775 656 678 q 351 872 497 872 l 244 872 l 244 114 l 351 114 m 321 1264 l 418 1139 l 515 1264 l 613 1264 l 483 1079 l 353 1079 l 224 1264 l 321 1264 z "},"Đ":{"ha":833,"x_min":-14,"x_max":835,"o":"m -14 544 l 179 544 l 179 986 l 413 986 q 635 928 540 986 q 783 759 731 871 q 835 492 835 647 q 783 225 835 336 q 635 57 731 114 q 413 0 540 0 l 179 0 l 179 442 l -14 442 l -14 544 m 406 114 q 631 210 551 114 q 710 492 710 307 q 631 775 710 678 q 406 872 551 872 l 299 872 l 299 544 l 431 544 l 431 442 l 299 442 l 299 114 l 406 114 z "},"Ð":{"ha":833,"x_min":-14,"x_max":835,"o":"m -14 544 l 179 544 l 179 986 l 413 986 q 635 928 540 986 q 783 759 731 871 q 835 492 835 647 q 783 225 835 336 q 635 57 731 114 q 413 0 540 0 l 179 0 l 179 442 l -14 442 l -14 544 m 406 114 q 631 210 551 114 q 710 492 710 307 q 631 775 710 678 q 406 872 551 872 l 299 872 l 299 544 l 431 544 l 431 442 l 299 442 l 299 114 l 406 114 z "},"E":{"ha":833,"x_min":125,"x_max":736,"o":"m 125 986 l 725 986 l 725 872 l 244 872 l 244 553 l 708 553 l 708 439 l 244 439 l 244 114 l 736 114 l 736 0 l 125 0 l 125 986 z "},"É":{"ha":833,"x_min":125,"x_max":736,"o":"m 125 986 l 725 986 l 725 872 l 244 872 l 244 553 l 708 553 l 708 439 l 244 439 l 244 114 l 736 114 l 736 0 l 125 0 l 125 986 m 481 1265 l 611 1265 l 486 1088 l 389 1088 l 481 1265 z "},"Ě":{"ha":833,"x_min":125,"x_max":736,"o":"m 125 986 l 725 986 l 725 872 l 244 872 l 244 553 l 708 553 l 708 439 l 244 439 l 244 114 l 736 114 l 736 0 l 125 0 l 125 986 m 344 1265 l 442 1140 l 539 1265 l 636 1265 l 507 1081 l 376 1081 l 247 1265 l 344 1265 z "},"Ê":{"ha":833,"x_min":125,"x_max":736,"o":"m 125 986 l 725 986 l 725 872 l 244 872 l 244 553 l 708 553 l 708 439 l 244 439 l 244 114 l 736 114 l 736 0 l 125 0 l 125 986 m 375 1267 l 506 1267 l 635 1082 l 538 1082 l 440 1207 l 343 1082 l 246 1082 l 375 1267 z "},"Ë":{"ha":833,"x_min":125,"x_max":736,"o":"m 125 986 l 725 986 l 725 872 l 244 872 l 244 553 l 708 553 l 708 439 l 244 439 l 244 114 l 736 114 l 736 0 l 125 0 l 125 986 z "},"Ė":{"ha":833,"x_min":125,"x_max":736,"o":"m 125 986 l 725 986 l 725 872 l 244 872 l 244 553 l 708 553 l 708 439 l 244 439 l 244 114 l 736 114 l 736 0 l 125 0 l 125 986 m 379 1239 l 501 1239 l 501 1103 l 379 1103 l 379 1239 z "},"È":{"ha":833,"x_min":125,"x_max":736,"o":"m 125 986 l 725 986 l 725 872 l 244 872 l 244 553 l 708 553 l 708 439 l 244 439 l 244 114 l 736 114 l 736 0 l 125 0 l 125 986 m 275 1265 l 406 1265 l 497 1088 l 400 1088 l 275 1265 z "},"Ē":{"ha":833,"x_min":125,"x_max":736,"o":"m 125 986 l 725 986 l 725 872 l 244 872 l 244 553 l 708 553 l 708 439 l 244 439 l 244 114 l 736 114 l 736 0 l 125 0 l 125 986 m 254 1219 l 626 1219 l 626 1119 l 254 1119 l 254 1219 z "},"Ę":{"ha":833,"x_min":125,"x_max":789,"o":"m 125 986 l 725 986 l 725 872 l 244 872 l 244 553 l 708 553 l 708 439 l 244 439 l 244 114 l 736 114 l 736 1 l 742 0 l 693 -62 q 659 -117 668 -96 q 650 -156 650 -137 q 692 -197 650 -197 q 726 -192 708 -197 q 756 -178 744 -187 l 789 -256 q 740 -282 772 -272 q 669 -292 708 -292 q 578 -262 611 -292 q 544 -181 544 -232 q 601 -42 544 -112 l 635 0 l 125 0 l 125 986 z "},"Ẽ":{"ha":833,"x_min":125,"x_max":736,"o":"m 125 986 l 725 986 l 725 872 l 244 872 l 244 553 l 708 553 l 708 439 l 244 439 l 244 114 l 736 114 l 736 0 l 125 0 l 125 986 m 525 1099 q 428 1140 479 1099 q 388 1169 401 1163 q 356 1176 375 1176 q 294 1089 299 1176 l 222 1089 q 260 1209 224 1167 q 356 1251 297 1251 q 410 1240 386 1251 q 461 1207 435 1229 q 499 1181 483 1189 q 531 1174 514 1174 q 571 1194 557 1174 q 589 1265 585 1214 l 661 1265 q 621 1142 657 1186 q 525 1099 585 1099 z "},"Ə":{"ha":833,"x_min":39,"x_max":794,"o":"m 406 -22 q 218 40 300 -22 q 90 215 136 103 q 40 472 43 328 l 39 542 l 668 542 q 626 724 661 644 q 535 847 592 803 q 406 892 478 892 q 274 837 332 892 q 192 696 215 782 l 65 707 q 198 927 107 846 q 406 1008 289 1008 q 609 943 521 1008 q 746 760 697 878 q 794 493 794 643 q 744 232 794 350 q 604 46 693 114 q 406 -22 515 -22 m 406 94 q 576 180 510 94 q 667 425 643 265 l 168 425 q 248 180 188 265 q 406 94 308 94 z "},"F":{"ha":833,"x_min":139,"x_max":750,"o":"m 139 986 l 750 986 l 750 872 l 258 872 l 258 539 l 725 539 l 725 425 l 258 425 l 258 0 l 139 0 l 139 986 z "},"G":{"ha":833,"x_min":53,"x_max":769,"o":"m 414 -22 q 226 37 308 -22 q 99 211 144 96 q 53 492 53 326 q 97 767 53 650 q 223 946 140 883 q 419 1008 306 1008 q 642 922 551 1008 q 765 692 732 836 l 640 683 q 560 835 615 779 q 419 892 504 892 q 241 788 304 892 q 178 492 178 683 q 242 198 178 301 q 425 94 307 94 q 544 132 492 94 q 626 235 596 169 q 658 383 656 301 l 422 383 l 422 497 l 769 497 l 769 0 l 658 0 l 656 126 q 414 -22 578 -22 z "},"Ğ":{"ha":833,"x_min":53,"x_max":769,"o":"m 414 -22 q 226 37 308 -22 q 99 211 144 96 q 53 492 53 326 q 97 767 53 650 q 223 946 140 883 q 419 1008 306 1008 q 642 922 551 1008 q 765 692 732 836 l 640 683 q 560 835 615 779 q 419 892 504 892 q 241 788 304 892 q 178 492 178 683 q 242 198 178 301 q 425 94 307 94 q 544 132 492 94 q 626 235 596 169 q 658 383 656 301 l 422 383 l 422 497 l 769 497 l 769 0 l 658 0 l 656 126 q 414 -22 578 -22 m 415 1088 q 278 1135 331 1088 q 221 1265 225 1183 l 301 1265 q 415 1174 314 1174 q 529 1265 517 1174 l 610 1265 q 553 1135 606 1183 q 415 1088 500 1088 z "},"Ǧ":{"ha":833,"x_min":53,"x_max":769,"o":"m 414 -22 q 226 37 308 -22 q 99 211 144 96 q 53 492 53 326 q 97 767 53 650 q 223 946 140 883 q 419 1008 306 1008 q 642 922 551 1008 q 765 692 732 836 l 640 683 q 560 835 615 779 q 419 892 504 892 q 241 788 304 892 q 178 492 178 683 q 242 198 178 301 q 425 94 307 94 q 544 132 492 94 q 626 235 596 169 q 658 383 656 301 l 422 383 l 422 497 l 769 497 l 769 0 l 658 0 l 656 126 q 414 -22 578 -22 m 318 1264 l 415 1139 l 513 1264 l 610 1264 l 481 1079 l 350 1079 l 221 1264 l 318 1264 z "},"Ĝ":{"ha":833,"x_min":53,"x_max":769,"o":"m 414 -22 q 226 37 308 -22 q 99 211 144 96 q 53 492 53 326 q 97 767 53 650 q 223 946 140 883 q 419 1008 306 1008 q 642 922 551 1008 q 765 692 732 836 l 640 683 q 560 835 615 779 q 419 892 504 892 q 241 788 304 892 q 178 492 178 683 q 242 198 178 301 q 425 94 307 94 q 544 132 492 94 q 626 235 596 169 q 658 383 656 301 l 422 383 l 422 497 l 769 497 l 769 0 l 658 0 l 656 126 q 414 -22 578 -22 m 349 1265 l 479 1265 l 608 1081 l 511 1081 l 414 1206 l 317 1081 l 219 1081 l 349 1265 z "},"Ģ":{"ha":833,"x_min":53,"x_max":769,"o":"m 414 -22 q 226 37 308 -22 q 99 211 144 96 q 53 492 53 326 q 97 767 53 650 q 223 946 140 883 q 419 1008 306 1008 q 642 922 551 1008 q 765 692 732 836 l 640 683 q 560 835 615 779 q 419 892 504 892 q 241 788 304 892 q 178 492 178 683 q 242 198 178 301 q 425 94 307 94 q 544 132 492 94 q 626 235 596 169 q 658 383 656 301 l 422 383 l 422 497 l 769 497 l 769 0 l 658 0 l 656 126 q 414 -22 578 -22 m 407 -172 l 360 -172 l 360 -47 l 499 -47 l 499 -167 l 410 -297 l 332 -297 l 407 -172 z "},"Ġ":{"ha":833,"x_min":53,"x_max":769,"o":"m 414 -22 q 226 37 308 -22 q 99 211 144 96 q 53 492 53 326 q 97 767 53 650 q 223 946 140 883 q 419 1008 306 1008 q 642 922 551 1008 q 765 692 732 836 l 640 683 q 560 835 615 779 q 419 892 504 892 q 241 788 304 892 q 178 492 178 683 q 242 198 178 301 q 425 94 307 94 q 544 132 492 94 q 626 235 596 169 q 658 383 656 301 l 422 383 l 422 497 l 769 497 l 769 0 l 658 0 l 656 126 q 414 -22 578 -22 m 353 1238 l 475 1238 l 475 1101 l 353 1101 l 353 1238 z "},"Ḡ":{"ha":833,"x_min":53,"x_max":769,"o":"m 414 -22 q 226 37 308 -22 q 99 211 144 96 q 53 492 53 326 q 97 767 53 650 q 223 946 140 883 q 419 1008 306 1008 q 642 922 551 1008 q 765 692 732 836 l 640 683 q 560 835 615 779 q 419 892 504 892 q 241 788 304 892 q 178 492 178 683 q 242 198 178 301 q 425 94 307 94 q 544 132 492 94 q 626 235 596 169 q 658 383 656 301 l 422 383 l 422 497 l 769 497 l 769 0 l 658 0 l 656 126 q 414 -22 578 -22 m 228 1218 l 600 1218 l 600 1118 l 228 1118 l 228 1218 z "},"Ǥ":{"ha":833,"x_min":28,"x_max":821,"o":"m 418 -22 q 217 46 306 -22 q 78 232 128 114 q 28 492 28 350 q 78 753 28 635 q 217 940 128 871 q 418 1008 306 1008 q 649 922 551 1008 q 779 697 746 835 l 654 689 q 566 835 629 779 q 418 892 503 892 q 276 838 336 892 q 184 692 215 783 q 153 492 153 600 q 184 293 153 383 q 276 149 215 203 q 418 94 336 94 q 536 123 485 94 q 619 204 588 151 l 418 204 l 418 307 l 656 307 q 661 382 661 339 l 418 382 l 418 499 l 788 499 l 788 438 q 776 307 788 371 l 821 307 l 821 204 l 747 204 q 618 37 703 96 q 418 -22 533 -22 z "},"H":{"ha":833,"x_min":94,"x_max":739,"o":"m 94 986 l 214 986 l 214 553 l 619 553 l 619 986 l 739 986 l 739 0 l 619 0 l 619 439 l 214 439 l 214 0 l 94 0 l 94 986 z "},"Ħ":{"ha":833,"x_min":33,"x_max":800,"o":"m 588 444 l 246 444 l 246 0 l 126 0 l 126 735 l 33 735 l 33 851 l 126 851 l 126 986 l 246 986 l 246 851 l 588 851 l 588 986 l 707 986 l 707 851 l 800 851 l 800 735 l 707 735 l 707 0 l 588 0 l 588 444 m 588 561 l 588 735 l 246 735 l 246 561 l 588 561 z "},"Ĥ":{"ha":833,"x_min":94,"x_max":739,"o":"m 94 986 l 214 986 l 214 553 l 619 553 l 619 986 l 739 986 l 739 0 l 619 0 l 619 439 l 214 439 l 214 0 l 94 0 l 94 986 m 350 1265 l 481 1265 l 610 1081 l 513 1081 l 415 1206 l 318 1081 l 221 1081 l 350 1265 z "},"I":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 114 l 357 114 l 357 872 l 111 872 l 111 986 l 722 986 l 722 872 l 476 872 l 476 114 l 722 114 l 722 0 l 111 0 l 111 114 z "},"Í":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 114 l 357 114 l 357 872 l 111 872 l 111 986 l 722 986 l 722 872 l 476 872 l 476 114 l 722 114 l 722 0 l 111 0 l 111 114 m 456 1264 l 586 1264 l 461 1086 l 364 1086 l 456 1264 z "},"Î":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 114 l 357 114 l 357 872 l 111 872 l 111 986 l 722 986 l 722 872 l 476 872 l 476 114 l 722 114 l 722 0 l 111 0 l 111 114 m 350 1265 l 481 1265 l 610 1081 l 513 1081 l 415 1206 l 318 1081 l 221 1081 l 350 1265 z "},"Ï":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 114 l 357 114 l 357 872 l 111 872 l 111 986 l 722 986 l 722 872 l 476 872 l 476 114 l 722 114 l 722 0 l 111 0 l 111 114 z "},"İ":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 114 l 357 114 l 357 872 l 111 872 l 111 986 l 722 986 l 722 872 l 476 872 l 476 114 l 722 114 l 722 0 l 111 0 l 111 114 m 354 1238 l 476 1238 l 476 1101 l 354 1101 l 354 1238 z "},"Ì":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 114 l 357 114 l 357 872 l 111 872 l 111 986 l 722 986 l 722 872 l 476 872 l 476 114 l 722 114 l 722 0 l 111 0 l 111 114 m 250 1264 l 381 1264 l 472 1086 l 375 1086 l 250 1264 z "},"Ī":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 114 l 357 114 l 357 872 l 111 872 l 111 986 l 722 986 l 722 872 l 476 872 l 476 114 l 722 114 l 722 0 l 111 0 l 111 114 m 229 1218 l 601 1218 l 601 1118 l 229 1118 l 229 1218 z "},"Į":{"ha":833,"x_min":111,"x_max":769,"o":"m 111 114 l 357 114 l 357 872 l 111 872 l 111 986 l 722 986 l 722 872 l 476 872 l 476 114 l 722 114 l 722 0 l 674 -62 q 640 -117 649 -96 q 631 -156 631 -137 q 672 -197 631 -197 q 707 -192 689 -197 q 736 -178 725 -187 l 769 -256 q 721 -282 753 -272 q 650 -292 689 -292 q 558 -262 592 -292 q 525 -181 525 -232 q 582 -42 525 -112 l 615 0 l 111 0 l 111 114 z "},"Ĩ":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 114 l 357 114 l 357 872 l 111 872 l 111 986 l 722 986 l 722 872 l 476 872 l 476 114 l 722 114 l 722 0 l 111 0 l 111 114 m 500 1097 q 403 1139 454 1097 q 363 1168 376 1161 q 331 1175 350 1175 q 269 1088 274 1175 l 197 1088 q 235 1208 199 1165 q 331 1250 272 1250 q 385 1239 361 1250 q 436 1206 410 1228 q 474 1180 458 1188 q 506 1172 489 1172 q 546 1192 532 1172 q 564 1264 560 1213 l 636 1264 q 596 1141 632 1185 q 500 1097 560 1097 z "},"J":{"ha":833,"x_min":56,"x_max":708,"o":"m 382 -22 q 147 71 235 -22 q 56 319 60 164 l 172 331 q 228 152 176 210 q 382 94 279 94 q 589 331 589 94 l 589 986 l 708 986 l 708 331 q 620 72 708 167 q 382 -22 532 -22 z "},"Ĵ":{"ha":833,"x_min":56,"x_max":839,"o":"m 382 -22 q 147 71 235 -22 q 56 319 60 164 l 172 331 q 228 152 176 210 q 382 94 279 94 q 589 331 589 94 l 589 986 l 708 986 l 708 331 q 620 72 708 167 q 382 -22 532 -22 m 579 1265 l 710 1265 l 839 1081 l 742 1081 l 644 1206 l 547 1081 l 450 1081 l 579 1265 z "},"K":{"ha":833,"x_min":83,"x_max":778,"o":"m 83 986 l 203 986 l 203 550 l 603 986 l 750 986 l 375 572 l 778 0 l 631 0 l 292 489 l 203 394 l 203 0 l 83 0 l 83 986 z "},"Ǩ":{"ha":833,"x_min":83,"x_max":778,"o":"m 83 986 l 203 986 l 203 550 l 603 986 l 750 986 l 375 572 l 778 0 l 631 0 l 292 489 l 203 394 l 203 0 l 83 0 l 83 986 m 288 1264 l 385 1139 l 482 1264 l 579 1264 l 450 1079 l 319 1079 l 190 1264 l 288 1264 z "},"Ķ":{"ha":833,"x_min":83,"x_max":778,"o":"m 83 986 l 203 986 l 203 550 l 603 986 l 750 986 l 375 572 l 778 0 l 631 0 l 292 489 l 203 394 l 203 0 l 83 0 l 83 986 m 386 -172 l 339 -172 l 339 -47 l 478 -47 l 478 -167 l 389 -297 l 311 -297 l 386 -172 z "},"L":{"ha":833,"x_min":139,"x_max":750,"o":"m 139 986 l 258 986 l 258 114 l 750 114 l 750 0 l 139 0 l 139 986 z "},"Ĺ":{"ha":833,"x_min":139,"x_max":750,"o":"m 139 986 l 258 986 l 258 114 l 750 114 l 750 0 l 139 0 l 139 986 m 238 1264 l 368 1264 l 243 1086 l 146 1086 l 238 1264 z "},"Ľ":{"ha":833,"x_min":139,"x_max":750,"o":"m 139 986 l 258 986 l 258 114 l 750 114 l 750 0 l 139 0 l 139 986 m 458 986 l 574 986 l 501 650 l 417 650 l 458 986 z "},"Ļ":{"ha":833,"x_min":139,"x_max":750,"o":"m 139 986 l 258 986 l 258 114 l 750 114 l 750 0 l 139 0 l 139 986 m 438 -172 l 390 -172 l 390 -47 l 529 -47 l 529 -167 l 440 -297 l 363 -297 l 438 -172 z "},"Ł":{"ha":833,"x_min":-36,"x_max":768,"o":"m -36 515 l 157 606 l 157 986 l 276 986 l 276 661 l 392 715 l 442 610 l 276 532 l 276 114 l 768 114 l 768 0 l 157 0 l 157 476 l 14 410 l -36 515 z "},"M":{"ha":833,"x_min":83,"x_max":750,"o":"m 203 826 l 203 0 l 83 0 l 83 986 l 264 986 l 417 300 l 569 986 l 750 986 l 750 0 l 631 0 l 631 826 l 469 111 l 364 111 l 203 826 z "},"N":{"ha":833,"x_min":97,"x_max":736,"o":"m 97 986 l 264 986 l 617 158 l 617 986 l 736 986 l 736 0 l 569 0 l 217 828 l 217 0 l 97 0 l 97 986 z "},"Ń":{"ha":833,"x_min":97,"x_max":736,"o":"m 97 986 l 264 986 l 617 158 l 617 986 l 736 986 l 736 0 l 569 0 l 217 828 l 217 0 l 97 0 l 97 986 m 456 1264 l 586 1264 l 461 1086 l 364 1086 l 456 1264 z "},"Ň":{"ha":833,"x_min":97,"x_max":736,"o":"m 97 986 l 264 986 l 617 158 l 617 986 l 736 986 l 736 0 l 569 0 l 217 828 l 217 0 l 97 0 l 97 986 m 319 1264 l 417 1139 l 514 1264 l 611 1264 l 482 1079 l 351 1079 l 222 1264 l 319 1264 z "},"Ņ":{"ha":833,"x_min":97,"x_max":736,"o":"m 97 986 l 264 986 l 617 158 l 617 986 l 736 986 l 736 0 l 569 0 l 217 828 l 217 0 l 97 0 l 97 986 m 408 -172 l 361 -172 l 361 -47 l 500 -47 l 500 -167 l 411 -297 l 333 -297 l 408 -172 z "},"Ñ":{"ha":833,"x_min":97,"x_max":736,"o":"m 97 986 l 264 986 l 617 158 l 617 986 l 736 986 l 736 0 l 569 0 l 217 828 l 217 0 l 97 0 l 97 986 m 500 1097 q 403 1139 454 1097 q 363 1168 376 1161 q 331 1175 350 1175 q 269 1088 274 1175 l 197 1088 q 235 1208 199 1165 q 331 1250 272 1250 q 385 1239 361 1250 q 436 1206 410 1228 q 474 1180 458 1188 q 506 1172 489 1172 q 546 1192 532 1172 q 564 1264 560 1213 l 636 1264 q 596 1141 632 1185 q 500 1097 560 1097 z "},"Ŋ":{"ha":833,"x_min":97,"x_max":736,"o":"m 374 -92 l 517 -92 q 604 -11 604 -92 l 604 0 l 569 0 l 217 828 l 217 0 l 97 0 l 97 986 l 264 986 l 617 158 l 617 986 l 736 986 l 736 0 l 725 0 l 725 -10 q 669 -154 725 -100 q 517 -208 613 -208 l 374 -208 l 374 -92 z "},"O":{"ha":833,"x_min":53,"x_max":781,"o":"m 417 -22 q 147 111 242 -22 q 53 492 53 244 q 147 874 53 740 q 417 1008 242 1008 q 686 874 592 1008 q 781 492 781 740 q 686 111 781 244 q 417 -22 592 -22 m 417 94 q 594 197 532 94 q 656 492 656 299 q 594 788 656 685 q 417 892 532 892 q 240 788 301 892 q 178 492 178 685 q 240 197 178 299 q 417 94 301 94 z "},"Ó":{"ha":833,"x_min":53,"x_max":781,"o":"m 417 -22 q 147 111 242 -22 q 53 492 53 244 q 147 874 53 740 q 417 1008 242 1008 q 686 874 592 1008 q 781 492 781 740 q 686 111 781 244 q 417 -22 592 -22 m 417 94 q 594 197 532 94 q 656 492 656 299 q 594 788 656 685 q 417 892 532 892 q 240 788 301 892 q 178 492 178 685 q 240 197 178 299 q 417 94 301 94 m 456 1264 l 586 1264 l 461 1086 l 364 1086 l 456 1264 z "},"Ô":{"ha":833,"x_min":53,"x_max":781,"o":"m 417 -22 q 147 111 242 -22 q 53 492 53 244 q 147 874 53 740 q 417 1008 242 1008 q 686 874 592 1008 q 781 492 781 740 q 686 111 781 244 q 417 -22 592 -22 m 417 94 q 594 197 532 94 q 656 492 656 299 q 594 788 656 685 q 417 892 532 892 q 240 788 301 892 q 178 492 178 685 q 240 197 178 299 q 417 94 301 94 m 350 1265 l 481 1265 l 610 1081 l 513 1081 l 415 1206 l 318 1081 l 221 1081 l 350 1265 z "},"Ö":{"ha":833,"x_min":53,"x_max":781,"o":"m 417 -22 q 147 111 242 -22 q 53 492 53 244 q 147 874 53 740 q 417 1008 242 1008 q 686 874 592 1008 q 781 492 781 740 q 686 111 781 244 q 417 -22 592 -22 m 417 94 q 594 197 532 94 q 656 492 656 299 q 594 788 656 685 q 417 892 532 892 q 240 788 301 892 q 178 492 178 685 q 240 197 178 299 q 417 94 301 94 z "},"Ò":{"ha":833,"x_min":53,"x_max":781,"o":"m 417 -22 q 147 111 242 -22 q 53 492 53 244 q 147 874 53 740 q 417 1008 242 1008 q 686 874 592 1008 q 781 492 781 740 q 686 111 781 244 q 417 -22 592 -22 m 417 94 q 594 197 532 94 q 656 492 656 299 q 594 788 656 685 q 417 892 532 892 q 240 788 301 892 q 178 492 178 685 q 240 197 178 299 q 417 94 301 94 m 250 1264 l 381 1264 l 472 1086 l 375 1086 l 250 1264 z "},"Ő":{"ha":833,"x_min":53,"x_max":781,"o":"m 417 -22 q 147 111 242 -22 q 53 492 53 244 q 147 874 53 740 q 417 1008 242 1008 q 686 874 592 1008 q 781 492 781 740 q 686 111 781 244 q 417 -22 592 -22 m 417 94 q 594 197 532 94 q 656 492 656 299 q 594 788 656 685 q 417 892 532 892 q 240 788 301 892 q 178 492 178 685 q 240 197 178 299 q 417 94 301 94 z "},"Ō":{"ha":833,"x_min":53,"x_max":781,"o":"m 417 -22 q 147 111 242 -22 q 53 492 53 244 q 147 874 53 740 q 417 1008 242 1008 q 686 874 592 1008 q 781 492 781 740 q 686 111 781 244 q 417 -22 592 -22 m 417 94 q 594 197 532 94 q 656 492 656 299 q 594 788 656 685 q 417 892 532 892 q 240 788 301 892 q 178 492 178 685 q 240 197 178 299 q 417 94 301 94 m 229 1218 l 601 1218 l 601 1118 l 229 1118 l 229 1218 z "},"Ø":{"ha":833,"x_min":53,"x_max":781,"o":"m 139 124 q 53 492 53 256 q 147 874 53 740 q 417 1008 242 1008 q 622 943 538 1008 l 661 1008 l 775 1008 l 692 867 q 781 492 781 733 q 686 111 781 244 q 417 -22 592 -22 q 210 46 294 -22 l 169 -22 l 53 -22 l 139 124 m 417 94 q 594 197 532 94 q 656 492 656 299 q 617 740 656 646 l 272 153 q 417 94 329 94 m 178 492 q 214 250 178 346 l 558 835 q 417 892 501 892 q 240 788 301 892 q 178 492 178 685 z "},"Õ":{"ha":833,"x_min":53,"x_max":781,"o":"m 417 -22 q 147 111 242 -22 q 53 492 53 244 q 147 874 53 740 q 417 1008 242 1008 q 686 874 592 1008 q 781 492 781 740 q 686 111 781 244 q 417 -22 592 -22 m 417 94 q 594 197 532 94 q 656 492 656 299 q 594 788 656 685 q 417 892 532 892 q 240 788 301 892 q 178 492 178 685 q 240 197 178 299 q 417 94 301 94 m 500 1097 q 403 1139 454 1097 q 363 1168 376 1161 q 331 1175 350 1175 q 269 1088 274 1175 l 197 1088 q 235 1208 199 1165 q 331 1250 272 1250 q 385 1239 361 1250 q 436 1206 410 1228 q 474 1180 458 1188 q 506 1172 489 1172 q 546 1192 532 1172 q 564 1264 560 1213 l 636 1264 q 596 1141 632 1185 q 500 1097 560 1097 z "},"Œ":{"ha":833,"x_min":10,"x_max":822,"o":"m 294 -22 q 142 40 206 -22 q 44 218 78 103 q 10 492 10 333 q 44 767 10 651 q 142 946 78 883 q 294 1008 206 1008 q 458 935 389 1008 l 458 986 l 815 986 l 815 869 l 578 869 l 578 551 l 806 551 l 806 438 l 578 438 l 578 117 l 822 117 l 822 0 l 458 0 l 458 51 q 294 -22 389 -22 m 294 94 q 417 197 379 94 q 456 492 456 299 q 417 789 456 686 q 294 892 379 892 q 171 789 210 892 q 132 492 132 686 q 171 197 132 300 q 294 94 210 94 z "},"P":{"ha":833,"x_min":125,"x_max":764,"o":"m 125 986 l 425 986 q 672 907 581 986 q 764 688 764 828 q 672 468 764 547 q 425 389 581 389 l 244 389 l 244 0 l 125 0 l 125 986 m 411 503 q 639 688 639 503 q 411 872 639 872 l 244 872 l 244 503 l 411 503 z "},"Þ":{"ha":833,"x_min":125,"x_max":786,"o":"m 125 986 l 244 986 l 244 808 l 436 808 q 694 730 601 808 q 786 511 786 651 q 694 288 786 368 q 436 207 601 207 l 244 207 l 244 0 l 125 0 l 125 986 m 436 324 q 661 511 661 324 q 436 692 661 692 l 244 692 l 244 324 l 436 324 z "},"Q":{"ha":833,"x_min":53,"x_max":781,"o":"m 531 -6 q 417 -22 479 -22 q 147 111 242 -22 q 53 492 53 244 q 147 874 53 740 q 417 1008 242 1008 q 686 874 592 1008 q 781 492 781 740 q 743 224 781 336 q 632 51 706 113 l 717 -97 l 583 -97 l 531 -6 m 417 94 q 469 100 447 94 l 361 289 l 494 289 l 568 161 q 656 492 656 261 q 594 788 656 685 q 417 892 532 892 q 240 788 301 892 q 178 492 178 685 q 240 197 178 299 q 417 94 301 94 z "},"R":{"ha":833,"x_min":97,"x_max":742,"o":"m 97 986 l 397 986 q 643 908 553 986 q 733 692 733 829 q 688 547 733 610 q 574 458 642 483 q 676 403 643 444 q 717 283 710 363 l 742 0 l 621 0 l 597 268 q 550 369 590 338 q 422 400 510 400 l 217 400 l 217 0 l 97 0 l 97 986 m 397 514 q 553 560 499 514 q 608 692 608 607 q 553 825 608 778 q 397 872 499 872 l 217 872 l 217 514 l 397 514 z "},"Ŕ":{"ha":833,"x_min":97,"x_max":742,"o":"m 97 986 l 397 986 q 643 908 553 986 q 733 692 733 829 q 688 547 733 610 q 574 458 642 483 q 676 403 643 444 q 717 283 710 363 l 742 0 l 621 0 l 597 268 q 550 369 590 338 q 422 400 510 400 l 217 400 l 217 0 l 97 0 l 97 986 m 397 514 q 553 560 499 514 q 608 692 608 607 q 553 825 608 778 q 397 872 499 872 l 217 872 l 217 514 l 397 514 m 436 1264 l 567 1264 l 442 1086 l 344 1086 l 436 1264 z "},"Ř":{"ha":833,"x_min":97,"x_max":742,"o":"m 97 986 l 397 986 q 643 908 553 986 q 733 692 733 829 q 688 547 733 610 q 574 458 642 483 q 676 403 643 444 q 717 283 710 363 l 742 0 l 621 0 l 597 268 q 550 369 590 338 q 422 400 510 400 l 217 400 l 217 0 l 97 0 l 97 986 m 397 514 q 553 560 499 514 q 608 692 608 607 q 553 825 608 778 q 397 872 499 872 l 217 872 l 217 514 l 397 514 m 300 1264 l 397 1139 l 494 1264 l 592 1264 l 463 1079 l 332 1079 l 203 1264 l 300 1264 z "},"Ŗ":{"ha":833,"x_min":97,"x_max":742,"o":"m 97 986 l 397 986 q 643 908 553 986 q 733 692 733 829 q 688 547 733 610 q 574 458 642 483 q 676 403 643 444 q 717 283 710 363 l 742 0 l 621 0 l 597 268 q 550 369 590 338 q 422 400 510 400 l 217 400 l 217 0 l 97 0 l 97 986 m 397 514 q 553 560 499 514 q 608 692 608 607 q 553 825 608 778 q 397 872 499 872 l 217 872 l 217 514 l 397 514 m 414 -172 l 367 -172 l 367 -47 l 506 -47 l 506 -167 l 417 -297 l 339 -297 l 414 -172 z "},"S":{"ha":833,"x_min":61,"x_max":772,"o":"m 432 -22 q 248 20 329 -22 q 118 139 167 63 q 61 315 69 215 l 186 324 q 265 154 200 214 q 435 94 331 94 q 592 133 538 94 q 647 244 647 172 q 625 328 647 294 q 544 393 603 363 q 371 456 485 424 q 203 521 265 486 q 113 605 142 556 q 85 728 85 654 q 124 874 85 810 q 238 973 164 938 q 413 1008 313 1008 q 649 922 560 1008 q 754 694 738 835 l 629 686 q 561 836 617 781 q 410 892 506 892 q 264 848 318 892 q 210 733 210 804 q 230 657 210 686 q 299 606 250 628 q 446 556 349 583 q 638 480 568 521 q 740 382 708 439 q 772 242 772 325 q 729 105 772 165 q 609 11 686 44 q 432 -22 532 -22 z "},"Ś":{"ha":833,"x_min":61,"x_max":772,"o":"m 432 -22 q 248 20 329 -22 q 118 139 167 63 q 61 315 69 215 l 186 324 q 265 154 200 214 q 435 94 331 94 q 592 133 538 94 q 647 244 647 172 q 625 328 647 294 q 544 393 603 363 q 371 456 485 424 q 203 521 265 486 q 113 605 142 556 q 85 728 85 654 q 124 874 85 810 q 238 973 164 938 q 413 1008 313 1008 q 649 922 560 1008 q 754 694 738 835 l 629 686 q 561 836 617 781 q 410 892 506 892 q 264 848 318 892 q 210 733 210 804 q 230 657 210 686 q 299 606 250 628 q 446 556 349 583 q 638 480 568 521 q 740 382 708 439 q 772 242 772 325 q 729 105 772 165 q 609 11 686 44 q 432 -22 532 -22 m 454 1264 l 585 1264 l 460 1086 l 363 1086 l 454 1264 z "},"Š":{"ha":833,"x_min":61,"x_max":772,"o":"m 432 -22 q 248 20 329 -22 q 118 139 167 63 q 61 315 69 215 l 186 324 q 265 154 200 214 q 435 94 331 94 q 592 133 538 94 q 647 244 647 172 q 625 328 647 294 q 544 393 603 363 q 371 456 485 424 q 203 521 265 486 q 113 605 142 556 q 85 728 85 654 q 124 874 85 810 q 238 973 164 938 q 413 1008 313 1008 q 649 922 560 1008 q 754 694 738 835 l 629 686 q 561 836 617 781 q 410 892 506 892 q 264 848 318 892 q 210 733 210 804 q 230 657 210 686 q 299 606 250 628 q 446 556 349 583 q 638 480 568 521 q 740 382 708 439 q 772 242 772 325 q 729 105 772 165 q 609 11 686 44 q 432 -22 532 -22 m 318 1264 l 415 1139 l 513 1264 l 610 1264 l 481 1079 l 350 1079 l 221 1264 l 318 1264 z "},"Ş":{"ha":833,"x_min":61,"x_max":772,"o":"m 407 -306 q 324 -284 360 -306 q 244 -212 288 -262 l 301 -160 q 349 -211 326 -192 q 401 -231 371 -231 q 449 -215 432 -231 q 467 -171 467 -199 q 447 -129 467 -144 q 394 -114 428 -114 q 361 -118 381 -114 l 322 -67 l 367 -18 q 155 91 238 1 q 61 315 72 181 l 186 324 q 265 154 200 214 q 435 94 331 94 q 592 133 538 94 q 647 244 647 172 q 625 328 647 294 q 544 393 603 363 q 371 456 485 424 q 203 521 265 486 q 113 605 142 556 q 85 728 85 654 q 124 874 85 810 q 238 973 164 938 q 413 1008 313 1008 q 649 922 560 1008 q 754 694 738 835 l 629 686 q 561 836 617 781 q 410 892 506 892 q 264 848 318 892 q 210 733 210 804 q 230 657 210 686 q 299 606 250 628 q 446 556 349 583 q 638 480 568 521 q 740 382 708 439 q 772 242 772 325 q 730 106 772 165 q 610 12 688 46 q 435 -22 533 -22 l 408 -51 q 506 -86 468 -51 q 543 -176 543 -121 q 503 -268 543 -231 q 407 -306 464 -306 z "},"Ŝ":{"ha":833,"x_min":61,"x_max":772,"o":"m 432 -22 q 248 20 329 -22 q 118 139 167 63 q 61 315 69 215 l 186 324 q 265 154 200 214 q 435 94 331 94 q 592 133 538 94 q 647 244 647 172 q 625 328 647 294 q 544 393 603 363 q 371 456 485 424 q 203 521 265 486 q 113 605 142 556 q 85 728 85 654 q 124 874 85 810 q 238 973 164 938 q 413 1008 313 1008 q 649 922 560 1008 q 754 694 738 835 l 629 686 q 561 836 617 781 q 410 892 506 892 q 264 848 318 892 q 210 733 210 804 q 230 657 210 686 q 299 606 250 628 q 446 556 349 583 q 638 480 568 521 q 740 382 708 439 q 772 242 772 325 q 729 105 772 165 q 609 11 686 44 q 432 -22 532 -22 m 349 1265 l 479 1265 l 608 1081 l 511 1081 l 414 1206 l 317 1081 l 219 1081 l 349 1265 z "},"Ș":{"ha":833,"x_min":61,"x_max":772,"o":"m 432 -22 q 248 20 329 -22 q 118 139 167 63 q 61 315 69 215 l 186 324 q 265 154 200 214 q 435 94 331 94 q 592 133 538 94 q 647 244 647 172 q 625 328 647 294 q 544 393 603 363 q 371 456 485 424 q 203 521 265 486 q 113 605 142 556 q 85 728 85 654 q 124 874 85 810 q 238 973 164 938 q 413 1008 313 1008 q 649 922 560 1008 q 754 694 738 835 l 629 686 q 561 836 617 781 q 410 892 506 892 q 264 848 318 892 q 210 733 210 804 q 230 657 210 686 q 299 606 250 628 q 446 556 349 583 q 638 480 568 521 q 740 382 708 439 q 772 242 772 325 q 729 105 772 165 q 609 11 686 44 q 432 -22 532 -22 m 408 -172 l 361 -172 l 361 -47 l 500 -47 l 500 -167 l 411 -297 l 333 -297 l 408 -172 z "},"ẞ":{"ha":833,"x_min":50,"x_max":799,"o":"m 50 696 q 125 908 50 831 q 328 986 200 986 l 732 986 l 732 858 l 426 589 q 621 556 536 590 q 752 457 706 522 q 799 307 799 392 q 756 147 799 217 q 633 39 713 78 q 449 0 554 0 l 263 0 l 263 117 l 449 117 q 614 167 553 117 q 675 301 675 217 q 606 441 675 390 q 418 490 538 492 l 276 489 l 276 607 l 575 869 l 328 869 q 210 825 251 869 q 169 696 169 781 l 169 0 l 50 0 l 50 696 z "},"T":{"ha":833,"x_min":56,"x_max":778,"o":"m 357 872 l 56 872 l 56 986 l 778 986 l 778 872 l 476 872 l 476 0 l 357 0 l 357 872 z "},"Ŧ":{"ha":833,"x_min":56,"x_max":778,"o":"m 357 432 l 139 432 l 139 535 l 357 535 l 357 872 l 56 872 l 56 986 l 778 986 l 778 872 l 476 872 l 476 535 l 694 535 l 694 432 l 476 432 l 476 0 l 357 0 l 357 432 z "},"Ť":{"ha":833,"x_min":56,"x_max":778,"o":"m 357 872 l 56 872 l 56 986 l 778 986 l 778 872 l 476 872 l 476 0 l 357 0 l 357 872 m 319 1264 l 417 1139 l 514 1264 l 611 1264 l 482 1079 l 351 1079 l 222 1264 l 319 1264 z "},"Ţ":{"ha":833,"x_min":56,"x_max":778,"o":"m 357 872 l 56 872 l 56 986 l 778 986 l 778 872 l 476 872 l 476 0 l 456 0 l 408 -51 q 506 -86 468 -51 q 543 -176 543 -121 q 503 -268 543 -231 q 407 -306 464 -306 q 324 -284 360 -306 q 244 -212 288 -262 l 301 -160 q 349 -211 326 -192 q 401 -231 371 -231 q 449 -215 432 -231 q 467 -171 467 -199 q 447 -129 467 -144 q 394 -114 428 -114 q 361 -118 381 -114 l 322 -67 l 383 0 l 357 0 l 357 872 z "},"Ț":{"ha":833,"x_min":56,"x_max":778,"o":"m 357 872 l 56 872 l 56 986 l 778 986 l 778 872 l 476 872 l 476 0 l 357 0 l 357 872 m 408 -172 l 361 -172 l 361 -47 l 500 -47 l 500 -167 l 411 -297 l 333 -297 l 408 -172 z "},"U":{"ha":833,"x_min":83,"x_max":750,"o":"m 417 -22 q 172 76 261 -22 q 83 344 83 175 l 83 986 l 203 986 l 203 344 q 258 159 203 224 q 417 94 313 94 q 576 159 521 94 q 631 344 631 224 l 631 986 l 750 986 l 750 344 q 661 76 750 175 q 417 -22 572 -22 z "},"Ú":{"ha":833,"x_min":83,"x_max":750,"o":"m 417 -22 q 172 76 261 -22 q 83 344 83 175 l 83 986 l 203 986 l 203 344 q 258 159 203 224 q 417 94 313 94 q 576 159 521 94 q 631 344 631 224 l 631 986 l 750 986 l 750 344 q 661 76 750 175 q 417 -22 572 -22 m 456 1264 l 586 1264 l 461 1086 l 364 1086 l 456 1264 z "},"Ŭ":{"ha":833,"x_min":83,"x_max":750,"o":"m 417 -22 q 172 76 261 -22 q 83 344 83 175 l 83 986 l 203 986 l 203 344 q 258 159 203 224 q 417 94 313 94 q 576 159 521 94 q 631 344 631 224 l 631 986 l 750 986 l 750 344 q 661 76 750 175 q 417 -22 572 -22 m 417 1088 q 279 1135 332 1088 q 222 1265 226 1183 l 303 1265 q 417 1174 315 1174 q 531 1265 518 1174 l 611 1265 q 554 1135 607 1183 q 417 1088 501 1088 z "},"Û":{"ha":833,"x_min":83,"x_max":750,"o":"m 417 -22 q 172 76 261 -22 q 83 344 83 175 l 83 986 l 203 986 l 203 344 q 258 159 203 224 q 417 94 313 94 q 576 159 521 94 q 631 344 631 224 l 631 986 l 750 986 l 750 344 q 661 76 750 175 q 417 -22 572 -22 m 350 1265 l 481 1265 l 610 1081 l 513 1081 l 415 1206 l 318 1081 l 221 1081 l 350 1265 z "},"Ü":{"ha":833,"x_min":83,"x_max":750,"o":"m 417 -22 q 172 76 261 -22 q 83 344 83 175 l 83 986 l 203 986 l 203 344 q 258 159 203 224 q 417 94 313 94 q 576 159 521 94 q 631 344 631 224 l 631 986 l 750 986 l 750 344 q 661 76 750 175 q 417 -22 572 -22 z "},"Ù":{"ha":833,"x_min":83,"x_max":750,"o":"m 417 -22 q 172 76 261 -22 q 83 344 83 175 l 83 986 l 203 986 l 203 344 q 258 159 203 224 q 417 94 313 94 q 576 159 521 94 q 631 344 631 224 l 631 986 l 750 986 l 750 344 q 661 76 750 175 q 417 -22 572 -22 m 250 1264 l 381 1264 l 472 1086 l 375 1086 l 250 1264 z "},"Ű":{"ha":833,"x_min":83,"x_max":750,"o":"m 417 -22 q 172 76 261 -22 q 83 344 83 175 l 83 986 l 203 986 l 203 344 q 258 159 203 224 q 417 94 313 94 q 576 159 521 94 q 631 344 631 224 l 631 986 l 750 986 l 750 344 q 661 76 750 175 q 417 -22 572 -22 z "},"Ū":{"ha":833,"x_min":83,"x_max":750,"o":"m 417 -22 q 172 76 261 -22 q 83 344 83 175 l 83 986 l 203 986 l 203 344 q 258 159 203 224 q 417 94 313 94 q 576 159 521 94 q 631 344 631 224 l 631 986 l 750 986 l 750 344 q 661 76 750 175 q 417 -22 572 -22 m 229 1218 l 601 1218 l 601 1118 l 229 1118 l 229 1218 z "},"Ų":{"ha":833,"x_min":83,"x_max":750,"o":"m 417 -22 q 172 76 261 -22 q 83 344 83 175 l 83 986 l 203 986 l 203 344 q 258 159 203 224 q 417 94 313 94 q 576 159 521 94 q 631 344 631 224 l 631 986 l 750 986 l 750 344 q 697 125 750 214 q 547 0 644 36 l 499 -62 q 465 -117 474 -96 q 456 -156 456 -137 q 497 -197 456 -197 q 532 -192 514 -197 q 561 -178 550 -187 l 594 -256 q 546 -282 578 -272 q 475 -292 514 -292 q 383 -262 417 -292 q 350 -181 350 -232 q 407 -42 350 -112 l 422 -22 l 417 -22 z "},"Ů":{"ha":833,"x_min":83,"x_max":750,"o":"m 417 -22 q 172 76 261 -22 q 83 344 83 175 l 83 986 l 203 986 l 203 344 q 258 159 203 224 q 417 94 313 94 q 576 159 521 94 q 631 344 631 224 l 631 986 l 750 986 l 750 344 q 661 76 750 175 q 417 -22 572 -22 m 417 1064 q 326 1101 363 1064 q 289 1192 289 1138 q 326 1283 289 1246 q 417 1319 363 1319 q 508 1283 471 1319 q 544 1192 544 1246 q 508 1101 544 1138 q 417 1064 471 1064 m 417 1133 q 458 1150 442 1133 q 475 1192 475 1167 q 458 1233 475 1217 q 417 1250 442 1250 q 375 1233 392 1250 q 358 1192 358 1217 q 375 1150 358 1167 q 417 1133 392 1133 z "},"Ũ":{"ha":833,"x_min":83,"x_max":750,"o":"m 417 -22 q 172 76 261 -22 q 83 344 83 175 l 83 986 l 203 986 l 203 344 q 258 159 203 224 q 417 94 313 94 q 576 159 521 94 q 631 344 631 224 l 631 986 l 750 986 l 750 344 q 661 76 750 175 q 417 -22 572 -22 m 500 1097 q 403 1139 454 1097 q 363 1168 376 1161 q 331 1175 350 1175 q 269 1088 274 1175 l 197 1088 q 235 1208 199 1165 q 331 1250 272 1250 q 385 1239 361 1250 q 436 1206 410 1228 q 474 1180 458 1188 q 506 1172 489 1172 q 546 1192 532 1172 q 564 1264 560 1213 l 636 1264 q 596 1141 632 1185 q 500 1097 560 1097 z "},"V":{"ha":833,"x_min":33,"x_max":800,"o":"m 33 986 l 161 986 l 417 133 l 672 986 l 800 986 l 494 0 l 339 0 l 33 986 z "},"W":{"ha":833,"x_min":56,"x_max":778,"o":"m 56 986 l 181 986 l 253 132 l 356 875 l 478 875 l 581 132 l 653 986 l 778 986 l 683 0 l 503 0 l 417 635 l 331 0 l 150 0 l 56 986 z "},"Ẃ":{"ha":833,"x_min":56,"x_max":778,"o":"m 56 986 l 181 986 l 253 132 l 356 875 l 478 875 l 581 132 l 653 986 l 778 986 l 683 0 l 503 0 l 417 635 l 331 0 l 150 0 l 56 986 m 456 1264 l 586 1264 l 461 1086 l 364 1086 l 456 1264 z "},"Ŵ":{"ha":833,"x_min":56,"x_max":778,"o":"m 56 986 l 181 986 l 253 132 l 356 875 l 478 875 l 581 132 l 653 986 l 778 986 l 683 0 l 503 0 l 417 635 l 331 0 l 150 0 l 56 986 m 350 1265 l 481 1265 l 610 1081 l 513 1081 l 415 1206 l 318 1081 l 221 1081 l 350 1265 z "},"Ẅ":{"ha":833,"x_min":56,"x_max":778,"o":"m 56 986 l 181 986 l 253 132 l 356 875 l 478 875 l 581 132 l 653 986 l 778 986 l 683 0 l 503 0 l 417 635 l 331 0 l 150 0 l 56 986 z "},"Ẁ":{"ha":833,"x_min":56,"x_max":778,"o":"m 56 986 l 181 986 l 253 132 l 356 875 l 478 875 l 581 132 l 653 986 l 778 986 l 683 0 l 503 0 l 417 635 l 331 0 l 150 0 l 56 986 m 250 1264 l 381 1264 l 472 1086 l 375 1086 l 250 1264 z "},"X":{"ha":833,"x_min":69,"x_max":764,"o":"m 342 496 l 75 986 l 214 986 l 418 588 l 622 986 l 758 986 l 492 494 l 764 0 l 625 0 l 415 404 l 206 0 l 69 0 l 342 496 z "},"Y":{"ha":833,"x_min":61,"x_max":772,"o":"m 358 403 l 61 986 l 197 986 l 417 542 l 636 986 l 772 986 l 478 403 l 478 0 l 358 0 l 358 403 z "},"Ý":{"ha":833,"x_min":61,"x_max":772,"o":"m 358 403 l 61 986 l 197 986 l 417 542 l 636 986 l 772 986 l 478 403 l 478 0 l 358 0 l 358 403 m 456 1264 l 586 1264 l 461 1086 l 364 1086 l 456 1264 z "},"Ŷ":{"ha":833,"x_min":61,"x_max":772,"o":"m 358 403 l 61 986 l 197 986 l 417 542 l 636 986 l 772 986 l 478 403 l 478 0 l 358 0 l 358 403 m 350 1265 l 481 1265 l 610 1081 l 513 1081 l 415 1206 l 318 1081 l 221 1081 l 350 1265 z "},"Ÿ":{"ha":833,"x_min":61,"x_max":772,"o":"m 358 403 l 61 986 l 197 986 l 417 542 l 636 986 l 772 986 l 478 403 l 478 0 l 358 0 l 358 403 z "},"Ỳ":{"ha":833,"x_min":61,"x_max":772,"o":"m 358 403 l 61 986 l 197 986 l 417 542 l 636 986 l 772 986 l 478 403 l 478 0 l 358 0 l 358 403 m 250 1264 l 381 1264 l 472 1086 l 375 1086 l 250 1264 z "},"Ỹ":{"ha":833,"x_min":61,"x_max":772,"o":"m 358 403 l 61 986 l 197 986 l 417 542 l 636 986 l 772 986 l 478 403 l 478 0 l 358 0 l 358 403 m 500 1097 q 403 1139 454 1097 q 363 1168 376 1161 q 331 1175 350 1175 q 269 1088 274 1175 l 197 1088 q 235 1208 199 1165 q 331 1250 272 1250 q 385 1239 361 1250 q 436 1206 410 1228 q 474 1180 458 1188 q 506 1172 489 1172 q 546 1192 532 1172 q 564 1264 560 1213 l 636 1264 q 596 1141 632 1185 q 500 1097 560 1097 z "},"Z":{"ha":833,"x_min":69,"x_max":764,"o":"m 69 119 l 619 872 l 94 872 l 94 986 l 753 986 l 753 867 l 203 114 l 764 114 l 764 0 l 69 0 l 69 119 z "},"Ź":{"ha":833,"x_min":69,"x_max":764,"o":"m 69 119 l 619 872 l 94 872 l 94 986 l 753 986 l 753 867 l 203 114 l 764 114 l 764 0 l 69 0 l 69 119 m 456 1264 l 586 1264 l 461 1086 l 364 1086 l 456 1264 z "},"Ž":{"ha":833,"x_min":69,"x_max":764,"o":"m 69 119 l 619 872 l 94 872 l 94 986 l 753 986 l 753 867 l 203 114 l 764 114 l 764 0 l 69 0 l 69 119 m 319 1264 l 417 1139 l 514 1264 l 611 1264 l 482 1079 l 351 1079 l 222 1264 l 319 1264 z "},"Ż":{"ha":833,"x_min":69,"x_max":764,"o":"m 69 119 l 619 872 l 94 872 l 94 986 l 753 986 l 753 867 l 203 114 l 764 114 l 764 0 l 69 0 l 69 119 m 354 1238 l 476 1238 l 476 1101 l 354 1101 l 354 1238 z "},"Ꞌ":{"ha":833,"x_min":356,"x_max":478,"o":"m 356 797 l 356 986 l 478 986 l 478 797 l 458 525 l 375 525 l 356 797 z "},"a":{"ha":833,"x_min":75,"x_max":764,"o":"m 319 -17 q 144 39 214 -17 q 75 183 75 94 q 131 329 75 276 q 308 406 186 382 l 556 456 q 514 595 556 549 q 392 642 472 642 q 273 608 317 642 q 213 510 229 575 l 89 519 q 190 690 110 626 q 392 753 269 753 q 599 673 525 753 q 672 453 672 593 l 672 147 q 683 113 672 122 q 717 103 693 103 l 764 103 l 764 0 q 703 -3 742 -3 q 606 23 642 -3 q 560 104 571 49 q 464 17 529 50 q 319 -17 399 -17 m 331 86 q 498 142 440 86 q 556 292 556 197 l 556 356 l 331 311 q 227 269 257 297 q 197 194 197 242 q 233 115 197 143 q 331 86 268 86 z "},"á":{"ha":833,"x_min":75,"x_max":764,"o":"m 319 -17 q 144 39 214 -17 q 75 183 75 94 q 131 329 75 276 q 308 406 186 382 l 556 456 q 514 595 556 549 q 392 642 472 642 q 273 608 317 642 q 213 510 229 575 l 89 519 q 190 690 110 626 q 392 753 269 753 q 599 673 525 753 q 672 453 672 593 l 672 147 q 683 113 672 122 q 717 103 693 103 l 764 103 l 764 0 q 703 -3 742 -3 q 606 23 642 -3 q 560 104 571 49 q 464 17 529 50 q 319 -17 399 -17 m 331 86 q 498 142 440 86 q 556 292 556 197 l 556 356 l 331 311 q 227 269 257 297 q 197 194 197 242 q 233 115 197 143 q 331 86 268 86 m 433 1014 l 564 1014 l 439 836 l 342 836 l 433 1014 z "},"ă":{"ha":833,"x_min":75,"x_max":764,"o":"m 319 -17 q 144 39 214 -17 q 75 183 75 94 q 131 329 75 276 q 308 406 186 382 l 556 456 q 514 595 556 549 q 392 642 472 642 q 273 608 317 642 q 213 510 229 575 l 89 519 q 190 690 110 626 q 392 753 269 753 q 599 673 525 753 q 672 453 672 593 l 672 147 q 683 113 672 122 q 717 103 693 103 l 764 103 l 764 0 q 703 -3 742 -3 q 606 23 642 -3 q 560 104 571 49 q 464 17 529 50 q 319 -17 399 -17 m 331 86 q 498 142 440 86 q 556 292 556 197 l 556 356 l 331 311 q 227 269 257 297 q 197 194 197 242 q 233 115 197 143 q 331 86 268 86 m 394 838 q 257 885 310 838 q 200 1015 204 933 l 281 1015 q 394 924 293 924 q 508 1015 496 924 l 589 1015 q 532 885 585 933 q 394 838 479 838 z "},"ǎ":{"ha":833,"x_min":75,"x_max":764,"o":"m 319 -17 q 144 39 214 -17 q 75 183 75 94 q 131 329 75 276 q 308 406 186 382 l 556 456 q 514 595 556 549 q 392 642 472 642 q 273 608 317 642 q 213 510 229 575 l 89 519 q 190 690 110 626 q 392 753 269 753 q 599 673 525 753 q 672 453 672 593 l 672 147 q 683 113 672 122 q 717 103 693 103 l 764 103 l 764 0 q 703 -3 742 -3 q 606 23 642 -3 q 560 104 571 49 q 464 17 529 50 q 319 -17 399 -17 m 331 86 q 498 142 440 86 q 556 292 556 197 l 556 356 l 331 311 q 227 269 257 297 q 197 194 197 242 q 233 115 197 143 q 331 86 268 86 m 297 1014 l 394 889 l 492 1014 l 589 1014 l 460 829 l 329 829 l 200 1014 l 297 1014 z "},"â":{"ha":833,"x_min":75,"x_max":764,"o":"m 319 -17 q 144 39 214 -17 q 75 183 75 94 q 131 329 75 276 q 308 406 186 382 l 556 456 q 514 595 556 549 q 392 642 472 642 q 273 608 317 642 q 213 510 229 575 l 89 519 q 190 690 110 626 q 392 753 269 753 q 599 673 525 753 q 672 453 672 593 l 672 147 q 683 113 672 122 q 717 103 693 103 l 764 103 l 764 0 q 703 -3 742 -3 q 606 23 642 -3 q 560 104 571 49 q 464 17 529 50 q 319 -17 399 -17 m 331 86 q 498 142 440 86 q 556 292 556 197 l 556 356 l 331 311 q 227 269 257 297 q 197 194 197 242 q 233 115 197 143 q 331 86 268 86 m 328 1015 l 458 1015 l 588 831 l 490 831 l 393 956 l 296 831 l 199 831 l 328 1015 z "},"ä":{"ha":833,"x_min":75,"x_max":764,"o":"m 319 -17 q 144 39 214 -17 q 75 183 75 94 q 131 329 75 276 q 308 406 186 382 l 556 456 q 514 595 556 549 q 392 642 472 642 q 273 608 317 642 q 213 510 229 575 l 89 519 q 190 690 110 626 q 392 753 269 753 q 599 673 525 753 q 672 453 672 593 l 672 147 q 683 113 672 122 q 717 103 693 103 l 764 103 l 764 0 q 703 -3 742 -3 q 606 23 642 -3 q 560 104 571 49 q 464 17 529 50 q 319 -17 399 -17 m 331 86 q 498 142 440 86 q 556 292 556 197 l 556 356 l 331 311 q 227 269 257 297 q 197 194 197 242 q 233 115 197 143 q 331 86 268 86 z "},"à":{"ha":833,"x_min":75,"x_max":764,"o":"m 319 -17 q 144 39 214 -17 q 75 183 75 94 q 131 329 75 276 q 308 406 186 382 l 556 456 q 514 595 556 549 q 392 642 472 642 q 273 608 317 642 q 213 510 229 575 l 89 519 q 190 690 110 626 q 392 753 269 753 q 599 673 525 753 q 672 453 672 593 l 672 147 q 683 113 672 122 q 717 103 693 103 l 764 103 l 764 0 q 703 -3 742 -3 q 606 23 642 -3 q 560 104 571 49 q 464 17 529 50 q 319 -17 399 -17 m 331 86 q 498 142 440 86 q 556 292 556 197 l 556 356 l 331 311 q 227 269 257 297 q 197 194 197 242 q 233 115 197 143 q 331 86 268 86 m 228 1014 l 358 1014 l 450 836 l 353 836 l 228 1014 z "},"ā":{"ha":833,"x_min":75,"x_max":764,"o":"m 319 -17 q 144 39 214 -17 q 75 183 75 94 q 131 329 75 276 q 308 406 186 382 l 556 456 q 514 595 556 549 q 392 642 472 642 q 273 608 317 642 q 213 510 229 575 l 89 519 q 190 690 110 626 q 392 753 269 753 q 599 673 525 753 q 672 453 672 593 l 672 147 q 683 113 672 122 q 717 103 693 103 l 764 103 l 764 0 q 703 -3 742 -3 q 606 23 642 -3 q 560 104 571 49 q 464 17 529 50 q 319 -17 399 -17 m 331 86 q 498 142 440 86 q 556 292 556 197 l 556 356 l 331 311 q 227 269 257 297 q 197 194 197 242 q 233 115 197 143 q 331 86 268 86 m 207 968 l 579 968 l 579 868 l 207 868 l 207 968 z "},"ą":{"ha":833,"x_min":75,"x_max":811,"o":"m 319 -17 q 144 39 214 -17 q 75 183 75 94 q 131 329 75 276 q 308 406 186 382 l 556 456 q 514 595 556 549 q 392 642 472 642 q 273 608 317 642 q 213 510 229 575 l 89 519 q 190 690 110 626 q 392 753 269 753 q 599 673 525 753 q 672 453 672 593 l 672 147 q 683 113 672 122 q 717 103 693 103 l 764 103 l 764 0 l 715 -62 q 681 -117 690 -96 q 672 -156 672 -137 q 714 -197 672 -197 q 749 -192 731 -197 q 778 -178 767 -187 l 811 -256 q 763 -282 794 -272 q 692 -292 731 -292 q 600 -262 633 -292 q 567 -181 567 -232 q 624 -42 567 -112 l 658 1 q 560 104 578 15 q 464 17 529 50 q 319 -17 399 -17 m 331 86 q 498 142 440 86 q 556 292 556 197 l 556 356 l 331 311 q 227 269 257 297 q 197 194 197 242 q 233 115 197 143 q 331 86 268 86 z "},"å":{"ha":833,"x_min":75,"x_max":764,"o":"m 319 -17 q 144 39 214 -17 q 75 183 75 94 q 131 329 75 276 q 308 406 186 382 l 556 456 q 514 595 556 549 q 392 642 472 642 q 273 608 317 642 q 213 510 229 575 l 89 519 q 190 690 110 626 q 392 753 269 753 q 599 673 525 753 q 672 453 672 593 l 672 147 q 683 113 672 122 q 717 103 693 103 l 764 103 l 764 0 q 703 -3 742 -3 q 606 23 642 -3 q 560 104 571 49 q 464 17 529 50 q 319 -17 399 -17 m 331 86 q 498 142 440 86 q 556 292 556 197 l 556 356 l 331 311 q 227 269 257 297 q 197 194 197 242 q 233 115 197 143 q 331 86 268 86 m 394 814 q 303 851 340 814 q 267 942 267 888 q 303 1033 267 996 q 394 1069 340 1069 q 485 1033 449 1069 q 522 942 522 996 q 485 851 522 888 q 394 814 449 814 m 394 883 q 436 900 419 883 q 453 942 453 917 q 436 983 453 967 q 394 1000 419 1000 q 353 983 369 1000 q 336 942 336 967 q 353 900 336 917 q 394 883 369 883 z "},"ã":{"ha":833,"x_min":75,"x_max":764,"o":"m 319 -17 q 144 39 214 -17 q 75 183 75 94 q 131 329 75 276 q 308 406 186 382 l 556 456 q 514 595 556 549 q 392 642 472 642 q 273 608 317 642 q 213 510 229 575 l 89 519 q 190 690 110 626 q 392 753 269 753 q 599 673 525 753 q 672 453 672 593 l 672 147 q 683 113 672 122 q 717 103 693 103 l 764 103 l 764 0 q 703 -3 742 -3 q 606 23 642 -3 q 560 104 571 49 q 464 17 529 50 q 319 -17 399 -17 m 331 86 q 498 142 440 86 q 556 292 556 197 l 556 356 l 331 311 q 227 269 257 297 q 197 194 197 242 q 233 115 197 143 q 331 86 268 86 m 478 847 q 381 889 432 847 q 341 918 354 911 q 308 925 328 925 q 247 838 251 925 l 175 838 q 213 958 176 915 q 308 1000 250 1000 q 363 989 339 1000 q 414 956 388 978 q 451 930 436 938 q 483 922 467 922 q 524 942 510 922 q 542 1014 538 963 l 614 1014 q 574 891 610 935 q 478 847 538 847 z "},"æ":{"ha":833,"x_min":18,"x_max":817,"o":"m 590 -17 q 385 144 444 -17 q 319 27 367 68 q 204 -17 272 -14 q 72 43 125 -14 q 18 196 18 100 q 70 335 18 276 q 213 417 122 394 l 331 447 q 309 597 331 551 q 239 642 288 642 q 174 608 194 642 q 143 510 154 575 l 28 519 q 239 753 64 753 q 410 647 360 753 q 590 753 471 753 q 753 661 697 753 q 815 385 808 569 l 817 332 l 471 332 q 507 156 476 218 q 590 94 538 94 q 653 123 631 94 q 690 218 676 151 l 808 208 q 733 35 782 86 q 590 -17 683 -17 m 208 94 q 302 145 272 94 q 332 304 332 196 l 332 340 l 247 319 q 164 273 193 307 q 135 196 135 239 q 156 122 135 150 q 208 94 176 94 m 696 435 q 661 594 686 546 q 590 642 636 642 q 510 594 538 642 q 472 435 482 546 l 696 435 z "},"b":{"ha":833,"x_min":111,"x_max":764,"o":"m 449 -17 q 313 17 372 -17 q 221 111 253 50 l 217 0 l 111 0 l 111 986 l 228 986 l 228 633 q 317 718 257 683 q 449 753 376 753 q 616 706 544 753 q 726 572 688 658 q 764 368 764 485 q 726 165 764 251 q 616 31 688 78 q 449 -17 544 -17 m 442 94 q 588 168 535 94 q 642 368 642 242 q 589 569 642 496 q 444 642 536 642 q 285 569 343 642 q 228 368 228 496 q 285 168 228 242 q 442 94 342 94 z "},"c":{"ha":833,"x_min":97,"x_max":750,"o":"m 436 -17 q 258 31 335 -17 q 139 165 181 78 q 97 368 97 253 q 139 571 97 483 q 258 706 181 658 q 436 753 335 753 q 641 686 560 753 q 744 497 722 619 l 622 489 q 556 601 606 561 q 436 642 507 642 q 277 569 335 642 q 219 368 219 496 q 277 167 219 240 q 436 94 335 94 q 560 138 510 94 q 628 261 611 181 l 750 253 q 642 56 726 129 q 436 -17 558 -17 z "},"ć":{"ha":833,"x_min":97,"x_max":750,"o":"m 436 -17 q 258 31 335 -17 q 139 165 181 78 q 97 368 97 253 q 139 571 97 483 q 258 706 181 658 q 436 753 335 753 q 641 686 560 753 q 744 497 722 619 l 622 489 q 556 601 606 561 q 436 642 507 642 q 277 569 335 642 q 219 368 219 496 q 277 167 219 240 q 436 94 335 94 q 560 138 510 94 q 628 261 611 181 l 750 253 q 642 56 726 129 q 436 -17 558 -17 m 474 1014 l 604 1014 l 479 836 l 382 836 l 474 1014 z "},"č":{"ha":833,"x_min":97,"x_max":750,"o":"m 436 -17 q 258 31 335 -17 q 139 165 181 78 q 97 368 97 253 q 139 571 97 483 q 258 706 181 658 q 436 753 335 753 q 641 686 560 753 q 744 497 722 619 l 622 489 q 556 601 606 561 q 436 642 507 642 q 277 569 335 642 q 219 368 219 496 q 277 167 219 240 q 436 94 335 94 q 560 138 510 94 q 628 261 611 181 l 750 253 q 642 56 726 129 q 436 -17 558 -17 m 338 1014 l 435 889 l 532 1014 l 629 1014 l 500 829 l 369 829 l 240 1014 l 338 1014 z "},"ç":{"ha":833,"x_min":97,"x_max":750,"o":"m 425 -306 q 342 -284 378 -306 q 263 -212 306 -262 l 319 -160 q 367 -211 344 -192 q 419 -231 389 -231 q 467 -215 450 -231 q 485 -171 485 -199 q 465 -129 485 -144 q 413 -114 446 -114 q 379 -118 399 -114 l 340 -67 l 389 -14 q 176 105 254 3 q 97 368 97 207 q 139 571 97 483 q 258 706 181 658 q 436 753 335 753 q 641 686 560 753 q 744 497 722 619 l 622 489 q 556 601 606 561 q 436 642 507 642 q 277 569 335 642 q 219 368 219 496 q 277 167 219 240 q 436 94 335 94 q 560 138 510 94 q 628 261 611 181 l 750 253 q 650 63 728 135 q 458 -15 572 -10 l 426 -51 q 524 -86 486 -51 q 561 -176 561 -121 q 522 -268 561 -231 q 425 -306 482 -306 z "},"ĉ":{"ha":833,"x_min":97,"x_max":750,"o":"m 436 -17 q 258 31 335 -17 q 139 165 181 78 q 97 368 97 253 q 139 571 97 483 q 258 706 181 658 q 436 753 335 753 q 641 686 560 753 q 744 497 722 619 l 622 489 q 556 601 606 561 q 436 642 507 642 q 277 569 335 642 q 219 368 219 496 q 277 167 219 240 q 436 94 335 94 q 560 138 510 94 q 628 261 611 181 l 750 253 q 642 56 726 129 q 436 -17 558 -17 m 368 1015 l 499 1015 l 628 831 l 531 831 l 433 956 l 336 831 l 239 831 l 368 1015 z "},"ċ":{"ha":833,"x_min":97,"x_max":750,"o":"m 436 -17 q 258 31 335 -17 q 139 165 181 78 q 97 368 97 253 q 139 571 97 483 q 258 706 181 658 q 436 753 335 753 q 641 686 560 753 q 744 497 722 619 l 622 489 q 556 601 606 561 q 436 642 507 642 q 277 569 335 642 q 219 368 219 496 q 277 167 219 240 q 436 94 335 94 q 560 138 510 94 q 628 261 611 181 l 750 253 q 642 56 726 129 q 436 -17 558 -17 m 372 988 l 494 988 l 494 851 l 372 851 l 372 988 z "},"d":{"ha":833,"x_min":69,"x_max":722,"o":"m 385 -17 q 217 31 289 -17 q 108 165 146 78 q 69 368 69 251 q 108 572 69 485 q 217 706 146 658 q 385 753 289 753 q 517 718 457 753 q 606 633 576 683 l 606 986 l 722 986 l 722 0 l 617 0 l 613 111 q 521 17 581 50 q 385 -17 461 -17 m 392 94 q 549 168 492 94 q 606 368 606 242 q 548 569 606 496 q 389 642 490 642 q 244 569 297 642 q 192 368 192 496 q 245 168 192 242 q 392 94 299 94 z "},"ď":{"ha":833,"x_min":69,"x_max":907,"o":"m 385 -17 q 217 31 289 -17 q 108 165 146 78 q 69 368 69 251 q 108 572 69 485 q 217 706 146 658 q 385 753 289 753 q 517 718 457 753 q 606 633 576 683 l 606 986 l 722 986 l 722 0 l 617 0 l 613 111 q 521 17 581 50 q 385 -17 461 -17 m 392 94 q 549 168 492 94 q 606 368 606 242 q 548 569 606 496 q 389 642 490 642 q 244 569 297 642 q 192 368 192 496 q 245 168 192 242 q 392 94 299 94 m 792 986 l 907 986 l 835 650 l 750 650 l 792 986 z "},"đ":{"ha":833,"x_min":69,"x_max":807,"o":"m 456 914 l 606 914 l 606 986 l 722 986 l 722 914 l 807 914 l 807 811 l 722 811 l 722 0 l 617 0 l 613 111 q 521 17 581 50 q 385 -17 461 -17 q 217 31 289 -17 q 108 165 146 78 q 69 368 69 251 q 108 572 69 485 q 217 706 146 658 q 385 753 289 753 q 517 718 457 753 q 606 633 576 683 l 606 811 l 456 811 l 456 914 m 392 94 q 549 168 492 94 q 606 368 606 242 q 548 569 606 496 q 389 642 490 642 q 244 569 297 642 q 192 368 192 496 q 245 168 192 242 q 392 94 299 94 z "},"ð":{"ha":833,"x_min":81,"x_max":776,"o":"m 318 819 l 424 857 q 249 900 350 892 l 276 1003 q 550 901 440 986 l 743 969 l 776 881 l 625 828 q 754 354 754 664 q 713 155 754 239 q 595 27 671 71 q 418 -17 519 -17 q 240 26 317 -17 q 122 150 164 69 q 81 340 81 231 q 122 531 81 450 q 240 654 164 611 q 418 697 317 697 q 608 631 521 697 q 518 789 578 728 l 351 731 l 318 819 m 418 94 q 576 160 521 94 q 632 354 632 225 q 575 524 632 463 q 418 586 518 586 q 259 522 315 586 q 203 340 203 458 q 259 158 203 222 q 418 94 315 94 z "},"e":{"ha":833,"x_min":92,"x_max":750,"o":"m 429 -17 q 250 31 326 -17 q 133 165 174 78 q 92 368 92 251 q 133 571 92 483 q 249 706 174 658 q 424 753 324 753 q 594 708 519 753 q 709 575 668 663 q 750 367 750 488 l 750 332 l 214 332 q 276 155 219 215 q 429 94 332 94 q 547 127 500 94 q 613 218 593 160 l 738 208 q 626 45 708 107 q 429 -17 543 -17 m 622 435 q 561 590 614 538 q 424 642 508 642 q 283 589 338 642 q 214 435 228 536 l 622 435 z "},"é":{"ha":833,"x_min":92,"x_max":750,"o":"m 429 -17 q 250 31 326 -17 q 133 165 174 78 q 92 368 92 251 q 133 571 92 483 q 249 706 174 658 q 424 753 324 753 q 594 708 519 753 q 709 575 668 663 q 750 367 750 488 l 750 332 l 214 332 q 276 155 219 215 q 429 94 332 94 q 547 127 500 94 q 613 218 593 160 l 738 208 q 626 45 708 107 q 429 -17 543 -17 m 622 435 q 561 590 614 538 q 424 642 508 642 q 283 589 338 642 q 214 435 228 536 l 622 435 m 464 1011 l 594 1011 l 469 833 l 372 833 l 464 1011 z "},"ě":{"ha":833,"x_min":92,"x_max":750,"o":"m 429 -17 q 250 31 326 -17 q 133 165 174 78 q 92 368 92 251 q 133 571 92 483 q 249 706 174 658 q 424 753 324 753 q 594 708 519 753 q 709 575 668 663 q 750 367 750 488 l 750 332 l 214 332 q 276 155 219 215 q 429 94 332 94 q 547 127 500 94 q 613 218 593 160 l 738 208 q 626 45 708 107 q 429 -17 543 -17 m 622 435 q 561 590 614 538 q 424 642 508 642 q 283 589 338 642 q 214 435 228 536 l 622 435 m 328 1011 l 425 886 l 522 1011 l 619 1011 l 490 826 l 360 826 l 231 1011 l 328 1011 z "},"ê":{"ha":833,"x_min":92,"x_max":750,"o":"m 429 -17 q 250 31 326 -17 q 133 165 174 78 q 92 368 92 251 q 133 571 92 483 q 249 706 174 658 q 424 753 324 753 q 594 708 519 753 q 709 575 668 663 q 750 367 750 488 l 750 332 l 214 332 q 276 155 219 215 q 429 94 332 94 q 547 127 500 94 q 613 218 593 160 l 738 208 q 626 45 708 107 q 429 -17 543 -17 m 622 435 q 561 590 614 538 q 424 642 508 642 q 283 589 338 642 q 214 435 228 536 l 622 435 m 358 1013 l 489 1013 l 618 828 l 521 828 l 424 953 l 326 828 l 229 828 l 358 1013 z "},"ë":{"ha":833,"x_min":92,"x_max":750,"o":"m 429 -17 q 250 31 326 -17 q 133 165 174 78 q 92 368 92 251 q 133 571 92 483 q 249 706 174 658 q 424 753 324 753 q 594 708 519 753 q 709 575 668 663 q 750 367 750 488 l 750 332 l 214 332 q 276 155 219 215 q 429 94 332 94 q 547 127 500 94 q 613 218 593 160 l 738 208 q 626 45 708 107 q 429 -17 543 -17 m 622 435 q 561 590 614 538 q 424 642 508 642 q 283 589 338 642 q 214 435 228 536 l 622 435 z "},"ė":{"ha":833,"x_min":92,"x_max":750,"o":"m 429 -17 q 250 31 326 -17 q 133 165 174 78 q 92 368 92 251 q 133 571 92 483 q 249 706 174 658 q 424 753 324 753 q 594 708 519 753 q 709 575 668 663 q 750 367 750 488 l 750 332 l 214 332 q 276 155 219 215 q 429 94 332 94 q 547 127 500 94 q 613 218 593 160 l 738 208 q 626 45 708 107 q 429 -17 543 -17 m 622 435 q 561 590 614 538 q 424 642 508 642 q 283 589 338 642 q 214 435 228 536 l 622 435 m 363 985 l 485 985 l 485 849 l 363 849 l 363 985 z "},"è":{"ha":833,"x_min":92,"x_max":750,"o":"m 429 -17 q 250 31 326 -17 q 133 165 174 78 q 92 368 92 251 q 133 571 92 483 q 249 706 174 658 q 424 753 324 753 q 594 708 519 753 q 709 575 668 663 q 750 367 750 488 l 750 332 l 214 332 q 276 155 219 215 q 429 94 332 94 q 547 127 500 94 q 613 218 593 160 l 738 208 q 626 45 708 107 q 429 -17 543 -17 m 622 435 q 561 590 614 538 q 424 642 508 642 q 283 589 338 642 q 214 435 228 536 l 622 435 m 258 1011 l 389 1011 l 481 833 l 383 833 l 258 1011 z "},"ē":{"ha":833,"x_min":92,"x_max":750,"o":"m 429 -17 q 250 31 326 -17 q 133 165 174 78 q 92 368 92 251 q 133 571 92 483 q 249 706 174 658 q 424 753 324 753 q 594 708 519 753 q 709 575 668 663 q 750 367 750 488 l 750 332 l 214 332 q 276 155 219 215 q 429 94 332 94 q 547 127 500 94 q 613 218 593 160 l 738 208 q 626 45 708 107 q 429 -17 543 -17 m 622 435 q 561 590 614 538 q 424 642 508 642 q 283 589 338 642 q 214 435 228 536 l 622 435 m 238 965 l 610 965 l 610 865 l 238 865 l 238 965 z "},"ę":{"ha":833,"x_min":92,"x_max":750,"o":"m 429 -17 q 250 31 326 -17 q 133 165 174 78 q 92 368 92 251 q 133 571 92 483 q 249 706 174 658 q 424 753 324 753 q 594 708 519 753 q 709 575 668 663 q 750 367 750 488 l 750 332 l 214 332 q 276 155 219 215 q 429 94 332 94 q 547 127 500 94 q 613 218 593 160 l 738 208 q 608 33 703 94 l 560 -29 q 526 -83 535 -62 q 517 -122 517 -104 q 558 -164 517 -164 q 593 -159 575 -164 q 622 -144 611 -154 l 656 -222 q 607 -249 639 -239 q 536 -258 575 -258 q 444 -228 478 -258 q 411 -147 411 -199 q 463 -15 411 -83 l 429 -17 m 622 435 q 561 590 614 538 q 424 642 508 642 q 283 589 338 642 q 214 435 228 536 l 622 435 z "},"ẽ":{"ha":833,"x_min":92,"x_max":750,"o":"m 429 -17 q 250 31 326 -17 q 133 165 174 78 q 92 368 92 251 q 133 571 92 483 q 249 706 174 658 q 424 753 324 753 q 594 708 519 753 q 709 575 668 663 q 750 367 750 488 l 750 332 l 214 332 q 276 155 219 215 q 429 94 332 94 q 547 127 500 94 q 613 218 593 160 l 738 208 q 626 45 708 107 q 429 -17 543 -17 m 622 435 q 561 590 614 538 q 424 642 508 642 q 283 589 338 642 q 214 435 228 536 l 622 435 m 508 844 q 411 886 463 844 q 372 915 385 908 q 339 922 358 922 q 278 835 282 922 l 206 835 q 244 955 207 913 q 339 997 281 997 q 394 986 369 997 q 444 953 418 975 q 482 927 467 935 q 514 919 497 919 q 554 940 540 919 q 572 1011 568 960 l 644 1011 q 604 888 640 932 q 508 844 568 844 z "},"ə":{"ha":833,"x_min":83,"x_max":742,"o":"m 404 753 q 583 706 507 753 q 701 572 660 658 q 742 368 742 485 q 701 165 742 253 q 585 31 660 78 q 410 -17 510 -17 q 240 28 314 -17 q 124 161 165 74 q 83 369 83 249 l 83 404 l 619 404 q 558 581 614 521 q 404 642 501 642 q 287 609 333 642 q 221 518 240 576 l 96 528 q 208 691 125 629 q 404 753 290 753 m 211 301 q 272 147 219 199 q 410 94 325 94 q 551 147 496 94 q 619 301 606 200 l 211 301 z "},"f":{"ha":833,"x_min":97,"x_max":736,"o":"m 322 783 q 374 934 322 882 q 528 986 425 986 l 736 986 l 736 883 l 528 883 q 462 857 485 883 q 439 783 439 831 l 439 736 l 722 736 l 722 633 l 439 633 l 439 0 l 322 0 l 322 633 l 97 633 l 97 736 l 322 736 l 322 783 z "},"g":{"ha":833,"x_min":83,"x_max":736,"o":"m 413 -225 q 211 -167 294 -225 q 104 -11 128 -108 l 226 -3 q 288 -86 244 -58 q 413 -114 331 -114 q 567 -74 514 -114 q 619 47 619 -33 l 619 167 q 534 74 592 108 q 404 39 476 39 q 238 84 311 39 q 124 210 165 129 q 83 396 83 292 q 124 581 83 500 q 235 708 164 663 q 399 753 307 753 q 538 717 476 753 q 625 619 600 681 l 625 736 l 736 736 l 736 50 q 650 -152 736 -79 q 413 -225 564 -225 m 410 150 q 563 218 507 150 q 619 403 619 286 q 563 577 619 513 q 410 642 506 642 q 260 576 314 642 q 206 396 206 510 q 260 216 206 282 q 410 150 315 150 z "},"ğ":{"ha":833,"x_min":83,"x_max":736,"o":"m 413 -225 q 211 -167 294 -225 q 104 -11 128 -108 l 226 -3 q 288 -86 244 -58 q 413 -114 331 -114 q 567 -74 514 -114 q 619 47 619 -33 l 619 167 q 534 74 592 108 q 404 39 476 39 q 238 84 311 39 q 124 210 165 129 q 83 396 83 292 q 124 581 83 500 q 235 708 164 663 q 399 753 307 753 q 538 717 476 753 q 625 619 600 681 l 625 736 l 736 736 l 736 50 q 650 -152 736 -79 q 413 -225 564 -225 m 410 150 q 563 218 507 150 q 619 403 619 286 q 563 577 619 513 q 410 642 506 642 q 260 576 314 642 q 206 396 206 510 q 260 216 206 282 q 410 150 315 150 m 414 838 q 276 885 329 838 q 219 1015 224 933 l 300 1015 q 414 924 313 924 q 528 1015 515 924 l 608 1015 q 551 885 604 933 q 414 838 499 838 z "},"ǧ":{"ha":833,"x_min":83,"x_max":736,"o":"m 413 -225 q 211 -167 294 -225 q 104 -11 128 -108 l 226 -3 q 288 -86 244 -58 q 413 -114 331 -114 q 567 -74 514 -114 q 619 47 619 -33 l 619 167 q 534 74 592 108 q 404 39 476 39 q 238 84 311 39 q 124 210 165 129 q 83 396 83 292 q 124 581 83 500 q 235 708 164 663 q 399 753 307 753 q 538 717 476 753 q 625 619 600 681 l 625 736 l 736 736 l 736 50 q 650 -152 736 -79 q 413 -225 564 -225 m 410 150 q 563 218 507 150 q 619 403 619 286 q 563 577 619 513 q 410 642 506 642 q 260 576 314 642 q 206 396 206 510 q 260 216 206 282 q 410 150 315 150 m 317 1014 l 414 889 l 511 1014 l 608 1014 l 479 829 l 349 829 l 219 1014 l 317 1014 z "},"ĝ":{"ha":833,"x_min":83,"x_max":736,"o":"m 413 -225 q 211 -167 294 -225 q 104 -11 128 -108 l 226 -3 q 288 -86 244 -58 q 413 -114 331 -114 q 567 -74 514 -114 q 619 47 619 -33 l 619 167 q 534 74 592 108 q 404 39 476 39 q 238 84 311 39 q 124 210 165 129 q 83 396 83 292 q 124 581 83 500 q 235 708 164 663 q 399 753 307 753 q 538 717 476 753 q 625 619 600 681 l 625 736 l 736 736 l 736 50 q 650 -152 736 -79 q 413 -225 564 -225 m 410 150 q 563 218 507 150 q 619 403 619 286 q 563 577 619 513 q 410 642 506 642 q 260 576 314 642 q 206 396 206 510 q 260 216 206 282 q 410 150 315 150 m 347 1015 l 478 1015 l 607 831 l 510 831 l 413 956 l 315 831 l 218 831 l 347 1015 z "},"ģ":{"ha":833,"x_min":83,"x_max":736,"o":"m 413 -225 q 211 -167 294 -225 q 104 -11 128 -108 l 226 -3 q 288 -86 244 -58 q 413 -114 331 -114 q 567 -74 514 -114 q 619 47 619 -33 l 619 167 q 534 74 592 108 q 404 39 476 39 q 238 84 311 39 q 124 210 165 129 q 83 396 83 292 q 124 581 83 500 q 235 708 164 663 q 399 753 307 753 q 538 717 476 753 q 625 619 600 681 l 625 736 l 736 736 l 736 50 q 650 -152 736 -79 q 413 -225 564 -225 m 410 150 q 563 218 507 150 q 619 403 619 286 q 563 577 619 513 q 410 642 506 642 q 260 576 314 642 q 206 396 206 510 q 260 216 206 282 q 410 150 315 150 m 440 949 l 488 949 l 488 824 l 349 824 l 349 943 l 438 1074 l 515 1074 l 440 949 z "},"ġ":{"ha":833,"x_min":83,"x_max":736,"o":"m 413 -225 q 211 -167 294 -225 q 104 -11 128 -108 l 226 -3 q 288 -86 244 -58 q 413 -114 331 -114 q 567 -74 514 -114 q 619 47 619 -33 l 619 167 q 534 74 592 108 q 404 39 476 39 q 238 84 311 39 q 124 210 165 129 q 83 396 83 292 q 124 581 83 500 q 235 708 164 663 q 399 753 307 753 q 538 717 476 753 q 625 619 600 681 l 625 736 l 736 736 l 736 50 q 650 -152 736 -79 q 413 -225 564 -225 m 410 150 q 563 218 507 150 q 619 403 619 286 q 563 577 619 513 q 410 642 506 642 q 260 576 314 642 q 206 396 206 510 q 260 216 206 282 q 410 150 315 150 m 351 988 l 474 988 l 474 851 l 351 851 l 351 988 z "},"ḡ":{"ha":833,"x_min":83,"x_max":736,"o":"m 413 -225 q 211 -167 294 -225 q 104 -11 128 -108 l 226 -3 q 288 -86 244 -58 q 413 -114 331 -114 q 567 -74 514 -114 q 619 47 619 -33 l 619 167 q 534 74 592 108 q 404 39 476 39 q 238 84 311 39 q 124 210 165 129 q 83 396 83 292 q 124 581 83 500 q 235 708 164 663 q 399 753 307 753 q 538 717 476 753 q 625 619 600 681 l 625 736 l 736 736 l 736 50 q 650 -152 736 -79 q 413 -225 564 -225 m 410 150 q 563 218 507 150 q 619 403 619 286 q 563 577 619 513 q 410 642 506 642 q 260 576 314 642 q 206 396 206 510 q 260 216 206 282 q 410 150 315 150 m 226 968 l 599 968 l 599 868 l 226 868 l 226 968 z "},"ǥ":{"ha":833,"x_min":83,"x_max":808,"o":"m 410 443 l 617 443 q 551 589 607 536 q 410 642 496 642 q 260 576 314 642 q 206 396 206 510 q 260 216 206 282 q 410 150 315 150 q 546 201 492 150 q 615 340 600 251 l 410 340 l 410 443 m 413 -225 q 211 -167 294 -225 q 104 -11 128 -108 l 226 -3 q 288 -86 244 -58 q 413 -114 331 -114 q 567 -74 514 -114 q 619 47 619 -33 l 619 167 q 534 74 592 108 q 404 39 476 39 q 238 84 311 39 q 124 210 165 129 q 83 396 83 292 q 124 581 83 500 q 235 708 164 663 q 399 753 307 753 q 538 717 476 753 q 625 619 600 681 l 625 736 l 736 736 l 736 443 l 808 443 l 808 340 l 736 340 l 736 50 q 650 -152 736 -79 q 413 -225 564 -225 z "},"h":{"ha":833,"x_min":139,"x_max":722,"o":"m 139 986 l 256 986 l 256 621 q 341 719 283 686 q 476 753 399 753 q 658 676 593 753 q 722 474 722 600 l 722 0 l 606 0 l 606 440 q 451 650 606 650 q 308 594 361 650 q 256 439 256 539 l 256 0 l 139 0 l 139 986 z "},"ħ":{"ha":833,"x_min":35,"x_max":722,"o":"m 35 914 l 139 914 l 139 986 l 256 986 l 256 914 l 388 914 l 388 811 l 256 811 l 256 621 q 341 719 283 686 q 476 753 399 753 q 658 676 593 753 q 722 474 722 600 l 722 0 l 606 0 l 606 440 q 451 650 606 650 q 308 594 361 650 q 256 439 256 539 l 256 0 l 139 0 l 139 811 l 35 811 l 35 914 z "},"ĥ":{"ha":833,"x_min":3,"x_max":722,"o":"m 139 986 l 256 986 l 256 621 q 341 719 283 686 q 476 753 399 753 q 658 676 593 753 q 722 474 722 600 l 722 0 l 606 0 l 606 440 q 451 650 606 650 q 308 594 361 650 q 256 439 256 539 l 256 0 l 139 0 l 139 986 m 132 1265 l 263 1265 l 392 1081 l 294 1081 l 197 1206 l 100 1081 l 3 1081 l 132 1265 z "},"i":{"ha":833,"x_min":111,"x_max":778,"o":"m 111 103 l 407 103 l 407 633 l 125 633 l 125 736 l 524 736 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 m 396 988 l 518 988 l 518 851 l 396 851 l 396 988 z "},"ı":{"ha":833,"x_min":111,"x_max":778,"o":"m 111 103 l 407 103 l 407 633 l 125 633 l 125 736 l 524 736 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 z "},"í":{"ha":833,"x_min":111,"x_max":778,"o":"m 111 103 l 407 103 l 407 633 l 125 633 l 125 736 l 524 736 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 m 497 1014 l 628 1014 l 503 836 l 406 836 l 497 1014 z "},"î":{"ha":833,"x_min":111,"x_max":778,"o":"m 111 103 l 407 103 l 407 633 l 125 633 l 125 736 l 524 736 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 m 392 1015 l 522 1015 l 651 831 l 554 831 l 457 956 l 360 831 l 263 831 l 392 1015 z "},"ï":{"ha":833,"x_min":111,"x_max":778,"o":"m 111 103 l 407 103 l 407 633 l 125 633 l 125 736 l 524 736 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 z "},"ì":{"ha":833,"x_min":111,"x_max":778,"o":"m 111 103 l 407 103 l 407 633 l 125 633 l 125 736 l 524 736 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 m 292 1014 l 422 1014 l 514 836 l 417 836 l 292 1014 z "},"ī":{"ha":833,"x_min":111,"x_max":778,"o":"m 111 103 l 407 103 l 407 633 l 125 633 l 125 736 l 524 736 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 m 271 968 l 643 968 l 643 868 l 271 868 l 271 968 z "},"į":{"ha":833,"x_min":111,"x_max":825,"o":"m 111 103 l 407 103 l 407 633 l 125 633 l 125 736 l 524 736 l 524 103 l 778 103 l 778 0 l 729 -62 q 695 -117 704 -96 q 686 -156 686 -137 q 728 -197 686 -197 q 763 -192 744 -197 q 792 -178 781 -187 l 825 -256 q 776 -282 808 -272 q 706 -292 744 -292 q 614 -262 647 -292 q 581 -181 581 -232 q 638 -42 581 -112 l 671 0 l 111 0 l 111 103 m 396 988 l 518 988 l 518 851 l 396 851 l 396 988 z "},"ĩ":{"ha":833,"x_min":111,"x_max":778,"o":"m 111 103 l 407 103 l 407 633 l 125 633 l 125 736 l 524 736 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 m 542 847 q 444 889 496 847 q 405 918 418 911 q 372 925 392 925 q 311 838 315 925 l 239 838 q 277 958 240 915 q 372 1000 314 1000 q 427 989 403 1000 q 478 956 451 978 q 515 930 500 938 q 547 922 531 922 q 588 942 574 922 q 606 1014 601 963 l 678 1014 q 638 891 674 935 q 542 847 601 847 z "},"j":{"ha":833,"x_min":153,"x_max":611,"o":"m 153 -106 l 413 -106 q 472 -76 450 -106 q 494 0 494 -46 l 494 633 l 167 633 l 167 736 l 611 736 l 611 0 q 563 -152 611 -96 q 425 -208 515 -208 l 153 -208 l 153 -106 m 489 988 l 611 988 l 611 851 l 489 851 l 489 988 z "},"ȷ":{"ha":833,"x_min":153,"x_max":611,"o":"m 153 -106 l 413 -106 q 472 -76 450 -106 q 494 0 494 -46 l 494 633 l 167 633 l 167 736 l 611 736 l 611 0 q 563 -152 611 -96 q 425 -208 515 -208 l 153 -208 l 153 -106 z "},"ĵ":{"ha":833,"x_min":153,"x_max":744,"o":"m 153 -106 l 413 -106 q 472 -76 450 -106 q 494 0 494 -46 l 494 633 l 167 633 l 167 736 l 611 736 l 611 0 q 563 -152 611 -96 q 425 -208 515 -208 l 153 -208 l 153 -106 m 485 1015 l 615 1015 l 744 831 l 647 831 l 550 956 l 453 831 l 356 831 l 485 1015 z "},"k":{"ha":833,"x_min":125,"x_max":769,"o":"m 125 986 l 242 986 l 242 340 l 600 736 l 756 736 l 465 424 l 769 0 l 628 0 l 386 344 l 242 192 l 242 0 l 125 0 l 125 986 z "},"ǩ":{"ha":833,"x_min":-10,"x_max":769,"o":"m 125 986 l 242 986 l 242 340 l 600 736 l 756 736 l 465 424 l 769 0 l 628 0 l 386 344 l 242 192 l 242 0 l 125 0 l 125 986 m 88 1264 l 185 1139 l 282 1264 l 379 1264 l 250 1079 l 119 1079 l -10 1264 l 88 1264 z "},"ķ":{"ha":833,"x_min":125,"x_max":769,"o":"m 125 986 l 242 986 l 242 340 l 600 736 l 756 736 l 465 424 l 769 0 l 628 0 l 386 344 l 242 192 l 242 0 l 125 0 l 125 986 m 421 -172 l 374 -172 l 374 -47 l 513 -47 l 513 -167 l 424 -297 l 346 -297 l 421 -172 z "},"l":{"ha":833,"x_min":111,"x_max":778,"o":"m 407 789 q 382 858 407 833 q 313 883 357 883 l 139 883 l 139 986 l 318 986 q 472 934 419 986 q 524 781 524 882 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 l 407 103 l 407 789 z "},"ĺ":{"ha":833,"x_min":111,"x_max":778,"o":"m 407 789 q 382 858 407 833 q 313 883 357 883 l 139 883 l 139 986 l 318 986 q 472 934 419 986 q 524 781 524 882 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 l 407 103 l 407 789 m 456 1264 l 586 1264 l 461 1086 l 364 1086 l 456 1264 z "},"ľ":{"ha":833,"x_min":111,"x_max":778,"o":"m 407 789 q 382 858 407 833 q 313 883 357 883 l 139 883 l 139 986 l 318 986 q 472 934 419 986 q 524 781 524 882 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 l 407 103 l 407 789 m 625 986 l 740 986 l 668 650 l 583 650 l 625 986 z "},"ļ":{"ha":833,"x_min":111,"x_max":778,"o":"m 407 789 q 382 858 407 833 q 313 883 357 883 l 139 883 l 139 986 l 318 986 q 472 934 419 986 q 524 781 524 882 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 l 407 103 l 407 789 m 408 -172 l 361 -172 l 361 -47 l 500 -47 l 500 -167 l 411 -297 l 333 -297 l 408 -172 z "},"ł":{"ha":833,"x_min":139,"x_max":806,"o":"m 315 515 l 435 571 l 435 789 q 410 858 435 833 q 340 883 385 883 l 167 883 l 167 986 l 346 986 q 499 934 447 986 q 551 781 551 882 l 551 624 l 629 660 l 672 567 l 551 511 l 551 103 l 806 103 l 806 0 l 139 0 l 139 103 l 435 103 l 435 457 l 358 422 l 315 515 z "},"m":{"ha":833,"x_min":56,"x_max":778,"o":"m 56 736 l 163 736 l 165 628 q 222 720 185 688 q 311 753 260 753 q 450 608 425 753 q 510 715 468 676 q 608 753 551 753 q 738 690 697 753 q 778 486 778 628 l 778 0 l 661 0 l 661 472 q 640 609 661 568 q 575 650 619 650 q 502 605 529 650 q 475 469 475 560 l 475 0 l 358 0 l 358 472 q 338 608 358 567 q 272 650 318 650 q 199 605 226 650 q 172 469 172 560 l 172 0 l 56 0 l 56 736 z "},"n":{"ha":833,"x_min":133,"x_max":717,"o":"m 133 736 l 240 736 l 243 604 q 331 715 271 678 q 469 753 390 753 q 653 675 590 753 q 717 474 717 597 l 717 0 l 600 0 l 600 440 q 563 597 600 544 q 444 650 526 650 q 303 595 357 650 q 250 440 250 540 l 250 0 l 133 0 l 133 736 z "},"ń":{"ha":833,"x_min":133,"x_max":717,"o":"m 133 736 l 240 736 l 243 604 q 331 715 271 678 q 469 753 390 753 q 653 675 590 753 q 717 474 717 597 l 717 0 l 600 0 l 600 440 q 563 597 600 544 q 444 650 526 650 q 303 595 357 650 q 250 440 250 540 l 250 0 l 133 0 l 133 736 m 464 1014 l 594 1014 l 469 836 l 372 836 l 464 1014 z "},"ň":{"ha":833,"x_min":133,"x_max":717,"o":"m 133 736 l 240 736 l 243 604 q 331 715 271 678 q 469 753 390 753 q 653 675 590 753 q 717 474 717 597 l 717 0 l 600 0 l 600 440 q 563 597 600 544 q 444 650 526 650 q 303 595 357 650 q 250 440 250 540 l 250 0 l 133 0 l 133 736 m 328 1014 l 425 889 l 522 1014 l 619 1014 l 490 829 l 360 829 l 231 1014 l 328 1014 z "},"ņ":{"ha":833,"x_min":133,"x_max":717,"o":"m 133 736 l 240 736 l 243 604 q 331 715 271 678 q 469 753 390 753 q 653 675 590 753 q 717 474 717 597 l 717 0 l 600 0 l 600 440 q 563 597 600 544 q 444 650 526 650 q 303 595 357 650 q 250 440 250 540 l 250 0 l 133 0 l 133 736 m 417 -172 l 369 -172 l 369 -47 l 508 -47 l 508 -167 l 419 -297 l 342 -297 l 417 -172 z "},"ñ":{"ha":833,"x_min":133,"x_max":717,"o":"m 133 736 l 240 736 l 243 604 q 331 715 271 678 q 469 753 390 753 q 653 675 590 753 q 717 474 717 597 l 717 0 l 600 0 l 600 440 q 563 597 600 544 q 444 650 526 650 q 303 595 357 650 q 250 440 250 540 l 250 0 l 133 0 l 133 736 m 508 847 q 411 889 463 847 q 372 918 385 911 q 339 925 358 925 q 278 838 282 925 l 206 838 q 244 958 207 915 q 339 1000 281 1000 q 394 989 369 1000 q 444 956 418 978 q 482 930 467 938 q 514 922 497 922 q 554 942 540 922 q 572 1014 568 963 l 644 1014 q 604 891 640 935 q 508 847 568 847 z "},"ŋ":{"ha":833,"x_min":133,"x_max":717,"o":"m 396 -106 l 511 -106 q 576 -80 553 -106 q 600 -11 600 -54 l 600 440 q 563 597 600 544 q 444 650 526 650 q 303 595 357 650 q 250 440 250 540 l 250 0 l 133 0 l 133 736 l 240 736 l 243 604 q 331 715 271 678 q 469 753 390 753 q 653 675 590 753 q 717 474 717 597 l 717 -10 q 660 -155 717 -101 q 508 -208 604 -208 l 396 -208 l 396 -106 z "},"o":{"ha":833,"x_min":81,"x_max":753,"o":"m 417 -17 q 240 31 315 -17 q 122 165 164 78 q 81 368 81 251 q 122 572 81 485 q 240 706 164 658 q 417 753 315 753 q 593 706 517 753 q 711 572 669 658 q 753 368 753 485 q 711 165 753 251 q 593 31 669 78 q 417 -17 517 -17 m 417 94 q 574 167 518 94 q 631 368 631 240 q 574 569 631 496 q 417 642 518 642 q 259 569 315 642 q 203 368 203 497 q 259 167 203 239 q 417 94 315 94 z "},"ó":{"ha":833,"x_min":81,"x_max":753,"o":"m 417 -17 q 240 31 315 -17 q 122 165 164 78 q 81 368 81 251 q 122 572 81 485 q 240 706 164 658 q 417 753 315 753 q 593 706 517 753 q 711 572 669 658 q 753 368 753 485 q 711 165 753 251 q 593 31 669 78 q 417 -17 517 -17 m 417 94 q 574 167 518 94 q 631 368 631 240 q 574 569 631 496 q 417 642 518 642 q 259 569 315 642 q 203 368 203 497 q 259 167 203 239 q 417 94 315 94 m 456 1014 l 586 1014 l 461 836 l 364 836 l 456 1014 z "},"ô":{"ha":833,"x_min":81,"x_max":753,"o":"m 417 -17 q 240 31 315 -17 q 122 165 164 78 q 81 368 81 251 q 122 572 81 485 q 240 706 164 658 q 417 753 315 753 q 593 706 517 753 q 711 572 669 658 q 753 368 753 485 q 711 165 753 251 q 593 31 669 78 q 417 -17 517 -17 m 417 94 q 574 167 518 94 q 631 368 631 240 q 574 569 631 496 q 417 642 518 642 q 259 569 315 642 q 203 368 203 497 q 259 167 203 239 q 417 94 315 94 m 350 1015 l 481 1015 l 610 831 l 513 831 l 415 956 l 318 831 l 221 831 l 350 1015 z "},"ö":{"ha":833,"x_min":81,"x_max":753,"o":"m 417 -17 q 240 31 315 -17 q 122 165 164 78 q 81 368 81 251 q 122 572 81 485 q 240 706 164 658 q 417 753 315 753 q 593 706 517 753 q 711 572 669 658 q 753 368 753 485 q 711 165 753 251 q 593 31 669 78 q 417 -17 517 -17 m 417 94 q 574 167 518 94 q 631 368 631 240 q 574 569 631 496 q 417 642 518 642 q 259 569 315 642 q 203 368 203 497 q 259 167 203 239 q 417 94 315 94 z "},"ò":{"ha":833,"x_min":81,"x_max":753,"o":"m 417 -17 q 240 31 315 -17 q 122 165 164 78 q 81 368 81 251 q 122 572 81 485 q 240 706 164 658 q 417 753 315 753 q 593 706 517 753 q 711 572 669 658 q 753 368 753 485 q 711 165 753 251 q 593 31 669 78 q 417 -17 517 -17 m 417 94 q 574 167 518 94 q 631 368 631 240 q 574 569 631 496 q 417 642 518 642 q 259 569 315 642 q 203 368 203 497 q 259 167 203 239 q 417 94 315 94 m 250 1014 l 381 1014 l 472 836 l 375 836 l 250 1014 z "},"ő":{"ha":833,"x_min":81,"x_max":753,"o":"m 417 -17 q 240 31 315 -17 q 122 165 164 78 q 81 368 81 251 q 122 572 81 485 q 240 706 164 658 q 417 753 315 753 q 593 706 517 753 q 711 572 669 658 q 753 368 753 485 q 711 165 753 251 q 593 31 669 78 q 417 -17 517 -17 m 417 94 q 574 167 518 94 q 631 368 631 240 q 574 569 631 496 q 417 642 518 642 q 259 569 315 642 q 203 368 203 497 q 259 167 203 239 q 417 94 315 94 z "},"ō":{"ha":833,"x_min":81,"x_max":753,"o":"m 417 -17 q 240 31 315 -17 q 122 165 164 78 q 81 368 81 251 q 122 572 81 485 q 240 706 164 658 q 417 753 315 753 q 593 706 517 753 q 711 572 669 658 q 753 368 753 485 q 711 165 753 251 q 593 31 669 78 q 417 -17 517 -17 m 417 94 q 574 167 518 94 q 631 368 631 240 q 574 569 631 496 q 417 642 518 642 q 259 569 315 642 q 203 368 203 497 q 259 167 203 239 q 417 94 315 94 m 229 968 l 601 968 l 601 868 l 229 868 l 229 968 z "},"ø":{"ha":833,"x_min":49,"x_max":783,"o":"m 151 113 q 81 368 81 214 q 122 572 81 485 q 240 706 164 658 q 417 753 315 753 q 614 692 529 753 l 663 753 l 783 753 l 681 624 q 753 368 753 521 q 711 165 753 251 q 593 31 669 78 q 417 -17 517 -17 q 218 44 303 -17 l 169 -17 l 49 -17 l 151 113 m 417 94 q 574 167 518 94 q 631 368 631 240 q 601 524 631 461 l 290 135 q 417 94 342 94 m 203 368 q 232 213 203 275 l 542 601 q 417 642 490 642 q 259 569 315 642 q 203 368 203 497 z "},"õ":{"ha":833,"x_min":81,"x_max":753,"o":"m 417 -17 q 240 31 315 -17 q 122 165 164 78 q 81 368 81 251 q 122 572 81 485 q 240 706 164 658 q 417 753 315 753 q 593 706 517 753 q 711 572 669 658 q 753 368 753 485 q 711 165 753 251 q 593 31 669 78 q 417 -17 517 -17 m 417 94 q 574 167 518 94 q 631 368 631 240 q 574 569 631 496 q 417 642 518 642 q 259 569 315 642 q 203 368 203 497 q 259 167 203 239 q 417 94 315 94 m 500 847 q 403 889 454 847 q 363 918 376 911 q 331 925 350 925 q 269 838 274 925 l 197 838 q 235 958 199 915 q 331 1000 272 1000 q 385 989 361 1000 q 436 956 410 978 q 474 930 458 938 q 506 922 489 922 q 546 942 532 922 q 564 1014 560 963 l 636 1014 q 596 891 632 935 q 500 847 560 847 z "},"œ":{"ha":833,"x_min":14,"x_max":821,"o":"m 594 -17 q 414 90 475 -17 q 243 -17 351 -17 q 122 31 174 -17 q 42 165 69 78 q 14 368 14 251 q 42 572 14 485 q 122 706 69 658 q 243 753 174 753 q 414 646 351 753 q 594 753 475 753 q 757 661 701 753 q 819 385 813 569 l 821 332 l 475 332 q 511 156 481 218 q 594 94 542 94 q 658 123 635 94 q 694 218 681 151 l 813 208 q 737 35 786 86 q 594 -17 688 -17 m 243 94 q 326 168 296 94 q 357 368 357 242 q 326 568 357 494 q 243 642 296 642 q 161 567 192 642 q 131 368 131 493 q 161 169 131 243 q 243 94 192 94 m 700 435 q 588 642 686 642 q 476 435 493 642 l 700 435 z "},"p":{"ha":833,"x_min":111,"x_max":764,"o":"m 111 736 l 219 736 l 221 618 q 313 718 254 683 q 447 753 372 753 q 624 701 553 753 q 729 561 694 649 q 764 368 764 474 q 729 175 764 263 q 624 35 694 88 q 447 -17 553 -17 q 316 14 375 -17 q 228 97 257 44 l 228 -208 l 111 -208 l 111 736 m 436 94 q 587 167 532 94 q 642 368 642 239 q 587 569 642 497 q 436 642 532 642 q 283 572 339 642 q 228 368 228 501 q 283 165 228 235 q 436 94 338 94 z "},"þ":{"ha":833,"x_min":111,"x_max":764,"o":"m 111 986 l 219 986 l 222 618 q 313 718 254 683 q 447 753 372 753 q 624 701 553 753 q 729 561 694 649 q 764 368 764 474 q 729 175 764 263 q 624 35 694 88 q 447 -17 553 -17 q 316 14 375 -17 q 228 97 257 44 l 228 -208 l 111 -208 l 111 986 m 436 94 q 587 167 532 94 q 642 368 642 239 q 587 569 642 497 q 436 642 532 642 q 283 572 339 642 q 228 368 228 501 q 283 165 228 235 q 436 94 338 94 z "},"q":{"ha":833,"x_min":69,"x_max":722,"o":"m 606 -208 l 606 97 q 517 14 576 44 q 386 -17 458 -17 q 210 35 281 -17 q 104 175 139 88 q 69 368 69 263 q 104 561 69 474 q 210 701 139 649 q 386 753 281 753 q 520 718 461 753 q 613 618 579 683 l 614 736 l 722 736 l 722 -208 l 606 -208 m 397 94 q 551 165 496 94 q 606 368 606 235 q 550 572 606 501 q 397 642 494 642 q 247 569 301 642 q 192 368 192 497 q 247 167 192 239 q 397 94 301 94 z "},"r":{"ha":833,"x_min":125,"x_max":750,"o":"m 125 103 l 317 103 l 317 633 l 125 633 l 125 736 l 414 736 l 422 594 q 599 736 460 736 l 750 736 l 750 631 l 600 631 q 476 583 519 631 q 433 444 433 535 l 433 103 l 667 103 l 667 0 l 125 0 l 125 103 z "},"ŕ":{"ha":833,"x_min":125,"x_max":750,"o":"m 125 103 l 317 103 l 317 633 l 125 633 l 125 736 l 414 736 l 422 594 q 599 736 460 736 l 750 736 l 750 631 l 600 631 q 476 583 519 631 q 433 444 433 535 l 433 103 l 667 103 l 667 0 l 125 0 l 125 103 m 510 1014 l 640 1014 l 515 836 l 418 836 l 510 1014 z "},"ř":{"ha":833,"x_min":125,"x_max":750,"o":"m 125 103 l 317 103 l 317 633 l 125 633 l 125 736 l 414 736 l 422 594 q 599 736 460 736 l 750 736 l 750 631 l 600 631 q 476 583 519 631 q 433 444 433 535 l 433 103 l 667 103 l 667 0 l 125 0 l 125 103 m 374 1014 l 471 889 l 568 1014 l 665 1014 l 536 829 l 406 829 l 276 1014 l 374 1014 z "},"ŗ":{"ha":833,"x_min":125,"x_max":750,"o":"m 125 103 l 317 103 l 317 633 l 125 633 l 125 736 l 414 736 l 422 594 q 599 736 460 736 l 750 736 l 750 631 l 600 631 q 476 583 519 631 q 433 444 433 535 l 433 103 l 667 103 l 667 0 l 125 0 l 125 103 m 365 -172 l 318 -172 l 318 -47 l 457 -47 l 457 -167 l 368 -297 l 290 -297 l 365 -172 z "},"s":{"ha":833,"x_min":111,"x_max":722,"o":"m 431 -17 q 267 16 338 -17 q 157 105 197 49 q 111 232 117 161 l 233 240 q 294 133 244 171 q 431 94 344 94 q 558 119 514 94 q 603 192 603 143 q 588 245 603 225 q 531 280 572 265 q 408 308 490 294 q 242 356 303 326 q 156 426 182 385 q 131 531 131 468 q 204 691 131 629 q 410 753 278 753 q 615 686 539 753 q 708 517 690 619 l 586 508 q 527 606 574 569 q 408 642 481 642 q 290 611 331 642 q 250 531 250 581 q 268 472 250 493 q 324 437 286 450 q 433 411 363 424 q 610 365 547 393 q 697 297 672 338 q 722 192 722 256 q 640 40 722 96 q 431 -17 558 -17 z "},"ś":{"ha":833,"x_min":111,"x_max":722,"o":"m 431 -17 q 267 16 338 -17 q 157 105 197 49 q 111 232 117 161 l 233 240 q 294 133 244 171 q 431 94 344 94 q 558 119 514 94 q 603 192 603 143 q 588 245 603 225 q 531 280 572 265 q 408 308 490 294 q 242 356 303 326 q 156 426 182 385 q 131 531 131 468 q 204 691 131 629 q 410 753 278 753 q 615 686 539 753 q 708 517 690 619 l 586 508 q 527 606 574 569 q 408 642 481 642 q 290 611 331 642 q 250 531 250 581 q 268 472 250 493 q 324 437 286 450 q 433 411 363 424 q 610 365 547 393 q 697 297 672 338 q 722 192 722 256 q 640 40 722 96 q 431 -17 558 -17 m 458 1014 l 589 1014 l 464 836 l 367 836 l 458 1014 z "},"š":{"ha":833,"x_min":111,"x_max":722,"o":"m 431 -17 q 267 16 338 -17 q 157 105 197 49 q 111 232 117 161 l 233 240 q 294 133 244 171 q 431 94 344 94 q 558 119 514 94 q 603 192 603 143 q 588 245 603 225 q 531 280 572 265 q 408 308 490 294 q 242 356 303 326 q 156 426 182 385 q 131 531 131 468 q 204 691 131 629 q 410 753 278 753 q 615 686 539 753 q 708 517 690 619 l 586 508 q 527 606 574 569 q 408 642 481 642 q 290 611 331 642 q 250 531 250 581 q 268 472 250 493 q 324 437 286 450 q 433 411 363 424 q 610 365 547 393 q 697 297 672 338 q 722 192 722 256 q 640 40 722 96 q 431 -17 558 -17 m 322 1014 l 419 889 l 517 1014 l 614 1014 l 485 829 l 354 829 l 225 1014 l 322 1014 z "},"ş":{"ha":833,"x_min":111,"x_max":722,"o":"m 410 -306 q 326 -284 363 -306 q 247 -212 290 -262 l 304 -160 q 351 -211 329 -192 q 404 -231 374 -231 q 452 -215 435 -231 q 469 -171 469 -199 q 450 -129 469 -144 q 397 -114 431 -114 q 364 -118 383 -114 l 325 -67 l 374 -14 q 188 68 258 0 q 111 232 118 136 l 233 240 q 294 133 244 171 q 431 94 344 94 q 558 119 514 94 q 603 192 603 143 q 588 245 603 225 q 531 280 572 265 q 408 308 490 294 q 242 356 303 326 q 156 426 182 385 q 131 531 131 468 q 204 691 131 629 q 410 753 278 753 q 615 686 539 753 q 708 517 690 619 l 586 508 q 527 606 574 569 q 408 642 481 642 q 290 611 331 642 q 250 531 250 581 q 268 472 250 493 q 324 437 286 450 q 433 411 363 424 q 610 365 547 393 q 697 297 672 338 q 722 192 722 256 q 644 42 722 97 q 443 -17 567 -14 l 411 -51 q 508 -86 471 -51 q 546 -176 546 -121 q 506 -268 546 -231 q 410 -306 467 -306 z "},"ŝ":{"ha":833,"x_min":111,"x_max":722,"o":"m 431 -17 q 267 16 338 -17 q 157 105 197 49 q 111 232 117 161 l 233 240 q 294 133 244 171 q 431 94 344 94 q 558 119 514 94 q 603 192 603 143 q 588 245 603 225 q 531 280 572 265 q 408 308 490 294 q 242 356 303 326 q 156 426 182 385 q 131 531 131 468 q 204 691 131 629 q 410 753 278 753 q 615 686 539 753 q 708 517 690 619 l 586 508 q 527 606 574 569 q 408 642 481 642 q 290 611 331 642 q 250 531 250 581 q 268 472 250 493 q 324 437 286 450 q 433 411 363 424 q 610 365 547 393 q 697 297 672 338 q 722 192 722 256 q 640 40 722 96 q 431 -17 558 -17 m 353 1015 l 483 1015 l 613 831 l 515 831 l 418 956 l 321 831 l 224 831 l 353 1015 z "},"ș":{"ha":833,"x_min":111,"x_max":722,"o":"m 431 -17 q 267 16 338 -17 q 157 105 197 49 q 111 232 117 161 l 233 240 q 294 133 244 171 q 431 94 344 94 q 558 119 514 94 q 603 192 603 143 q 588 245 603 225 q 531 280 572 265 q 408 308 490 294 q 242 356 303 326 q 156 426 182 385 q 131 531 131 468 q 204 691 131 629 q 410 753 278 753 q 615 686 539 753 q 708 517 690 619 l 586 508 q 527 606 574 569 q 408 642 481 642 q 290 611 331 642 q 250 531 250 581 q 268 472 250 493 q 324 437 286 450 q 433 411 363 424 q 610 365 547 393 q 697 297 672 338 q 722 192 722 256 q 640 40 722 96 q 431 -17 558 -17 m 411 -172 l 364 -172 l 364 -47 l 503 -47 l 503 -167 l 414 -297 l 336 -297 l 411 -172 z "},"ß":{"ha":833,"x_min":106,"x_max":756,"o":"m 106 697 q 144 861 106 792 q 250 967 182 931 q 403 1003 318 1003 q 555 972 489 1003 q 658 883 621 940 q 694 751 694 826 q 656 617 694 675 q 546 535 617 558 q 698 442 640 510 q 756 276 756 374 q 708 126 756 189 q 581 32 660 64 q 407 0 501 0 l 318 0 l 318 111 l 407 111 q 567 157 501 111 q 633 286 633 203 q 565 425 633 378 q 393 469 496 472 l 332 468 l 332 581 l 393 579 q 524 623 474 578 q 575 740 575 668 q 527 849 575 807 q 403 892 479 892 q 272 842 321 892 q 224 697 224 792 l 224 0 l 106 0 l 106 697 z "},"t":{"ha":833,"x_min":69,"x_max":722,"o":"m 525 0 q 365 50 418 0 q 313 203 313 100 l 313 633 l 69 633 l 69 736 l 313 736 l 313 931 l 429 931 l 429 736 l 722 736 l 722 633 l 429 633 l 429 203 q 453 127 429 151 q 525 103 476 103 l 722 103 l 722 0 l 525 0 z "},"ŧ":{"ha":833,"x_min":69,"x_max":722,"o":"m 92 443 l 313 443 l 313 633 l 69 633 l 69 736 l 313 736 l 313 931 l 429 931 l 429 736 l 722 736 l 722 633 l 429 633 l 429 443 l 710 443 l 710 340 l 429 340 l 429 203 q 453 127 429 151 q 525 103 476 103 l 722 103 l 722 0 l 525 0 q 365 50 418 0 q 313 203 313 100 l 313 340 l 92 340 l 92 443 z "},"ť":{"ha":833,"x_min":69,"x_max":722,"o":"m 525 0 q 365 50 418 0 q 313 203 313 100 l 313 633 l 69 633 l 69 736 l 313 736 l 313 931 l 429 931 l 429 736 l 722 736 l 722 633 l 429 633 l 429 203 q 453 127 429 151 q 525 103 476 103 l 722 103 l 722 0 l 525 0 m 550 1106 l 665 1106 l 593 769 l 508 769 l 550 1106 z "},"ţ":{"ha":833,"x_min":69,"x_max":722,"o":"m 478 -51 q 575 -86 538 -51 q 613 -176 613 -121 q 573 -268 613 -231 q 476 -306 533 -306 q 393 -284 429 -306 q 314 -212 357 -262 l 371 -160 q 418 -211 396 -192 q 471 -231 440 -231 q 519 -215 501 -231 q 536 -171 536 -199 q 517 -129 536 -144 q 464 -114 497 -114 q 431 -118 450 -114 l 392 -67 l 458 6 q 349 68 385 19 q 313 203 313 117 l 313 633 l 69 633 l 69 736 l 313 736 l 313 931 l 429 931 l 429 736 l 722 736 l 722 633 l 429 633 l 429 203 q 453 127 429 151 q 525 103 476 103 l 722 103 l 722 0 l 525 0 l 478 -51 z "},"ț":{"ha":833,"x_min":69,"x_max":722,"o":"m 525 0 q 365 50 418 0 q 313 203 313 100 l 313 633 l 69 633 l 69 736 l 313 736 l 313 931 l 429 931 l 429 736 l 722 736 l 722 633 l 429 633 l 429 203 q 453 127 429 151 q 525 103 476 103 l 722 103 l 722 0 l 525 0 m 478 -172 l 431 -172 l 431 -47 l 569 -47 l 569 -167 l 481 -297 l 403 -297 l 478 -172 z "},"u":{"ha":833,"x_min":125,"x_max":700,"o":"m 365 -17 q 190 58 256 -17 q 125 263 125 132 l 125 736 l 242 736 l 242 294 q 280 137 242 188 q 397 86 318 86 q 533 142 483 86 q 583 297 583 199 l 583 736 l 700 736 l 700 0 l 589 0 l 589 119 q 501 19 558 54 q 365 -17 444 -17 z "},"ú":{"ha":833,"x_min":125,"x_max":700,"o":"m 365 -17 q 190 58 256 -17 q 125 263 125 132 l 125 736 l 242 736 l 242 294 q 280 137 242 188 q 397 86 318 86 q 533 142 483 86 q 583 297 583 199 l 583 736 l 700 736 l 700 0 l 589 0 l 589 119 q 501 19 558 54 q 365 -17 444 -17 m 446 1014 l 576 1014 l 451 836 l 354 836 l 446 1014 z "},"ŭ":{"ha":833,"x_min":125,"x_max":700,"o":"m 365 -17 q 190 58 256 -17 q 125 263 125 132 l 125 736 l 242 736 l 242 294 q 280 137 242 188 q 397 86 318 86 q 533 142 483 86 q 583 297 583 199 l 583 736 l 700 736 l 700 0 l 589 0 l 589 119 q 501 19 558 54 q 365 -17 444 -17 m 407 838 q 269 885 322 838 q 213 1015 217 933 l 293 1015 q 407 924 306 924 q 521 1015 508 924 l 601 1015 q 544 885 597 933 q 407 838 492 838 z "},"û":{"ha":833,"x_min":125,"x_max":700,"o":"m 365 -17 q 190 58 256 -17 q 125 263 125 132 l 125 736 l 242 736 l 242 294 q 280 137 242 188 q 397 86 318 86 q 533 142 483 86 q 583 297 583 199 l 583 736 l 700 736 l 700 0 l 589 0 l 589 119 q 501 19 558 54 q 365 -17 444 -17 m 340 1015 l 471 1015 l 600 831 l 503 831 l 406 956 l 308 831 l 211 831 l 340 1015 z "},"ü":{"ha":833,"x_min":125,"x_max":700,"o":"m 365 -17 q 190 58 256 -17 q 125 263 125 132 l 125 736 l 242 736 l 242 294 q 280 137 242 188 q 397 86 318 86 q 533 142 483 86 q 583 297 583 199 l 583 736 l 700 736 l 700 0 l 589 0 l 589 119 q 501 19 558 54 q 365 -17 444 -17 z "},"ù":{"ha":833,"x_min":125,"x_max":700,"o":"m 365 -17 q 190 58 256 -17 q 125 263 125 132 l 125 736 l 242 736 l 242 294 q 280 137 242 188 q 397 86 318 86 q 533 142 483 86 q 583 297 583 199 l 583 736 l 700 736 l 700 0 l 589 0 l 589 119 q 501 19 558 54 q 365 -17 444 -17 m 240 1014 l 371 1014 l 463 836 l 365 836 l 240 1014 z "},"ű":{"ha":833,"x_min":125,"x_max":700,"o":"m 365 -17 q 190 58 256 -17 q 125 263 125 132 l 125 736 l 242 736 l 242 294 q 280 137 242 188 q 397 86 318 86 q 533 142 483 86 q 583 297 583 199 l 583 736 l 700 736 l 700 0 l 589 0 l 589 119 q 501 19 558 54 q 365 -17 444 -17 z "},"ū":{"ha":833,"x_min":125,"x_max":700,"o":"m 365 -17 q 190 58 256 -17 q 125 263 125 132 l 125 736 l 242 736 l 242 294 q 280 137 242 188 q 397 86 318 86 q 533 142 483 86 q 583 297 583 199 l 583 736 l 700 736 l 700 0 l 589 0 l 589 119 q 501 19 558 54 q 365 -17 444 -17 m 219 968 l 592 968 l 592 868 l 219 868 l 219 968 z "},"ų":{"ha":833,"x_min":125,"x_max":747,"o":"m 365 -17 q 190 58 256 -17 q 125 263 125 132 l 125 736 l 242 736 l 242 294 q 280 137 242 188 q 397 86 318 86 q 533 142 483 86 q 583 297 583 199 l 583 736 l 700 736 l 700 0 l 651 -62 q 617 -117 626 -96 q 608 -156 608 -137 q 650 -197 608 -197 q 685 -192 667 -197 q 714 -178 703 -187 l 747 -256 q 699 -282 731 -272 q 628 -292 667 -292 q 536 -262 569 -292 q 503 -181 503 -232 q 560 -42 503 -112 l 593 0 l 589 0 l 589 119 q 501 19 558 54 q 365 -17 444 -17 z "},"ů":{"ha":833,"x_min":125,"x_max":700,"o":"m 365 -17 q 190 58 256 -17 q 125 263 125 132 l 125 736 l 242 736 l 242 294 q 280 137 242 188 q 397 86 318 86 q 533 142 483 86 q 583 297 583 199 l 583 736 l 700 736 l 700 0 l 589 0 l 589 119 q 501 19 558 54 q 365 -17 444 -17 m 407 814 q 316 851 353 814 q 279 942 279 888 q 316 1033 279 996 q 407 1069 353 1069 q 498 1033 461 1069 q 535 942 535 996 q 498 851 535 888 q 407 814 461 814 m 407 883 q 449 900 432 883 q 465 942 465 917 q 449 983 465 967 q 407 1000 432 1000 q 365 983 382 1000 q 349 942 349 967 q 365 900 349 917 q 407 883 382 883 z "},"ũ":{"ha":833,"x_min":125,"x_max":700,"o":"m 365 -17 q 190 58 256 -17 q 125 263 125 132 l 125 736 l 242 736 l 242 294 q 280 137 242 188 q 397 86 318 86 q 533 142 483 86 q 583 297 583 199 l 583 736 l 700 736 l 700 0 l 589 0 l 589 119 q 501 19 558 54 q 365 -17 444 -17 m 490 847 q 393 889 444 847 q 353 918 367 911 q 321 925 340 925 q 260 838 264 925 l 188 838 q 226 958 189 915 q 321 1000 263 1000 q 376 989 351 1000 q 426 956 400 978 q 464 930 449 938 q 496 922 479 922 q 536 942 522 922 q 554 1014 550 963 l 626 1014 q 586 891 622 935 q 490 847 550 847 z "},"v":{"ha":833,"x_min":75,"x_max":758,"o":"m 75 736 l 203 736 l 417 119 l 631 736 l 758 736 l 490 0 l 343 0 l 75 736 z "},"w":{"ha":833,"x_min":28,"x_max":806,"o":"m 28 736 l 156 736 l 249 122 l 354 633 l 479 633 l 585 122 l 678 736 l 806 736 l 661 0 l 514 0 l 417 458 l 319 0 l 174 0 l 28 736 z "},"ẃ":{"ha":833,"x_min":28,"x_max":806,"o":"m 28 736 l 156 736 l 249 122 l 354 633 l 479 633 l 585 122 l 678 736 l 806 736 l 661 0 l 514 0 l 417 458 l 319 0 l 174 0 l 28 736 m 456 1014 l 586 1014 l 461 836 l 364 836 l 456 1014 z "},"ŵ":{"ha":833,"x_min":28,"x_max":806,"o":"m 28 736 l 156 736 l 249 122 l 354 633 l 479 633 l 585 122 l 678 736 l 806 736 l 661 0 l 514 0 l 417 458 l 319 0 l 174 0 l 28 736 m 350 1015 l 481 1015 l 610 831 l 513 831 l 415 956 l 318 831 l 221 831 l 350 1015 z "},"ẅ":{"ha":833,"x_min":28,"x_max":806,"o":"m 28 736 l 156 736 l 249 122 l 354 633 l 479 633 l 585 122 l 678 736 l 806 736 l 661 0 l 514 0 l 417 458 l 319 0 l 174 0 l 28 736 z "},"ẁ":{"ha":833,"x_min":28,"x_max":806,"o":"m 28 736 l 156 736 l 249 122 l 354 633 l 479 633 l 585 122 l 678 736 l 806 736 l 661 0 l 514 0 l 417 458 l 319 0 l 174 0 l 28 736 m 250 1014 l 381 1014 l 472 836 l 375 836 l 250 1014 z "},"x":{"ha":833,"x_min":75,"x_max":758,"o":"m 346 378 l 89 736 l 225 736 l 418 458 l 607 736 l 746 736 l 490 375 l 758 0 l 622 0 l 418 297 l 214 0 l 75 0 l 346 378 z "},"y":{"ha":833,"x_min":75,"x_max":764,"o":"m 192 -106 l 276 -106 q 333 -94 314 -106 q 363 -56 351 -82 l 389 14 l 349 14 l 75 736 l 203 736 l 426 122 l 636 736 l 764 736 l 465 -93 q 403 -181 444 -153 q 288 -208 361 -208 l 192 -208 l 192 -106 z "},"ý":{"ha":833,"x_min":75,"x_max":764,"o":"m 192 -106 l 276 -106 q 333 -94 314 -106 q 363 -56 351 -82 l 389 14 l 349 14 l 75 736 l 203 736 l 426 122 l 636 736 l 764 736 l 465 -93 q 403 -181 444 -153 q 288 -208 361 -208 l 192 -208 l 192 -106 m 460 1014 l 590 1014 l 465 836 l 368 836 l 460 1014 z "},"ŷ":{"ha":833,"x_min":75,"x_max":764,"o":"m 192 -106 l 276 -106 q 333 -94 314 -106 q 363 -56 351 -82 l 389 14 l 349 14 l 75 736 l 203 736 l 426 122 l 636 736 l 764 736 l 465 -93 q 403 -181 444 -153 q 288 -208 361 -208 l 192 -208 l 192 -106 m 354 1015 l 485 1015 l 614 831 l 517 831 l 419 956 l 322 831 l 225 831 l 354 1015 z "},"ÿ":{"ha":833,"x_min":75,"x_max":764,"o":"m 192 -106 l 276 -106 q 333 -94 314 -106 q 363 -56 351 -82 l 389 14 l 349 14 l 75 736 l 203 736 l 426 122 l 636 736 l 764 736 l 465 -93 q 403 -181 444 -153 q 288 -208 361 -208 l 192 -208 l 192 -106 z "},"ỳ":{"ha":833,"x_min":75,"x_max":764,"o":"m 192 -106 l 276 -106 q 333 -94 314 -106 q 363 -56 351 -82 l 389 14 l 349 14 l 75 736 l 203 736 l 426 122 l 636 736 l 764 736 l 465 -93 q 403 -181 444 -153 q 288 -208 361 -208 l 192 -208 l 192 -106 m 254 1014 l 385 1014 l 476 836 l 379 836 l 254 1014 z "},"ỹ":{"ha":833,"x_min":75,"x_max":764,"o":"m 192 -106 l 276 -106 q 333 -94 314 -106 q 363 -56 351 -82 l 389 14 l 349 14 l 75 736 l 203 736 l 426 122 l 636 736 l 764 736 l 465 -93 q 403 -181 444 -153 q 288 -208 361 -208 l 192 -208 l 192 -106 m 504 847 q 407 889 458 847 q 367 918 381 911 q 335 925 354 925 q 274 838 278 925 l 201 838 q 240 958 203 915 q 335 1000 276 1000 q 390 989 365 1000 q 440 956 414 978 q 478 930 463 938 q 510 922 493 922 q 550 942 536 922 q 568 1014 564 963 l 640 1014 q 600 891 636 935 q 504 847 564 847 z "},"z":{"ha":833,"x_min":114,"x_max":719,"o":"m 114 117 l 581 633 l 128 633 l 128 736 l 708 736 l 708 619 l 242 103 l 719 103 l 719 0 l 114 0 l 114 117 z "},"ź":{"ha":833,"x_min":114,"x_max":719,"o":"m 114 117 l 581 633 l 128 633 l 128 736 l 708 736 l 708 619 l 242 103 l 719 103 l 719 0 l 114 0 l 114 117 m 457 1014 l 588 1014 l 463 836 l 365 836 l 457 1014 z "},"ž":{"ha":833,"x_min":114,"x_max":719,"o":"m 114 117 l 581 633 l 128 633 l 128 736 l 708 736 l 708 619 l 242 103 l 719 103 l 719 0 l 114 0 l 114 117 m 321 1014 l 418 889 l 515 1014 l 613 1014 l 483 829 l 353 829 l 224 1014 l 321 1014 z "},"ż":{"ha":833,"x_min":114,"x_max":719,"o":"m 114 117 l 581 633 l 128 633 l 128 736 l 708 736 l 708 619 l 242 103 l 719 103 l 719 0 l 114 0 l 114 117 m 356 988 l 478 988 l 478 851 l 356 851 l 356 988 z "},"ꞌ":{"ha":833,"x_min":356,"x_max":478,"o":"m 356 797 l 356 986 l 478 986 l 478 797 l 458 525 l 375 525 l 356 797 z "},"ª":{"ha":833,"x_min":199,"x_max":635,"o":"m 354 513 q 244 549 289 513 q 199 643 199 585 q 235 731 199 696 q 339 779 272 765 l 483 808 q 399 906 483 906 q 306 838 322 906 l 207 844 q 270 953 219 913 q 399 993 321 993 q 533 943 486 993 q 581 803 581 893 l 581 636 q 588 613 581 619 q 610 606 594 606 l 635 606 l 635 522 q 593 519 621 519 q 494 590 511 519 q 440 534 478 556 q 354 513 403 513 m 367 594 q 452 623 421 594 q 483 700 483 651 l 483 733 l 364 710 q 316 689 332 703 q 300 650 300 675 q 317 609 300 624 q 367 594 335 594 z "},"º":{"ha":833,"x_min":201,"x_max":631,"o":"m 415 513 q 260 578 319 513 q 201 751 201 643 q 260 924 201 858 q 415 989 319 989 q 571 924 511 989 q 631 751 631 858 q 571 578 631 643 q 415 513 511 513 m 415 601 q 499 642 469 601 q 529 751 529 682 q 499 860 529 821 q 415 900 469 900 q 333 860 363 900 q 303 751 303 821 q 333 642 303 682 q 415 601 363 601 z "},"А":{"ha":833,"x_min":36,"x_max":797,"o":"m 336 986 l 497 986 l 797 0 l 675 0 l 594 275 l 239 275 l 158 0 l 36 0 l 336 986 m 561 389 l 417 878 l 272 389 l 561 389 z "},"Б":{"ha":833,"x_min":111,"x_max":750,"o":"m 726 986 l 726 869 l 231 869 l 231 597 l 411 597 q 658 518 567 597 q 750 299 750 439 q 658 79 750 158 q 411 0 567 0 l 111 0 l 111 986 l 726 986 m 231 483 l 231 114 l 397 114 q 625 299 625 114 q 397 483 625 483 l 231 483 z "},"В":{"ha":833,"x_min":125,"x_max":772,"o":"m 125 986 l 403 986 q 647 915 563 986 q 731 717 731 844 q 682 580 731 636 q 554 508 633 524 q 715 433 657 494 q 772 275 772 371 q 683 74 772 147 q 438 0 593 0 l 125 0 l 125 986 m 439 114 q 594 156 540 114 q 647 275 647 199 q 594 400 647 356 q 439 444 540 444 l 244 444 l 244 114 l 439 114 m 408 558 q 606 714 606 558 q 408 872 606 872 l 244 872 l 244 558 l 408 558 z "},"Г":{"ha":833,"x_min":139,"x_max":767,"o":"m 139 986 l 767 986 l 767 869 l 258 869 l 258 0 l 139 0 l 139 986 z "},"Ѓ":{"ha":833,"x_min":139,"x_max":767,"o":"m 139 986 l 767 986 l 767 869 l 258 869 l 258 0 l 139 0 l 139 986 m 492 1264 l 622 1264 l 497 1086 l 400 1086 l 492 1264 z "},"Ґ":{"ha":833,"x_min":139,"x_max":781,"o":"m 258 0 l 139 0 l 139 986 l 661 986 l 661 1201 l 781 1201 l 781 869 l 258 869 l 258 0 z "},"Ғ":{"ha":833,"x_min":26,"x_max":767,"o":"m 26 514 l 139 514 l 139 986 l 767 986 l 767 869 l 258 869 l 258 514 l 590 514 l 590 397 l 258 397 l 258 0 l 139 0 l 139 397 l 26 397 l 26 514 z "},"Д":{"ha":833,"x_min":17,"x_max":806,"o":"m 17 117 l 74 117 q 153 160 125 117 q 192 300 181 204 l 264 986 l 683 986 l 683 117 l 806 117 l 806 -208 l 686 -208 l 686 0 l 136 0 l 136 -208 l 17 -208 l 17 117 m 564 117 l 564 869 l 374 869 l 311 286 q 256 117 299 176 l 564 117 z "},"Е":{"ha":833,"x_min":125,"x_max":736,"o":"m 125 986 l 725 986 l 725 872 l 244 872 l 244 553 l 708 553 l 708 439 l 244 439 l 244 114 l 736 114 l 736 0 l 125 0 l 125 986 z "},"Ѐ":{"ha":833,"x_min":125,"x_max":736,"o":"m 125 986 l 725 986 l 725 872 l 244 872 l 244 553 l 708 553 l 708 439 l 244 439 l 244 114 l 736 114 l 736 0 l 125 0 l 125 986 m 275 1265 l 406 1265 l 497 1088 l 400 1088 l 275 1265 z "},"Ё":{"ha":833,"x_min":125,"x_max":736,"o":"m 125 986 l 725 986 l 725 872 l 244 872 l 244 553 l 708 553 l 708 439 l 244 439 l 244 114 l 736 114 l 736 0 l 125 0 l 125 986 z "},"Ж":{"ha":833,"x_min":10,"x_max":824,"o":"m 242 493 l 24 986 l 153 986 l 357 525 l 357 986 l 476 986 l 476 525 l 681 986 l 810 986 l 592 493 l 824 0 l 694 0 l 476 464 l 476 0 l 357 0 l 357 464 l 139 0 l 10 0 l 242 493 z "},"З":{"ha":833,"x_min":46,"x_max":785,"o":"m 417 -22 q 232 13 315 -22 q 99 111 149 49 q 46 254 50 174 l 169 263 q 245 140 176 186 q 417 94 314 94 q 594 145 526 94 q 661 281 661 196 q 628 385 661 343 q 542 448 594 428 q 436 467 490 468 l 333 465 l 333 583 l 436 582 q 524 598 481 581 q 597 653 568 615 q 626 751 626 692 q 568 853 626 815 q 417 892 510 892 q 274 856 332 892 q 207 763 217 821 l 82 771 q 138 893 90 839 q 258 978 185 947 q 417 1008 331 1008 q 588 976 511 1008 q 708 885 664 943 q 751 751 751 826 q 571 533 751 586 q 729 435 674 503 q 785 271 785 367 q 737 119 785 186 q 605 15 689 53 q 417 -22 521 -22 z "},"И":{"ha":833,"x_min":97,"x_max":736,"o":"m 617 0 l 617 828 l 264 0 l 97 0 l 97 986 l 217 986 l 217 158 l 569 986 l 736 986 l 736 0 l 617 0 z "},"Й":{"ha":833,"x_min":97,"x_max":736,"o":"m 617 0 l 617 828 l 264 0 l 97 0 l 97 986 l 217 986 l 217 158 l 569 986 l 736 986 l 736 0 l 617 0 m 417 1088 q 279 1135 332 1088 q 222 1265 226 1183 l 303 1265 q 417 1174 315 1174 q 531 1265 518 1174 l 611 1265 q 554 1135 607 1183 q 417 1088 501 1088 z "},"К":{"ha":833,"x_min":81,"x_max":785,"o":"m 200 482 l 200 0 l 81 0 l 81 986 l 200 986 l 200 504 l 614 986 l 771 986 l 344 493 l 785 0 l 628 0 l 200 482 z "},"Ќ":{"ha":833,"x_min":81,"x_max":785,"o":"m 200 482 l 200 0 l 81 0 l 81 986 l 200 986 l 200 504 l 614 986 l 771 986 l 344 493 l 785 0 l 628 0 l 200 482 m 447 1264 l 578 1264 l 453 1086 l 356 1086 l 447 1264 z "},"Л":{"ha":833,"x_min":28,"x_max":760,"o":"m 28 117 l 108 117 q 165 144 144 117 q 190 218 186 171 l 254 986 l 760 986 l 760 0 l 640 0 l 640 869 l 367 869 l 311 204 q 126 0 294 0 l 28 0 l 28 117 z "},"М":{"ha":833,"x_min":83,"x_max":750,"o":"m 203 826 l 203 0 l 83 0 l 83 986 l 264 986 l 417 300 l 569 986 l 750 986 l 750 0 l 631 0 l 631 826 l 469 111 l 364 111 l 203 826 z "},"Н":{"ha":833,"x_min":94,"x_max":739,"o":"m 94 986 l 214 986 l 214 553 l 619 553 l 619 986 l 739 986 l 739 0 l 619 0 l 619 439 l 214 439 l 214 0 l 94 0 l 94 986 z "},"О":{"ha":833,"x_min":53,"x_max":781,"o":"m 417 -22 q 147 111 242 -22 q 53 492 53 244 q 147 874 53 740 q 417 1008 242 1008 q 686 874 592 1008 q 781 492 781 740 q 686 111 781 244 q 417 -22 592 -22 m 417 94 q 594 197 532 94 q 656 492 656 299 q 594 788 656 685 q 417 892 532 892 q 240 788 301 892 q 178 492 178 685 q 240 197 178 299 q 417 94 301 94 z "},"П":{"ha":833,"x_min":97,"x_max":736,"o":"m 97 986 l 736 986 l 736 0 l 617 0 l 617 869 l 217 869 l 217 0 l 97 0 l 97 986 z "},"Р":{"ha":833,"x_min":125,"x_max":764,"o":"m 125 986 l 425 986 q 672 907 581 986 q 764 688 764 828 q 672 468 764 547 q 425 389 581 389 l 244 389 l 244 0 l 125 0 l 125 986 m 411 503 q 639 688 639 503 q 411 872 639 872 l 244 872 l 244 503 l 411 503 z "},"С":{"ha":833,"x_min":53,"x_max":772,"o":"m 419 -22 q 149 115 244 -22 q 53 492 53 253 q 149 871 53 733 q 419 1008 244 1008 q 642 923 550 1008 q 764 685 733 838 l 636 676 q 556 836 613 781 q 419 892 499 892 q 240 788 303 892 q 178 492 178 685 q 240 197 178 300 q 419 94 303 94 q 566 156 507 94 q 646 332 625 217 l 772 325 q 651 70 744 163 q 419 -22 558 -22 z "},"Т":{"ha":833,"x_min":56,"x_max":778,"o":"m 357 872 l 56 872 l 56 986 l 778 986 l 778 872 l 476 872 l 476 0 l 357 0 l 357 872 z "},"У":{"ha":833,"x_min":72,"x_max":769,"o":"m 196 117 l 281 117 q 326 128 308 117 q 353 168 343 140 l 388 264 l 340 264 l 72 986 l 204 986 l 425 375 l 639 986 l 769 986 l 456 115 q 394 28 435 56 q 292 0 354 0 l 196 0 l 196 117 z "},"Ў":{"ha":833,"x_min":72,"x_max":769,"o":"m 196 117 l 281 117 q 326 128 308 117 q 353 168 343 140 l 388 264 l 340 264 l 72 986 l 204 986 l 425 375 l 639 986 l 769 986 l 456 115 q 394 28 435 56 q 292 0 354 0 l 196 0 l 196 117 z "},"Ф":{"ha":833,"x_min":35,"x_max":799,"o":"m 356 106 q 120 226 206 124 q 35 493 35 329 q 120 760 35 657 q 356 881 206 863 l 356 986 l 475 986 l 475 882 q 713 760 626 863 q 799 493 799 657 q 713 226 799 329 q 475 104 626 124 l 475 0 l 356 0 l 356 106 m 160 493 q 212 313 160 383 q 356 224 264 242 l 356 763 q 212 674 264 744 q 160 493 160 603 m 475 224 q 621 312 568 240 q 674 493 674 383 q 621 674 674 603 q 475 763 568 746 l 475 224 z "},"Х":{"ha":833,"x_min":69,"x_max":764,"o":"m 342 496 l 75 986 l 214 986 l 418 588 l 622 986 l 758 986 l 492 494 l 764 0 l 625 0 l 415 404 l 206 0 l 69 0 l 342 496 z "},"Ч":{"ha":833,"x_min":69,"x_max":736,"o":"m 617 469 q 513 369 585 407 q 356 331 442 331 q 206 369 271 331 q 106 481 142 408 q 69 650 69 554 l 69 986 l 189 986 l 189 650 q 235 503 189 558 q 356 447 282 447 q 489 474 429 447 q 583 547 549 500 q 617 651 617 593 l 617 986 l 736 986 l 736 0 l 617 0 l 617 469 z "},"Ц":{"ha":833,"x_min":83,"x_max":813,"o":"m 203 986 l 203 117 l 603 117 l 603 986 l 722 986 l 722 117 l 813 117 l 813 -208 l 693 -208 l 693 0 l 83 0 l 83 986 l 203 986 z "},"Ш":{"ha":833,"x_min":76,"x_max":757,"o":"m 76 986 l 196 986 l 196 117 l 357 117 l 357 986 l 476 986 l 476 117 l 638 117 l 638 986 l 757 986 l 757 0 l 76 0 l 76 986 z "},"Щ":{"ha":833,"x_min":49,"x_max":813,"o":"m 49 986 l 168 986 l 168 117 l 329 117 l 329 986 l 449 986 l 449 117 l 610 117 l 610 986 l 729 986 l 729 117 l 813 117 l 813 -208 l 693 -208 l 693 0 l 49 0 l 49 986 z "},"Џ":{"ha":833,"x_min":97,"x_max":736,"o":"m 357 0 l 97 0 l 97 986 l 217 986 l 217 117 l 617 117 l 617 986 l 736 986 l 736 0 l 476 0 l 476 -208 l 357 -208 l 357 0 z "},"Ь":{"ha":833,"x_min":125,"x_max":764,"o":"m 244 986 l 244 597 l 425 597 q 672 518 581 597 q 764 299 764 439 q 672 79 764 158 q 425 0 581 0 l 125 0 l 125 986 l 244 986 m 244 483 l 244 114 l 411 114 q 639 299 639 114 q 411 483 639 483 l 244 483 z "},"Ы":{"ha":833,"x_min":49,"x_max":785,"o":"m 49 986 l 169 986 l 169 603 l 236 603 q 492 522 400 603 q 585 299 585 442 q 492 79 585 158 q 236 0 400 0 l 49 0 l 49 986 m 236 117 q 460 299 460 117 q 236 486 460 486 l 169 486 l 169 117 l 236 117 m 665 986 l 785 986 l 785 0 l 665 0 l 665 986 z "},"Ъ":{"ha":833,"x_min":42,"x_max":788,"o":"m 42 986 l 269 986 l 269 869 l 268 869 l 268 597 l 449 597 q 696 518 604 597 q 788 299 788 439 q 696 79 788 158 q 449 0 604 0 l 149 0 l 149 869 l 42 869 l 42 986 m 268 483 l 268 114 l 435 114 q 663 299 663 114 q 435 483 663 483 l 268 483 z "},"Љ":{"ha":833,"x_min":7,"x_max":826,"o":"m 7 117 l 46 117 q 104 144 83 117 q 128 218 125 171 l 164 986 l 507 986 l 508 603 l 547 603 q 753 522 681 603 q 826 299 826 442 q 753 78 826 157 q 547 0 681 0 l 388 0 l 388 869 l 276 869 l 249 204 q 199 54 244 108 q 64 0 153 0 l 7 0 l 7 117 m 535 117 q 652 162 615 117 q 689 299 689 207 q 652 440 689 393 q 535 486 615 486 l 508 486 l 508 117 l 535 117 z "},"Њ":{"ha":833,"x_min":28,"x_max":806,"o":"m 311 436 l 147 436 l 147 0 l 28 0 l 28 986 l 147 986 l 147 553 l 311 553 l 311 986 l 432 986 l 432 603 l 485 603 q 726 524 646 603 q 806 299 806 444 q 726 77 806 154 q 485 0 646 0 l 311 0 l 311 436 m 472 117 q 624 160 581 117 q 668 299 668 204 q 624 441 668 396 q 472 486 581 486 l 432 486 l 432 117 l 472 117 z "},"Ѕ":{"ha":833,"x_min":61,"x_max":772,"o":"m 432 -22 q 248 20 329 -22 q 118 139 167 63 q 61 315 69 215 l 186 324 q 265 154 200 214 q 435 94 331 94 q 592 133 538 94 q 647 244 647 172 q 625 328 647 294 q 544 393 603 363 q 371 456 485 424 q 203 521 265 486 q 113 605 142 556 q 85 728 85 654 q 124 874 85 810 q 238 973 164 938 q 413 1008 313 1008 q 649 922 560 1008 q 754 694 738 835 l 629 686 q 561 836 617 781 q 410 892 506 892 q 264 848 318 892 q 210 733 210 804 q 230 657 210 686 q 299 606 250 628 q 446 556 349 583 q 638 480 568 521 q 740 382 708 439 q 772 242 772 325 q 729 105 772 165 q 609 11 686 44 q 432 -22 532 -22 z "},"Є":{"ha":833,"x_min":53,"x_max":771,"o":"m 417 -22 q 147 111 242 -22 q 53 492 53 244 q 147 874 53 740 q 417 1008 242 1008 q 653 915 563 1008 q 771 642 743 821 l 643 642 q 564 827 622 763 q 417 892 506 892 q 250 803 311 892 q 179 550 189 715 l 479 550 l 479 433 l 179 433 q 250 182 189 269 q 417 94 311 94 q 564 159 506 94 q 644 344 622 224 l 771 344 q 653 72 744 167 q 417 -22 563 -22 z "},"Э":{"ha":833,"x_min":63,"x_max":781,"o":"m 417 -22 q 180 72 271 -22 q 63 344 89 167 l 189 344 q 269 159 211 224 q 417 94 328 94 q 583 182 522 94 q 654 433 644 269 l 354 433 l 354 550 l 654 550 q 583 803 644 715 q 417 892 522 892 q 269 827 328 892 q 190 642 211 763 l 63 642 q 181 915 90 821 q 417 1008 271 1008 q 686 874 592 1008 q 781 492 781 740 q 686 111 781 244 q 417 -22 592 -22 z "},"І":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 114 l 357 114 l 357 872 l 111 872 l 111 986 l 722 986 l 722 872 l 476 872 l 476 114 l 722 114 l 722 0 l 111 0 l 111 114 z "},"Ї":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 114 l 357 114 l 357 872 l 111 872 l 111 986 l 722 986 l 722 872 l 476 872 l 476 114 l 722 114 l 722 0 l 111 0 l 111 114 z "},"Ј":{"ha":833,"x_min":56,"x_max":708,"o":"m 382 -22 q 147 71 235 -22 q 56 319 60 164 l 172 331 q 228 152 176 210 q 382 94 279 94 q 589 331 589 94 l 589 986 l 708 986 l 708 331 q 620 72 708 167 q 382 -22 532 -22 z "},"Ћ":{"ha":833,"x_min":7,"x_max":792,"o":"m 167 869 l 7 869 l 7 986 l 489 986 l 489 869 l 286 869 l 286 524 q 373 619 314 583 q 506 656 432 656 q 655 617 590 656 q 756 505 719 578 q 792 336 792 432 l 792 0 l 672 0 l 672 336 q 622 482 672 425 q 492 539 571 539 q 344 482 401 539 q 286 335 286 425 l 286 0 l 167 0 l 167 869 z "},"Ю":{"ha":833,"x_min":28,"x_max":806,"o":"m 536 -22 q 340 88 404 -22 q 269 435 275 197 l 147 435 l 147 0 l 28 0 l 28 986 l 147 986 l 147 551 l 269 551 q 340 899 275 789 q 536 1008 404 1008 q 741 885 676 1008 q 806 492 806 761 q 741 101 806 224 q 536 -22 676 -22 m 536 94 q 620 134 589 94 q 666 260 651 174 q 681 492 681 347 q 666 724 681 638 q 620 851 651 811 q 536 892 589 892 q 453 852 485 892 q 408 725 422 813 q 394 492 394 638 q 408 260 394 346 q 453 134 422 174 q 536 94 485 94 z "},"Я":{"ha":833,"x_min":64,"x_max":735,"o":"m 615 0 l 615 397 l 438 397 l 421 397 l 196 0 l 64 0 l 299 415 q 142 513 197 442 q 88 688 88 585 q 180 907 88 828 q 438 986 272 986 l 735 986 l 735 0 l 615 0 m 615 514 l 615 869 l 438 869 q 271 823 329 869 q 213 690 213 776 q 271 560 213 606 q 438 514 329 514 l 615 514 z "},"Ђ":{"ha":833,"x_min":7,"x_max":819,"o":"m 167 869 l 7 869 l 7 986 l 489 986 l 489 869 l 286 869 l 286 525 q 369 619 313 583 q 496 656 426 656 q 663 615 589 656 q 778 500 736 574 q 819 332 819 426 q 771 156 819 231 q 633 40 722 81 q 422 0 543 0 l 422 117 q 622 174 550 117 q 694 332 694 232 q 640 483 694 428 q 496 539 586 539 q 344 482 403 539 q 286 335 286 425 l 286 0 l 167 0 l 167 869 z "},"Ѣ":{"ha":833,"x_min":21,"x_max":797,"o":"m 21 853 l 158 853 l 158 986 l 278 986 l 278 853 l 506 853 l 506 736 l 278 736 l 278 597 l 458 597 q 706 518 614 597 q 797 299 797 439 q 706 79 797 158 q 458 0 614 0 l 158 0 l 158 736 l 21 736 l 21 853 m 278 483 l 278 114 l 444 114 q 672 299 672 114 q 444 483 672 483 l 278 483 z "},"Ѫ":{"ha":833,"x_min":1,"x_max":832,"o":"m 146 401 q 208 503 169 469 q 292 540 246 538 l 89 869 l 89 986 l 744 986 l 744 869 l 542 540 q 686 401 638 533 l 832 0 l 704 0 l 582 344 q 549 406 565 389 q 506 424 532 424 l 476 424 l 476 0 l 357 0 l 357 424 l 328 424 q 283 406 301 424 q 250 344 265 389 l 129 0 l 1 0 l 146 401 m 417 563 l 606 869 l 228 869 l 417 563 z "},"Ѳ":{"ha":833,"x_min":53,"x_max":781,"o":"m 417 -22 q 147 111 242 -22 q 53 492 53 244 q 147 874 53 740 q 417 1008 242 1008 q 686 874 592 1008 q 781 492 781 740 q 686 111 781 244 q 417 -22 592 -22 m 417 94 q 575 169 515 94 q 650 388 635 244 q 622 386 640 386 q 544 399 589 386 q 399 449 499 411 q 297 476 350 468 q 203 481 243 485 l 178 476 q 242 192 179 290 q 417 94 304 94 m 189 599 q 224 600 200 600 q 308 592 264 600 q 389 569 353 583 q 534 517 493 529 q 607 504 575 504 q 642 507 617 504 l 656 508 q 591 793 653 694 q 417 892 529 892 q 258 817 318 892 q 183 599 199 742 l 189 599 z "},"Ѵ":{"ha":833,"x_min":19,"x_max":826,"o":"m 19 986 l 146 986 l 390 182 l 579 807 q 653 938 604 890 q 763 986 703 986 l 826 986 l 826 869 l 807 869 q 735 842 761 869 q 686 742 708 814 l 458 0 l 324 0 l 19 986 z "},"Җ":{"ha":833,"x_min":10,"x_max":819,"o":"m 700 0 l 667 0 l 457 465 l 457 0 l 343 0 l 343 465 l 133 0 l 10 0 l 232 493 l 22 986 l 147 986 l 343 522 l 343 986 l 457 986 l 457 522 l 653 986 l 778 986 l 568 493 l 746 97 l 819 97 l 819 -208 l 700 -208 l 700 0 z "},"Қ":{"ha":833,"x_min":67,"x_max":799,"o":"m 679 0 l 614 0 l 186 482 l 186 0 l 67 0 l 67 986 l 186 986 l 186 504 l 600 986 l 757 986 l 331 493 l 683 97 l 799 97 l 799 -208 l 679 -208 l 679 0 z "},"Ң":{"ha":833,"x_min":74,"x_max":813,"o":"m 693 0 l 599 0 l 599 439 l 193 439 l 193 0 l 74 0 l 74 986 l 193 986 l 193 553 l 599 553 l 599 986 l 718 986 l 718 97 l 813 97 l 813 -208 l 693 -208 l 693 0 z "},"Ү":{"ha":833,"x_min":61,"x_max":772,"o":"m 358 403 l 61 986 l 197 986 l 417 542 l 636 986 l 772 986 l 478 403 l 478 0 l 358 0 l 358 403 z "},"Ұ":{"ha":833,"x_min":60,"x_max":771,"o":"m 142 435 l 340 435 l 60 986 l 196 986 l 415 542 l 635 986 l 771 986 l 493 435 l 731 435 l 731 318 l 476 318 l 476 0 l 357 0 l 357 318 l 142 318 l 142 435 z "},"Ҳ":{"ha":833,"x_min":69,"x_max":806,"o":"m 686 0 l 625 0 l 415 404 l 206 0 l 69 0 l 342 496 l 75 986 l 214 986 l 418 588 l 622 986 l 758 986 l 492 494 l 710 97 l 806 97 l 806 -208 l 686 -208 l 686 0 z "},"Ҷ":{"ha":833,"x_min":49,"x_max":813,"o":"m 693 0 l 596 0 l 596 469 q 492 369 564 407 q 335 331 421 331 q 185 369 250 331 q 85 481 121 408 q 49 650 49 554 l 49 986 l 168 986 l 168 650 q 215 503 168 558 q 335 447 261 447 q 468 474 408 447 q 562 547 528 500 q 596 651 596 593 l 596 986 l 715 986 l 715 97 l 813 97 l 813 -208 l 693 -208 l 693 0 z "},"Һ":{"ha":833,"x_min":97,"x_max":764,"o":"m 217 517 q 320 617 249 579 q 478 656 392 656 q 627 617 563 656 q 728 505 692 578 q 764 336 764 432 l 764 0 l 644 0 l 644 336 q 598 483 644 428 q 478 539 551 539 q 344 513 404 539 q 251 440 285 486 q 217 335 217 393 l 217 0 l 97 0 l 97 986 l 217 986 l 217 517 z "},"Ӏ":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 114 l 357 114 l 357 872 l 111 872 l 111 986 l 722 986 l 722 872 l 476 872 l 476 114 l 722 114 l 722 0 l 111 0 l 111 114 z "},"Ә":{"ha":833,"x_min":39,"x_max":794,"o":"m 406 -22 q 218 40 300 -22 q 90 215 136 103 q 40 472 43 328 l 39 542 l 668 542 q 626 724 661 644 q 535 847 592 803 q 406 892 478 892 q 274 837 332 892 q 192 696 215 782 l 65 707 q 198 927 107 846 q 406 1008 289 1008 q 609 943 521 1008 q 746 760 697 878 q 794 493 794 643 q 744 232 794 350 q 604 46 693 114 q 406 -22 515 -22 m 406 94 q 576 180 510 94 q 667 425 643 265 l 168 425 q 248 180 188 265 q 406 94 308 94 z "},"Ӣ":{"ha":833,"x_min":97,"x_max":736,"o":"m 617 0 l 617 828 l 264 0 l 97 0 l 97 986 l 217 986 l 217 158 l 569 986 l 736 986 l 736 0 l 617 0 m 229 1218 l 601 1218 l 601 1118 l 229 1118 l 229 1218 z "},"Ө":{"ha":833,"x_min":53,"x_max":781,"o":"m 417 -22 q 147 111 242 -22 q 53 492 53 244 q 147 874 53 740 q 417 1008 242 1008 q 686 874 592 1008 q 781 492 781 740 q 686 111 781 244 q 417 -22 592 -22 m 417 94 q 584 181 524 94 q 654 435 644 268 l 179 435 q 249 181 189 268 q 417 94 310 94 m 654 551 q 583 804 644 717 q 417 892 522 892 q 250 804 311 892 q 179 551 189 717 l 654 551 z "},"Ӯ":{"ha":833,"x_min":72,"x_max":769,"o":"m 196 117 l 281 117 q 326 128 308 117 q 353 168 343 140 l 388 264 l 340 264 l 72 986 l 204 986 l 425 375 l 639 986 l 769 986 l 456 115 q 394 28 435 56 q 292 0 354 0 l 196 0 l 196 117 m 235 1218 l 607 1218 l 607 1118 l 235 1118 l 235 1218 z "},"а":{"ha":833,"x_min":75,"x_max":764,"o":"m 319 -17 q 144 39 214 -17 q 75 183 75 94 q 131 329 75 276 q 308 406 186 382 l 556 456 q 514 595 556 549 q 392 642 472 642 q 273 608 317 642 q 213 510 229 575 l 89 519 q 190 690 110 626 q 392 753 269 753 q 599 673 525 753 q 672 453 672 593 l 672 147 q 683 113 672 122 q 717 103 693 103 l 764 103 l 764 0 q 703 -3 742 -3 q 606 23 642 -3 q 560 104 571 49 q 464 17 529 50 q 319 -17 399 -17 m 331 86 q 498 142 440 86 q 556 292 556 197 l 556 356 l 331 311 q 227 269 257 297 q 197 194 197 242 q 233 115 197 143 q 331 86 268 86 z "},"б":{"ha":833,"x_min":81,"x_max":751,"o":"m 417 -22 q 294 3 350 -22 q 186 85 238 29 q 107 228 133 138 q 81 435 81 318 q 105 657 81 568 q 181 803 129 746 q 321 901 233 861 q 417 966 392 935 q 443 1056 443 997 l 563 1057 q 528 917 563 974 q 410 817 493 861 l 385 804 q 288 744 322 771 q 230 667 254 718 q 201 531 206 617 q 299 636 239 600 q 438 672 358 672 q 599 628 528 672 q 711 506 671 585 q 751 328 751 428 q 708 143 751 222 q 588 21 664 64 q 417 -22 513 -22 m 417 89 q 569 156 510 89 q 629 328 629 222 q 572 496 629 431 q 424 561 514 561 q 268 494 328 561 q 208 321 208 428 q 266 153 208 218 q 417 89 324 89 z "},"в":{"ha":833,"x_min":125,"x_max":739,"o":"m 125 736 l 431 736 q 633 681 558 736 q 708 532 708 625 q 667 431 708 471 q 551 381 625 392 q 689 325 639 371 q 739 208 739 279 q 667 56 739 113 q 471 0 594 0 l 125 0 l 125 736 m 471 103 q 575 131 536 103 q 614 208 614 160 q 575 292 614 261 q 471 322 536 322 l 242 322 l 242 103 l 471 103 m 431 425 q 542 454 500 425 q 583 532 583 483 q 542 606 583 579 q 431 633 500 633 l 242 633 l 242 425 l 431 425 z "},"г":{"ha":833,"x_min":207,"x_max":722,"o":"m 207 736 l 722 736 l 722 633 l 324 633 l 324 0 l 207 0 l 207 736 z "},"ѓ":{"ha":833,"x_min":207,"x_max":722,"o":"m 207 736 l 722 736 l 722 633 l 324 633 l 324 0 l 207 0 l 207 736 m 504 1014 l 635 1014 l 510 836 l 413 836 l 504 1014 z "},"ґ":{"ha":833,"x_min":207,"x_max":722,"o":"m 324 0 l 207 0 l 207 736 l 603 736 l 603 951 l 722 951 l 722 633 l 324 633 l 324 0 z "},"ғ":{"ha":833,"x_min":57,"x_max":722,"o":"m 57 425 l 207 425 l 207 736 l 722 736 l 722 633 l 324 633 l 324 425 l 482 425 l 482 322 l 324 322 l 324 0 l 207 0 l 207 322 l 57 322 l 57 425 z "},"д":{"ha":833,"x_min":28,"x_max":806,"o":"m 93 103 q 161 135 136 103 q 196 236 186 167 l 263 736 l 686 736 l 686 103 l 806 103 l 806 -208 l 689 -208 l 689 0 l 144 0 l 144 -208 l 28 -208 l 28 103 l 93 103 m 569 103 l 569 633 l 368 633 l 310 225 q 265 103 300 150 l 569 103 z "},"е":{"ha":833,"x_min":92,"x_max":750,"o":"m 429 -17 q 250 31 326 -17 q 133 165 174 78 q 92 368 92 251 q 133 571 92 483 q 249 706 174 658 q 424 753 324 753 q 594 708 519 753 q 709 575 668 663 q 750 367 750 488 l 750 332 l 214 332 q 276 155 219 215 q 429 94 332 94 q 547 127 500 94 q 613 218 593 160 l 738 208 q 626 45 708 107 q 429 -17 543 -17 m 622 435 q 561 590 614 538 q 424 642 508 642 q 283 589 338 642 q 214 435 228 536 l 622 435 z "},"ѐ":{"ha":833,"x_min":92,"x_max":750,"o":"m 429 -17 q 250 31 326 -17 q 133 165 174 78 q 92 368 92 251 q 133 571 92 483 q 249 706 174 658 q 424 753 324 753 q 594 708 519 753 q 709 575 668 663 q 750 367 750 488 l 750 332 l 214 332 q 276 155 219 215 q 429 94 332 94 q 547 127 500 94 q 613 218 593 160 l 738 208 q 626 45 708 107 q 429 -17 543 -17 m 622 435 q 561 590 614 538 q 424 642 508 642 q 283 589 338 642 q 214 435 228 536 l 622 435 m 258 1011 l 389 1011 l 481 833 l 383 833 l 258 1011 z "},"ё":{"ha":833,"x_min":92,"x_max":750,"o":"m 429 -17 q 250 31 326 -17 q 133 165 174 78 q 92 368 92 251 q 133 571 92 483 q 249 706 174 658 q 424 753 324 753 q 594 708 519 753 q 709 575 668 663 q 750 367 750 488 l 750 332 l 214 332 q 276 155 219 215 q 429 94 332 94 q 547 127 500 94 q 613 218 593 160 l 738 208 q 626 45 708 107 q 429 -17 543 -17 m 622 435 q 561 590 614 538 q 424 642 508 642 q 283 589 338 642 q 214 435 228 536 l 622 435 z "},"ж":{"ha":833,"x_min":15,"x_max":818,"o":"m 233 368 l 24 736 l 157 736 l 358 383 l 358 736 l 475 736 l 475 383 l 676 736 l 810 736 l 600 368 l 818 0 l 685 0 l 475 354 l 475 0 l 358 0 l 358 354 l 149 0 l 15 0 l 233 368 z "},"з":{"ha":833,"x_min":111,"x_max":715,"o":"m 414 -17 q 198 43 279 -17 q 111 206 117 103 l 233 211 q 286 126 238 158 q 414 94 335 94 q 544 125 496 94 q 593 207 593 156 q 548 301 593 267 q 428 335 503 336 l 351 333 l 351 444 l 428 443 q 530 470 492 442 q 568 546 568 499 q 526 616 568 590 q 414 642 485 642 q 310 618 350 642 q 264 554 269 594 l 138 561 q 222 701 147 650 q 414 753 297 753 q 616 697 542 753 q 690 546 690 642 q 651 455 690 494 q 543 396 613 415 q 669 320 624 372 q 715 200 715 268 q 634 42 715 100 q 414 -17 553 -17 z "},"и":{"ha":833,"x_min":131,"x_max":704,"o":"m 131 736 l 247 736 l 247 178 l 567 736 l 704 736 l 704 0 l 588 0 l 588 547 l 275 0 l 131 0 l 131 736 z "},"й":{"ha":833,"x_min":131,"x_max":704,"o":"m 131 736 l 247 736 l 247 178 l 567 736 l 704 736 l 704 0 l 588 0 l 588 547 l 275 0 l 131 0 l 131 736 m 418 838 q 281 885 333 838 q 224 1015 228 933 l 304 1015 q 418 924 317 924 q 532 1015 519 924 l 613 1015 q 556 885 608 933 q 418 838 503 838 z "},"ѝ":{"ha":833,"x_min":131,"x_max":704,"o":"m 131 736 l 247 736 l 247 178 l 567 736 l 704 736 l 704 0 l 588 0 l 588 547 l 275 0 l 131 0 l 131 736 m 251 1014 l 382 1014 l 474 836 l 376 836 l 251 1014 z "},"к":{"ha":833,"x_min":126,"x_max":772,"o":"m 246 358 l 246 0 l 126 0 l 126 736 l 246 736 l 246 378 l 592 736 l 756 736 l 393 368 l 772 0 l 606 0 l 246 358 z "},"ќ":{"ha":833,"x_min":126,"x_max":772,"o":"m 246 358 l 246 0 l 126 0 l 126 736 l 246 736 l 246 378 l 592 736 l 756 736 l 393 368 l 772 0 l 606 0 l 246 358 m 457 1014 l 588 1014 l 463 836 l 365 836 l 457 1014 z "},"л":{"ha":833,"x_min":35,"x_max":736,"o":"m 35 117 l 103 117 q 183 217 172 117 l 239 736 l 736 736 l 736 0 l 619 0 l 619 633 l 346 633 l 300 204 q 244 51 289 101 q 119 0 200 0 l 35 0 l 35 117 z "},"м":{"ha":833,"x_min":50,"x_max":783,"o":"m 64 736 l 218 736 l 425 143 l 631 736 l 783 736 l 783 0 l 667 0 l 667 479 l 497 0 l 351 0 l 178 486 l 167 0 l 50 0 l 64 736 z "},"н":{"ha":833,"x_min":126,"x_max":707,"o":"m 126 736 l 243 736 l 243 418 l 590 418 l 590 736 l 707 736 l 707 0 l 590 0 l 590 315 l 243 315 l 243 0 l 126 0 l 126 736 z "},"о":{"ha":833,"x_min":81,"x_max":753,"o":"m 417 -17 q 240 31 315 -17 q 122 165 164 78 q 81 368 81 251 q 122 572 81 485 q 240 706 164 658 q 417 753 315 753 q 593 706 517 753 q 711 572 669 658 q 753 368 753 485 q 711 165 753 251 q 593 31 669 78 q 417 -17 517 -17 m 417 94 q 574 167 518 94 q 631 368 631 240 q 574 569 631 496 q 417 642 518 642 q 259 569 315 642 q 203 368 203 497 q 259 167 203 239 q 417 94 315 94 z "},"п":{"ha":833,"x_min":125,"x_max":708,"o":"m 125 736 l 708 736 l 708 0 l 592 0 l 592 633 l 242 633 l 242 0 l 125 0 l 125 736 z "},"р":{"ha":833,"x_min":111,"x_max":764,"o":"m 111 736 l 219 736 l 221 618 q 313 718 254 683 q 447 753 372 753 q 624 701 553 753 q 729 561 694 649 q 764 368 764 474 q 729 175 764 263 q 624 35 694 88 q 447 -17 553 -17 q 316 14 375 -17 q 228 97 257 44 l 228 -208 l 111 -208 l 111 736 m 436 94 q 587 167 532 94 q 642 368 642 239 q 587 569 642 497 q 436 642 532 642 q 283 572 339 642 q 228 368 228 501 q 283 165 228 235 q 436 94 338 94 z "},"с":{"ha":833,"x_min":97,"x_max":750,"o":"m 436 -17 q 258 31 335 -17 q 139 165 181 78 q 97 368 97 253 q 139 571 97 483 q 258 706 181 658 q 436 753 335 753 q 641 686 560 753 q 744 497 722 619 l 622 489 q 556 601 606 561 q 436 642 507 642 q 277 569 335 642 q 219 368 219 496 q 277 167 219 240 q 436 94 335 94 q 560 138 510 94 q 628 261 611 181 l 750 253 q 642 56 726 129 q 436 -17 558 -17 z "},"т":{"ha":833,"x_min":93,"x_max":742,"o":"m 358 633 l 93 633 l 93 736 l 742 736 l 742 633 l 476 633 l 476 0 l 358 0 l 358 633 z "},"у":{"ha":833,"x_min":75,"x_max":764,"o":"m 192 -106 l 276 -106 q 333 -94 314 -106 q 363 -56 351 -82 l 389 14 l 349 14 l 75 736 l 203 736 l 426 122 l 636 736 l 764 736 l 465 -93 q 403 -181 444 -153 q 288 -208 361 -208 l 192 -208 l 192 -106 z "},"ў":{"ha":833,"x_min":75,"x_max":764,"o":"m 192 -106 l 276 -106 q 333 -94 314 -106 q 363 -56 351 -82 l 389 14 l 349 14 l 75 736 l 203 736 l 426 122 l 636 736 l 764 736 l 465 -93 q 403 -181 444 -153 q 288 -208 361 -208 l 192 -208 l 192 -106 z "},"ф":{"ha":833,"x_min":32,"x_max":800,"o":"m 358 38 q 188 94 261 49 q 73 210 114 139 q 32 372 32 282 q 73 534 32 463 q 188 651 114 606 q 358 707 261 696 l 358 986 l 476 986 l 476 706 q 646 649 572 694 q 760 533 719 604 q 800 372 800 461 q 760 212 800 283 q 646 95 719 140 q 476 39 572 50 l 476 -208 l 358 -208 l 358 38 m 157 372 q 210 226 157 283 q 358 156 264 169 l 358 589 q 210 518 264 575 q 157 372 157 461 m 476 156 q 622 228 569 171 q 675 372 675 285 q 622 517 675 460 q 476 589 569 574 l 476 156 z "},"х":{"ha":833,"x_min":75,"x_max":758,"o":"m 346 378 l 89 736 l 225 736 l 418 458 l 607 736 l 746 736 l 490 375 l 758 0 l 622 0 l 418 297 l 214 0 l 75 0 l 346 378 z "},"ч":{"ha":833,"x_min":96,"x_max":708,"o":"m 592 357 q 507 257 565 294 q 375 219 449 219 q 172 306 247 219 q 96 539 96 392 l 96 736 l 213 736 l 213 539 q 257 387 213 443 q 375 331 301 331 q 533 387 475 331 q 592 540 592 443 l 592 736 l 708 736 l 708 0 l 592 0 l 592 357 z "},"ц":{"ha":833,"x_min":125,"x_max":811,"o":"m 242 736 l 242 103 l 592 103 l 592 736 l 708 736 l 708 103 l 811 103 l 811 -208 l 694 -208 l 694 0 l 125 0 l 125 736 l 242 736 z "},"ш":{"ha":833,"x_min":78,"x_max":754,"o":"m 78 736 l 194 736 l 194 103 l 357 103 l 357 736 l 475 736 l 475 103 l 638 103 l 638 736 l 754 736 l 754 0 l 78 0 l 78 736 z "},"щ":{"ha":833,"x_min":78,"x_max":819,"o":"m 78 736 l 194 736 l 194 103 l 357 103 l 357 736 l 475 736 l 475 103 l 638 103 l 638 736 l 754 736 l 754 103 l 819 103 l 819 -208 l 703 -208 l 703 0 l 78 0 l 78 736 z "},"џ":{"ha":833,"x_min":125,"x_max":708,"o":"m 358 0 l 125 0 l 125 736 l 242 736 l 242 103 l 592 103 l 592 736 l 708 736 l 708 0 l 475 0 l 475 -208 l 358 -208 l 358 0 z "},"ь":{"ha":833,"x_min":113,"x_max":746,"o":"m 113 736 l 229 736 l 229 465 l 424 465 q 663 405 579 465 q 746 232 746 344 q 663 60 746 121 q 424 0 579 0 l 113 0 l 113 736 m 424 104 q 624 232 624 104 q 424 363 624 363 l 229 363 l 229 104 l 424 104 z "},"ы":{"ha":833,"x_min":49,"x_max":785,"o":"m 49 736 l 165 736 l 165 465 l 275 465 q 514 405 431 465 q 597 232 597 344 q 514 60 597 121 q 275 0 431 0 l 49 0 l 49 736 m 275 104 q 475 232 475 104 q 275 363 475 363 l 165 363 l 165 104 l 275 104 m 668 736 l 785 736 l 785 0 l 668 0 l 668 736 z "},"ъ":{"ha":833,"x_min":14,"x_max":767,"o":"m 14 736 l 250 736 l 250 465 l 444 465 q 683 405 600 465 q 767 232 767 344 q 683 60 767 121 q 444 0 600 0 l 133 0 l 133 633 l 14 633 l 14 736 m 444 104 q 644 232 644 104 q 444 363 644 363 l 250 363 l 250 104 l 444 104 z "},"љ":{"ha":833,"x_min":7,"x_max":826,"o":"m 7 117 l 19 117 q 100 217 89 117 l 156 736 l 496 736 l 496 465 l 574 465 q 761 405 696 465 q 826 232 826 344 q 761 60 826 121 q 574 0 696 0 l 378 0 l 378 633 l 263 633 l 217 204 q 161 51 206 101 q 36 0 117 0 l 7 0 l 7 117 m 574 104 q 670 138 636 104 q 704 232 704 171 q 670 328 704 294 q 574 363 636 363 l 496 363 l 496 104 l 574 104 z "},"њ":{"ha":833,"x_min":28,"x_max":813,"o":"m 364 321 l 144 321 l 144 0 l 28 0 l 28 736 l 144 736 l 144 424 l 364 424 l 364 736 l 481 736 l 481 465 l 560 465 q 747 405 682 465 q 813 232 813 344 q 747 60 813 121 q 560 0 682 0 l 364 0 l 364 321 m 560 104 q 656 138 622 104 q 690 232 690 171 q 656 328 690 294 q 560 363 622 363 l 481 363 l 481 104 l 560 104 z "},"ѕ":{"ha":833,"x_min":111,"x_max":722,"o":"m 431 -17 q 267 16 338 -17 q 157 105 197 49 q 111 232 117 161 l 233 240 q 294 133 244 171 q 431 94 344 94 q 558 119 514 94 q 603 192 603 143 q 588 245 603 225 q 531 280 572 265 q 408 308 490 294 q 242 356 303 326 q 156 426 182 385 q 131 531 131 468 q 204 691 131 629 q 410 753 278 753 q 615 686 539 753 q 708 517 690 619 l 586 508 q 527 606 574 569 q 408 642 481 642 q 290 611 331 642 q 250 531 250 581 q 268 472 250 493 q 324 437 286 450 q 433 411 363 424 q 610 365 547 393 q 697 297 672 338 q 722 192 722 256 q 640 40 722 96 q 431 -17 558 -17 z "},"є":{"ha":833,"x_min":85,"x_max":747,"o":"m 422 -17 q 244 31 321 -17 q 126 165 168 78 q 85 368 85 251 q 126 572 85 485 q 244 706 168 658 q 422 753 321 753 q 633 680 547 753 q 747 479 719 607 l 622 479 q 549 599 601 557 q 422 642 497 642 q 276 583 331 642 q 210 419 221 525 l 456 419 l 456 317 l 210 317 q 276 153 221 211 q 422 94 331 94 q 549 136 497 94 q 621 253 600 178 l 746 253 q 633 55 718 126 q 422 -17 547 -17 z "},"э":{"ha":833,"x_min":86,"x_max":749,"o":"m 411 -17 q 201 55 286 -17 q 88 253 115 126 l 213 253 q 285 136 233 178 q 411 94 336 94 q 558 153 503 94 q 624 317 613 211 l 378 317 l 378 419 l 624 419 q 558 583 613 525 q 411 642 503 642 q 284 599 336 642 q 211 479 232 557 l 86 479 q 200 680 114 607 q 411 753 286 753 q 589 706 513 753 q 707 572 665 658 q 749 368 749 485 q 707 165 749 251 q 589 31 665 78 q 411 -17 513 -17 z "},"і":{"ha":833,"x_min":111,"x_max":778,"o":"m 111 103 l 407 103 l 407 633 l 125 633 l 125 736 l 524 736 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 m 396 988 l 518 988 l 518 851 l 396 851 l 396 988 z "},"ї":{"ha":833,"x_min":111,"x_max":778,"o":"m 111 103 l 407 103 l 407 633 l 125 633 l 125 736 l 524 736 l 524 103 l 778 103 l 778 0 l 111 0 l 111 103 z "},"ј":{"ha":833,"x_min":153,"x_max":611,"o":"m 153 -106 l 413 -106 q 472 -76 450 -106 q 494 0 494 -46 l 494 633 l 167 633 l 167 736 l 611 736 l 611 0 q 563 -152 611 -96 q 425 -208 515 -208 l 153 -208 l 153 -106 m 489 988 l 611 988 l 611 851 l 489 851 l 489 988 z "},"ћ":{"ha":833,"x_min":35,"x_max":722,"o":"m 35 914 l 139 914 l 139 986 l 256 986 l 256 914 l 388 914 l 388 811 l 256 811 l 256 621 q 341 719 283 686 q 476 753 399 753 q 658 676 593 753 q 722 474 722 600 l 722 0 l 606 0 l 606 440 q 451 650 606 650 q 308 594 361 650 q 256 439 256 539 l 256 0 l 139 0 l 139 811 l 35 811 l 35 914 z "},"ю":{"ha":833,"x_min":28,"x_max":792,"o":"m 508 -17 q 310 69 382 -17 q 228 317 239 156 l 144 317 l 144 0 l 28 0 l 28 736 l 144 736 l 144 419 l 228 419 q 310 667 239 581 q 508 753 382 753 q 718 653 644 753 q 792 368 792 553 q 718 83 792 183 q 508 -17 644 -17 m 508 94 q 628 166 586 94 q 669 368 669 238 q 628 570 669 499 q 508 642 586 642 q 390 571 432 642 q 349 368 349 500 q 390 165 349 236 q 508 94 432 94 z "},"я":{"ha":833,"x_min":113,"x_max":708,"o":"m 292 306 q 167 389 211 335 q 124 514 124 443 q 200 678 124 621 q 418 736 276 736 l 708 736 l 708 0 l 592 0 l 592 285 l 417 285 l 249 0 l 113 0 l 292 306 m 592 388 l 592 635 l 418 635 q 292 603 336 635 q 249 511 249 571 q 292 419 249 451 q 418 388 336 388 l 592 388 z "},"ђ":{"ha":833,"x_min":35,"x_max":717,"o":"m 132 811 l 35 811 l 35 914 l 132 914 l 132 986 l 249 986 l 249 914 l 388 914 l 388 811 l 249 811 l 249 636 q 338 724 279 694 q 468 753 396 753 q 649 677 582 753 q 717 474 717 601 l 717 -4 q 669 -154 717 -100 q 531 -208 621 -208 l 460 -208 l 460 -106 l 517 -106 q 578 -78 556 -106 q 600 -4 600 -51 l 600 432 q 443 642 600 642 q 301 587 354 642 q 249 432 249 532 l 249 0 l 132 0 l 132 811 z "},"ѣ":{"ha":833,"x_min":14,"x_max":811,"o":"m 178 736 l 14 736 l 14 853 l 178 853 l 178 986 l 294 986 l 294 853 l 457 853 l 457 736 l 294 736 l 294 493 l 489 493 q 728 429 644 493 q 811 246 811 365 q 728 64 811 128 q 489 0 644 0 l 178 0 l 178 736 m 489 104 q 637 141 585 104 q 689 246 689 178 q 637 353 689 315 q 489 390 585 390 l 294 390 l 294 104 l 489 104 z "},"ѫ":{"ha":833,"x_min":19,"x_max":814,"o":"m 135 299 q 192 371 153 344 q 278 401 231 397 l 60 633 l 60 736 l 775 736 l 775 633 l 557 401 q 642 370 604 396 q 699 299 681 344 l 814 0 l 681 0 l 596 246 q 563 291 585 279 q 506 303 542 303 l 476 303 l 476 0 l 357 0 l 357 303 l 326 303 q 270 291 292 303 q 238 246 249 279 l 153 0 l 19 0 l 135 299 m 418 404 l 633 633 l 201 633 l 418 404 z "},"ѳ":{"ha":833,"x_min":81,"x_max":753,"o":"m 417 -17 q 240 31 315 -17 q 122 165 164 78 q 81 368 81 251 q 122 572 81 485 q 240 706 164 658 q 417 753 315 753 q 593 706 517 753 q 711 572 669 658 q 753 368 753 485 q 711 165 753 251 q 593 31 669 78 q 417 -17 517 -17 m 417 94 q 547 139 494 94 q 619 264 599 183 l 601 263 q 521 272 558 263 q 390 324 483 282 q 309 349 353 342 q 228 356 265 357 q 203 353 219 356 q 262 163 206 232 q 417 94 318 94 m 217 457 q 301 452 256 460 q 382 428 346 444 q 486 383 451 396 q 540 367 521 369 q 590 365 560 364 q 631 372 610 365 q 573 570 629 499 q 417 642 517 642 q 281 594 333 642 q 211 457 228 546 l 217 457 z "},"ѵ":{"ha":833,"x_min":51,"x_max":806,"o":"m 51 736 l 176 736 l 393 122 l 550 569 q 636 693 578 650 q 774 736 694 736 l 806 736 l 806 633 l 792 633 q 660 531 697 633 l 467 0 l 319 0 l 51 736 z "},"җ":{"ha":833,"x_min":15,"x_max":819,"o":"m 703 0 l 685 0 l 475 354 l 475 0 l 358 0 l 358 354 l 149 0 l 15 0 l 233 368 l 24 736 l 157 736 l 358 383 l 358 736 l 475 736 l 475 383 l 676 736 l 810 736 l 600 368 l 757 103 l 819 103 l 819 -208 l 703 -208 l 703 0 z "},"қ":{"ha":833,"x_min":125,"x_max":778,"o":"m 661 0 l 604 0 l 244 358 l 244 0 l 125 0 l 125 736 l 244 736 l 244 378 l 590 736 l 754 736 l 392 368 l 665 103 l 778 103 l 778 -208 l 661 -208 l 661 0 z "},"ң":{"ha":833,"x_min":126,"x_max":803,"o":"m 686 0 l 590 0 l 590 315 l 243 315 l 243 0 l 126 0 l 126 736 l 243 736 l 243 418 l 590 418 l 590 736 l 707 736 l 707 103 l 803 103 l 803 -208 l 686 -208 l 686 0 z "},"ү":{"ha":833,"x_min":75,"x_max":758,"o":"m 75 736 l 200 736 l 417 140 l 633 736 l 758 736 l 475 0 l 475 -208 l 358 -208 l 358 0 l 75 736 z "},"ұ":{"ha":833,"x_min":75,"x_max":758,"o":"m 194 51 l 339 51 l 75 736 l 200 736 l 417 140 l 633 736 l 758 736 l 494 51 l 638 51 l 638 -51 l 475 -51 l 475 -208 l 358 -208 l 358 -51 l 194 -51 l 194 51 z "},"ҳ":{"ha":833,"x_min":75,"x_max":790,"o":"m 674 0 l 622 0 l 418 297 l 214 0 l 75 0 l 346 378 l 89 736 l 225 736 l 418 458 l 607 736 l 746 736 l 490 375 l 685 103 l 790 103 l 790 -208 l 674 -208 l 674 0 z "},"ҷ":{"ha":833,"x_min":96,"x_max":804,"o":"m 688 0 l 592 0 l 592 357 q 507 257 565 294 q 375 219 449 219 q 172 306 247 219 q 96 539 96 392 l 96 736 l 213 736 l 213 539 q 257 387 213 443 q 375 331 301 331 q 533 387 475 331 q 592 540 592 443 l 592 736 l 708 736 l 708 103 l 804 103 l 804 -208 l 688 -208 l 688 0 z "},"һ":{"ha":833,"x_min":139,"x_max":722,"o":"m 139 986 l 256 986 l 256 621 q 341 719 283 686 q 476 753 399 753 q 658 676 593 753 q 722 474 722 600 l 722 0 l 606 0 l 606 440 q 451 650 606 650 q 308 594 361 650 q 256 439 256 539 l 256 0 l 139 0 l 139 986 z "},"ӏ":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 114 l 357 114 l 357 872 l 111 872 l 111 986 l 722 986 l 722 872 l 476 872 l 476 114 l 722 114 l 722 0 l 111 0 l 111 114 z "},"ә":{"ha":833,"x_min":83,"x_max":742,"o":"m 404 753 q 583 706 507 753 q 701 572 660 658 q 742 368 742 485 q 701 165 742 253 q 585 31 660 78 q 410 -17 510 -17 q 240 28 314 -17 q 124 161 165 74 q 83 369 83 249 l 83 404 l 619 404 q 558 581 614 521 q 404 642 501 642 q 287 609 333 642 q 221 518 240 576 l 96 528 q 208 691 125 629 q 404 753 290 753 m 211 301 q 272 147 219 199 q 410 94 325 94 q 551 147 496 94 q 619 301 606 200 l 211 301 z "},"ӣ":{"ha":833,"x_min":131,"x_max":704,"o":"m 131 736 l 247 736 l 247 178 l 567 736 l 704 736 l 704 0 l 588 0 l 588 547 l 275 0 l 131 0 l 131 736 m 231 968 l 603 968 l 603 868 l 231 868 l 231 968 z "},"ө":{"ha":833,"x_min":81,"x_max":753,"o":"m 417 -17 q 240 31 315 -17 q 122 165 164 78 q 81 368 81 251 q 122 572 81 485 q 240 706 164 658 q 417 753 315 753 q 593 706 517 753 q 711 572 669 658 q 753 368 753 485 q 711 165 753 251 q 593 31 669 78 q 417 -17 517 -17 m 417 94 q 562 153 507 94 q 628 317 617 211 l 206 317 q 271 153 217 211 q 417 94 325 94 m 628 419 q 562 583 617 525 q 417 642 507 642 q 271 583 325 642 q 206 419 217 525 l 628 419 z "},"ӯ":{"ha":833,"x_min":75,"x_max":764,"o":"m 192 -106 l 276 -106 q 333 -94 314 -106 q 363 -56 351 -82 l 389 14 l 349 14 l 75 736 l 203 736 l 426 122 l 636 736 l 764 736 l 465 -93 q 403 -181 444 -153 q 288 -208 361 -208 l 192 -208 l 192 -106 m 233 968 l 606 968 l 606 868 l 233 868 l 233 968 z "},"Λ":{"ha":833,"x_min":36,"x_max":797,"o":"m 336 986 l 497 986 l 797 0 l 675 0 l 417 882 l 158 0 l 36 0 l 336 986 z "},"Ω":{"ha":833,"x_min":13,"x_max":821,"o":"m 15 117 l 199 117 q 65 281 117 172 q 13 506 13 390 q 64 766 13 651 q 207 944 115 881 q 417 1008 299 1008 q 626 944 535 1008 q 769 766 718 881 q 821 506 821 651 q 769 281 821 390 q 635 117 717 172 l 818 117 l 818 0 l 472 0 l 472 117 q 585 169 533 117 q 666 310 636 221 q 696 506 696 399 q 660 706 696 618 q 561 842 625 793 q 417 892 497 892 q 272 842 336 892 q 173 706 208 793 q 138 506 138 618 q 167 310 138 399 q 249 169 197 221 q 361 117 300 117 l 361 0 l 15 0 l 15 117 z "},"λ":{"ha":833,"x_min":82,"x_max":764,"o":"m 717 0 q 587 41 632 0 q 510 186 542 82 l 406 525 l 204 0 l 82 0 l 354 693 l 319 806 q 288 865 307 846 q 233 883 269 883 l 146 883 l 146 986 l 224 986 q 360 944 311 986 q 440 800 408 901 l 631 181 q 662 122 643 140 q 715 103 681 103 l 764 103 l 764 0 l 717 0 z "},"π":{"ha":833,"x_min":22,"x_max":808,"o":"m 763 0 q 602 53 654 0 q 550 203 550 107 l 550 633 l 288 633 l 288 0 l 171 0 l 171 633 l 22 633 l 22 736 l 808 736 l 808 633 l 667 633 l 667 203 q 691 129 667 156 q 761 103 715 103 l 806 103 l 806 0 l 763 0 z "},"ℇ":{"ha":833,"x_min":71,"x_max":781,"o":"m 426 -22 q 241 15 322 -22 q 115 119 160 53 q 71 271 71 186 q 124 435 71 368 q 279 533 176 503 q 104 751 104 586 q 145 885 104 826 q 259 976 186 943 q 426 1008 332 1008 q 608 971 518 1008 q 749 875 697 933 l 671 779 q 558 864 626 831 q 426 897 489 897 q 280 858 333 897 q 226 751 226 818 q 286 619 226 663 q 426 578 346 576 l 501 579 l 501 469 l 426 471 q 257 420 321 472 q 193 281 193 368 q 257 142 193 194 q 426 89 321 89 q 578 126 499 89 q 706 221 657 163 l 781 122 q 628 18 726 58 q 426 -22 529 -22 z "},"⓿":{"ha":833,"x_min":21,"x_max":814,"o":"m 417 206 q 263 283 319 206 q 207 493 207 361 q 263 705 207 626 q 417 783 319 783 q 570 705 514 783 q 626 493 626 626 q 570 283 626 361 q 417 206 514 206 m 417 288 q 509 342 476 288 q 542 493 542 396 q 529 601 542 556 l 349 313 q 417 288 376 288 m 292 493 q 304 389 292 433 l 485 676 q 417 701 458 701 q 324 647 357 701 q 292 493 292 593 m 418 -22 q 628 40 539 -22 q 766 219 718 103 q 814 493 814 336 q 766 767 814 650 q 628 946 718 883 q 418 1008 539 1008 q 208 946 297 1008 q 69 767 118 883 q 21 493 21 650 q 69 219 21 336 q 208 40 118 103 q 418 -22 297 -22 z "},"❶":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 628 40 539 -22 q 766 219 718 103 q 814 493 814 336 q 766 767 814 650 q 628 946 718 883 q 418 1008 539 1008 q 208 946 297 1008 q 69 767 118 883 q 21 493 21 650 q 69 219 21 336 q 208 40 118 103 q 418 -22 297 -22 m 386 579 l 249 578 l 249 639 l 282 639 q 374 671 342 639 q 406 763 406 703 l 471 763 l 471 224 l 386 224 l 386 579 z "},"❷":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 628 40 539 -22 q 766 219 718 103 q 814 493 814 336 q 766 767 814 650 q 628 946 718 883 q 418 1008 539 1008 q 208 946 297 1008 q 69 767 118 883 q 21 493 21 650 q 69 219 21 336 q 208 40 118 103 q 418 -22 297 -22 m 232 222 q 267 362 232 297 q 396 489 301 426 l 440 518 q 494 568 478 543 q 510 624 510 593 q 486 678 510 657 q 429 700 463 700 q 325 600 344 700 l 242 604 q 302 732 251 685 q 429 779 353 779 q 549 735 503 779 q 594 624 594 692 q 561 526 594 575 q 464 438 528 478 l 425 413 q 333 301 335 351 l 599 301 l 599 222 l 232 222 z "},"❸":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 628 40 539 -22 q 766 219 718 103 q 814 493 814 336 q 766 767 814 650 q 628 946 718 883 q 418 1008 539 1008 q 208 946 297 1008 q 69 767 118 883 q 21 493 21 650 q 69 219 21 336 q 208 40 118 103 q 418 -22 297 -22 m 418 206 q 283 251 339 206 q 222 361 226 296 l 307 365 q 342 308 310 332 q 418 285 375 285 q 490 311 461 285 q 519 375 519 338 q 488 444 519 415 q 411 474 457 474 l 376 474 l 376 553 l 411 553 q 475 576 449 553 q 501 626 501 599 q 476 682 501 658 q 417 706 451 706 q 355 688 381 706 q 326 646 329 671 l 242 651 q 301 747 250 708 q 417 785 351 785 q 538 743 489 785 q 586 640 586 701 q 562 565 586 600 q 494 518 538 531 q 575 461 543 503 q 607 369 607 419 q 552 253 607 301 q 418 206 497 206 z "},"❹":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 628 40 539 -22 q 766 219 718 103 q 814 493 814 336 q 766 767 814 650 q 628 946 718 883 q 418 1008 539 1008 q 208 946 297 1008 q 69 767 118 883 q 21 493 21 650 q 69 219 21 336 q 208 40 118 103 q 418 -22 297 -22 m 444 329 l 183 329 l 183 399 l 458 763 l 529 763 l 529 408 l 590 408 l 590 329 l 529 329 l 529 224 l 444 224 l 444 329 m 444 408 l 444 611 l 292 408 l 444 408 z "},"❺":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 628 40 539 -22 q 766 219 718 103 q 814 493 814 336 q 766 767 814 650 q 628 946 718 883 q 418 1008 539 1008 q 208 946 297 1008 q 69 767 118 883 q 21 493 21 650 q 69 219 21 336 q 208 40 118 103 q 418 -22 297 -22 m 417 201 q 288 244 340 201 q 229 356 235 288 l 317 360 q 353 303 324 325 q 419 281 382 281 q 490 313 461 281 q 518 393 518 344 q 490 474 518 440 q 419 507 461 507 q 367 495 390 507 q 322 450 343 483 l 239 450 l 282 764 l 568 764 l 568 685 l 356 685 l 338 543 q 381 574 358 564 q 432 583 403 583 q 556 528 507 583 q 606 393 606 474 q 553 255 606 308 q 417 201 500 201 z "},"❻":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 628 40 539 -22 q 766 219 718 103 q 814 493 814 336 q 766 767 814 650 q 628 946 718 883 q 418 1008 539 1008 q 208 946 297 1008 q 69 767 118 883 q 21 493 21 650 q 69 219 21 336 q 208 40 118 103 q 418 -22 297 -22 m 418 206 q 278 279 326 206 q 229 461 229 353 q 280 690 229 597 q 433 782 331 782 q 538 748 492 782 q 603 651 585 714 l 521 643 q 488 688 511 672 q 433 703 465 703 q 351 655 382 703 q 310 535 319 607 q 433 594 356 594 q 556 541 507 594 q 604 401 604 488 q 551 259 604 313 q 418 206 499 206 m 419 285 q 490 317 464 285 q 517 401 517 350 q 492 484 517 453 q 421 515 467 515 q 351 482 379 515 q 322 399 322 449 q 349 317 322 349 q 419 285 376 285 z "},"❼":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 628 40 539 -22 q 766 219 718 103 q 814 493 814 336 q 766 767 814 650 q 628 946 718 883 q 418 1008 539 1008 q 208 946 297 1008 q 69 767 118 883 q 21 493 21 650 q 69 219 21 336 q 208 40 118 103 q 418 -22 297 -22 m 344 224 q 388 464 344 347 q 510 683 431 581 l 231 683 l 231 763 l 614 763 l 614 701 q 429 224 429 464 l 344 224 z "},"❽":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 628 40 539 -22 q 766 219 718 103 q 814 493 814 336 q 766 767 814 650 q 628 946 718 883 q 418 1008 539 1008 q 208 946 297 1008 q 69 767 118 883 q 21 493 21 650 q 69 219 21 336 q 208 40 118 103 q 418 -22 297 -22 m 417 206 q 276 250 332 206 q 221 369 221 294 q 249 460 221 421 q 326 518 276 499 q 247 631 247 551 q 297 739 247 696 q 417 782 346 782 q 538 739 489 782 q 586 631 586 696 q 507 518 586 551 q 585 458 557 497 q 613 369 613 419 q 557 250 613 294 q 417 206 501 206 m 417 285 q 495 310 465 285 q 525 375 525 335 q 494 447 525 419 q 417 475 464 475 q 339 447 369 475 q 308 375 308 419 q 338 310 308 335 q 417 285 368 285 m 417 550 q 476 572 453 550 q 499 631 499 594 q 476 683 499 663 q 417 703 453 703 q 358 683 381 703 q 335 631 335 663 q 358 572 335 594 q 417 550 381 550 z "},"❾":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 628 40 539 -22 q 766 219 718 103 q 814 493 814 336 q 766 767 814 650 q 628 946 718 883 q 418 1008 539 1008 q 208 946 297 1008 q 69 767 118 883 q 21 493 21 650 q 69 219 21 336 q 208 40 118 103 q 418 -22 297 -22 m 404 206 q 299 240 346 206 q 235 336 253 274 l 317 344 q 349 300 326 315 q 404 285 372 285 q 487 330 454 285 q 529 454 519 375 q 476 409 508 425 q 404 393 444 393 q 282 447 331 393 q 233 586 233 500 q 286 728 233 675 q 419 782 339 782 q 560 708 511 782 q 608 526 608 635 q 553 292 608 379 q 404 206 499 206 m 417 472 q 487 506 458 472 q 515 589 515 539 q 488 671 515 639 q 418 703 460 703 q 347 670 374 703 q 321 586 321 638 q 346 503 321 535 q 417 472 371 472 z "},"⓪":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 208 40 297 -22 q 69 219 118 103 q 21 493 21 336 q 69 767 21 650 q 208 946 118 883 q 418 1008 297 1008 q 628 946 539 1008 q 766 767 718 883 q 814 493 814 650 q 766 219 814 336 q 628 40 718 103 q 418 -22 539 -22 m 418 81 q 574 131 508 81 q 676 274 640 181 q 713 493 713 367 q 676 713 713 619 q 574 856 640 806 q 418 907 507 907 q 261 856 328 907 q 159 713 194 806 q 124 493 124 619 q 159 274 124 367 q 261 131 194 181 q 418 81 328 81 m 417 206 q 263 283 319 206 q 207 493 207 361 q 263 705 207 626 q 417 783 319 783 q 570 705 514 783 q 626 493 626 626 q 570 283 626 361 q 417 206 514 206 m 417 288 q 509 342 476 288 q 542 493 542 396 q 529 601 542 556 l 349 313 q 417 288 376 288 m 292 493 q 304 389 292 433 l 485 676 q 417 701 458 701 q 324 647 357 701 q 292 493 292 593 z "},"①":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 208 40 297 -22 q 69 219 118 103 q 21 493 21 336 q 69 767 21 650 q 208 946 118 883 q 418 1008 297 1008 q 628 946 539 1008 q 766 767 718 883 q 814 493 814 650 q 766 219 814 336 q 628 40 718 103 q 418 -22 539 -22 m 418 81 q 574 131 508 81 q 676 274 640 181 q 713 493 713 367 q 676 713 713 619 q 574 856 640 806 q 418 907 507 907 q 261 856 328 907 q 159 713 194 806 q 124 493 124 619 q 159 274 124 367 q 261 131 194 181 q 418 81 328 81 m 386 579 l 249 578 l 249 639 l 282 639 q 374 671 342 639 q 406 763 406 703 l 471 763 l 471 224 l 386 224 l 386 579 z "},"②":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 208 40 297 -22 q 69 219 118 103 q 21 493 21 336 q 69 767 21 650 q 208 946 118 883 q 418 1008 297 1008 q 628 946 539 1008 q 766 767 718 883 q 814 493 814 650 q 766 219 814 336 q 628 40 718 103 q 418 -22 539 -22 m 418 81 q 574 131 508 81 q 676 274 640 181 q 713 493 713 367 q 676 713 713 619 q 574 856 640 806 q 418 907 507 907 q 261 856 328 907 q 159 713 194 806 q 124 493 124 619 q 159 274 124 367 q 261 131 194 181 q 418 81 328 81 m 232 222 q 267 362 232 297 q 396 489 301 426 l 440 518 q 494 568 478 543 q 510 624 510 593 q 486 678 510 657 q 429 700 463 700 q 325 600 344 700 l 242 604 q 302 732 251 685 q 429 779 353 779 q 549 735 503 779 q 594 624 594 692 q 561 526 594 575 q 464 438 528 478 l 425 413 q 333 301 335 351 l 599 301 l 599 222 l 232 222 z "},"③":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 208 40 297 -22 q 69 219 118 103 q 21 493 21 336 q 69 767 21 650 q 208 946 118 883 q 418 1008 297 1008 q 628 946 539 1008 q 766 767 718 883 q 814 493 814 650 q 766 219 814 336 q 628 40 718 103 q 418 -22 539 -22 m 418 81 q 574 131 508 81 q 676 274 640 181 q 713 493 713 367 q 676 713 713 619 q 574 856 640 806 q 418 907 507 907 q 261 856 328 907 q 159 713 194 806 q 124 493 124 619 q 159 274 124 367 q 261 131 194 181 q 418 81 328 81 m 418 206 q 283 251 339 206 q 222 361 226 296 l 307 365 q 342 308 310 332 q 418 285 375 285 q 490 311 461 285 q 519 375 519 338 q 488 444 519 415 q 411 474 457 474 l 376 474 l 376 553 l 411 553 q 475 576 449 553 q 501 626 501 599 q 476 682 501 658 q 417 706 451 706 q 355 688 381 706 q 326 646 329 671 l 242 651 q 301 747 250 708 q 417 785 351 785 q 538 743 489 785 q 586 640 586 701 q 562 565 586 600 q 494 518 538 531 q 575 461 543 503 q 607 369 607 419 q 552 253 607 301 q 418 206 497 206 z "},"④":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 208 40 297 -22 q 69 219 118 103 q 21 493 21 336 q 69 767 21 650 q 208 946 118 883 q 418 1008 297 1008 q 628 946 539 1008 q 766 767 718 883 q 814 493 814 650 q 766 219 814 336 q 628 40 718 103 q 418 -22 539 -22 m 418 81 q 574 131 508 81 q 676 274 640 181 q 713 493 713 367 q 676 713 713 619 q 574 856 640 806 q 418 907 507 907 q 261 856 328 907 q 159 713 194 806 q 124 493 124 619 q 159 274 124 367 q 261 131 194 181 q 418 81 328 81 m 444 329 l 183 329 l 183 399 l 458 763 l 529 763 l 529 408 l 590 408 l 590 329 l 529 329 l 529 224 l 444 224 l 444 329 m 444 408 l 444 611 l 292 408 l 444 408 z "},"⑤":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 208 40 297 -22 q 69 219 118 103 q 21 493 21 336 q 69 767 21 650 q 208 946 118 883 q 418 1008 297 1008 q 628 946 539 1008 q 766 767 718 883 q 814 493 814 650 q 766 219 814 336 q 628 40 718 103 q 418 -22 539 -22 m 418 81 q 574 131 508 81 q 676 274 640 181 q 713 493 713 367 q 676 713 713 619 q 574 856 640 806 q 418 907 507 907 q 261 856 328 907 q 159 713 194 806 q 124 493 124 619 q 159 274 124 367 q 261 131 194 181 q 418 81 328 81 m 417 201 q 288 244 340 201 q 229 356 235 288 l 317 360 q 353 303 324 325 q 419 281 382 281 q 490 313 461 281 q 518 393 518 344 q 490 474 518 440 q 419 507 461 507 q 367 495 390 507 q 322 450 343 483 l 239 450 l 282 764 l 568 764 l 568 685 l 356 685 l 338 543 q 381 574 358 564 q 432 583 403 583 q 556 528 507 583 q 606 393 606 474 q 553 255 606 308 q 417 201 500 201 z "},"⑥":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 208 40 297 -22 q 69 219 118 103 q 21 493 21 336 q 69 767 21 650 q 208 946 118 883 q 418 1008 297 1008 q 628 946 539 1008 q 766 767 718 883 q 814 493 814 650 q 766 219 814 336 q 628 40 718 103 q 418 -22 539 -22 m 418 81 q 574 131 508 81 q 676 274 640 181 q 713 493 713 367 q 676 713 713 619 q 574 856 640 806 q 418 907 507 907 q 261 856 328 907 q 159 713 194 806 q 124 493 124 619 q 159 274 124 367 q 261 131 194 181 q 418 81 328 81 m 418 206 q 278 279 326 206 q 229 461 229 353 q 280 690 229 597 q 433 782 331 782 q 538 748 492 782 q 603 651 585 714 l 521 643 q 488 688 511 672 q 433 703 465 703 q 351 655 382 703 q 310 535 319 607 q 433 594 356 594 q 556 541 507 594 q 604 401 604 488 q 551 259 604 313 q 418 206 499 206 m 419 285 q 490 317 464 285 q 517 401 517 350 q 492 484 517 453 q 421 515 467 515 q 351 482 379 515 q 322 399 322 449 q 349 317 322 349 q 419 285 376 285 z "},"⑦":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 208 40 297 -22 q 69 219 118 103 q 21 493 21 336 q 69 767 21 650 q 208 946 118 883 q 418 1008 297 1008 q 628 946 539 1008 q 766 767 718 883 q 814 493 814 650 q 766 219 814 336 q 628 40 718 103 q 418 -22 539 -22 m 418 81 q 574 131 508 81 q 676 274 640 181 q 713 493 713 367 q 676 713 713 619 q 574 856 640 806 q 418 907 507 907 q 261 856 328 907 q 159 713 194 806 q 124 493 124 619 q 159 274 124 367 q 261 131 194 181 q 418 81 328 81 m 344 224 q 388 464 344 347 q 510 683 431 581 l 231 683 l 231 763 l 614 763 l 614 701 q 429 224 429 464 l 344 224 z "},"⑧":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 208 40 297 -22 q 69 219 118 103 q 21 493 21 336 q 69 767 21 650 q 208 946 118 883 q 418 1008 297 1008 q 628 946 539 1008 q 766 767 718 883 q 814 493 814 650 q 766 219 814 336 q 628 40 718 103 q 418 -22 539 -22 m 418 81 q 574 131 508 81 q 676 274 640 181 q 713 493 713 367 q 676 713 713 619 q 574 856 640 806 q 418 907 507 907 q 261 856 328 907 q 159 713 194 806 q 124 493 124 619 q 159 274 124 367 q 261 131 194 181 q 418 81 328 81 m 417 206 q 276 250 332 206 q 221 369 221 294 q 249 460 221 421 q 326 518 276 499 q 247 631 247 551 q 297 739 247 696 q 417 782 346 782 q 538 739 489 782 q 586 631 586 696 q 507 518 586 551 q 585 458 557 497 q 613 369 613 419 q 557 250 613 294 q 417 206 501 206 m 417 285 q 495 310 465 285 q 525 375 525 335 q 494 447 525 419 q 417 475 464 475 q 339 447 369 475 q 308 375 308 419 q 338 310 308 335 q 417 285 368 285 m 417 550 q 476 572 453 550 q 499 631 499 594 q 476 683 499 663 q 417 703 453 703 q 358 683 381 703 q 335 631 335 663 q 358 572 335 594 q 417 550 381 550 z "},"⑨":{"ha":833,"x_min":21,"x_max":814,"o":"m 418 -22 q 208 40 297 -22 q 69 219 118 103 q 21 493 21 336 q 69 767 21 650 q 208 946 118 883 q 418 1008 297 1008 q 628 946 539 1008 q 766 767 718 883 q 814 493 814 650 q 766 219 814 336 q 628 40 718 103 q 418 -22 539 -22 m 418 81 q 574 131 508 81 q 676 274 640 181 q 713 493 713 367 q 676 713 713 619 q 574 856 640 806 q 418 907 507 907 q 261 856 328 907 q 159 713 194 806 q 124 493 124 619 q 159 274 124 367 q 261 131 194 181 q 418 81 328 81 m 404 206 q 299 240 346 206 q 235 336 253 274 l 317 344 q 349 300 326 315 q 404 285 372 285 q 487 330 454 285 q 529 454 519 375 q 476 409 508 425 q 404 393 444 393 q 282 447 331 393 q 233 586 233 500 q 286 728 233 675 q 419 782 339 782 q 560 708 511 782 q 608 526 608 635 q 553 292 608 379 q 404 206 499 206 m 417 472 q 487 506 458 472 q 515 589 515 539 q 488 671 515 639 q 418 703 460 703 q 347 670 374 703 q 321 586 321 638 q 346 503 321 535 q 417 472 371 472 z "},"⁄":{"ha":833,"x_min":18,"x_max":815,"o":"m 699 986 l 815 986 l 135 0 l 18 0 l 699 986 z "},"½":{"ha":833,"x_min":56,"x_max":793,"o":"m 664 986 l 772 986 l 579 631 l 471 631 l 664 986 m 249 354 l 357 354 l 164 0 l 56 0 l 249 354 m 426 0 q 461 140 426 75 q 590 267 496 204 l 635 296 q 688 346 672 321 q 704 401 704 371 q 681 456 704 435 q 624 478 657 478 q 519 378 539 478 l 436 382 q 497 510 446 463 q 624 557 547 557 q 743 513 697 557 q 789 401 789 469 q 756 304 789 353 q 658 215 722 256 l 619 190 q 528 79 529 129 l 793 79 l 793 0 l 426 0 m 210 804 l 72 803 l 72 864 l 106 864 q 197 896 165 864 q 229 988 229 928 l 294 988 l 294 449 l 210 449 l 210 804 z "},"⅓":{"ha":833,"x_min":56,"x_max":801,"o":"m 664 986 l 772 986 l 579 631 l 471 631 l 664 986 m 249 354 l 357 354 l 164 0 l 56 0 l 249 354 m 613 -22 q 477 23 533 -22 q 417 133 421 68 l 501 138 q 537 81 504 104 q 613 57 569 57 q 685 83 656 57 q 714 147 714 110 q 683 217 714 188 q 606 246 651 246 l 571 246 l 571 325 l 606 325 q 669 348 643 325 q 696 399 696 371 q 671 454 696 431 q 611 478 646 478 q 549 460 575 478 q 521 418 524 443 l 436 424 q 495 519 444 481 q 611 557 546 557 q 732 515 683 557 q 781 413 781 474 q 756 338 781 372 q 689 290 732 303 q 769 233 738 275 q 801 142 801 192 q 747 26 801 74 q 613 -22 692 -22 m 208 804 l 71 803 l 71 864 l 104 864 q 196 896 164 864 q 228 988 228 928 l 293 988 l 293 449 l 208 449 l 208 804 z "},"⅔":{"ha":833,"x_min":28,"x_max":807,"o":"m 664 986 l 772 986 l 579 631 l 471 631 l 664 986 m 249 354 l 357 354 l 164 0 l 56 0 l 249 354 m 618 -22 q 483 23 539 -22 q 422 133 426 68 l 507 138 q 542 81 510 104 q 618 57 575 57 q 690 83 661 57 q 719 147 719 110 q 688 217 719 188 q 611 246 657 246 l 576 246 l 576 325 l 611 325 q 675 348 649 325 q 701 399 701 371 q 676 454 701 431 q 617 478 651 478 q 555 460 581 478 q 526 418 529 443 l 442 424 q 501 519 450 481 q 617 557 551 557 q 738 515 689 557 q 786 413 786 474 q 762 338 786 372 q 694 290 738 303 q 775 233 743 275 q 807 142 807 192 q 752 26 807 74 q 618 -22 697 -22 m 28 451 q 63 591 28 526 q 192 718 97 656 l 236 747 q 290 797 274 772 q 306 853 306 822 q 282 908 306 886 q 225 929 258 929 q 121 829 140 929 l 38 833 q 98 961 47 914 q 225 1008 149 1008 q 344 965 299 1008 q 390 853 390 921 q 357 756 390 804 q 260 667 324 707 l 221 642 q 129 531 131 581 l 394 531 l 394 451 l 28 451 z "},"¼":{"ha":833,"x_min":56,"x_max":793,"o":"m 664 986 l 772 986 l 579 631 l 471 631 l 664 986 m 249 354 l 357 354 l 164 0 l 56 0 l 249 354 m 647 106 l 386 106 l 386 175 l 661 539 l 732 539 l 732 185 l 793 185 l 793 106 l 732 106 l 732 0 l 647 0 l 647 106 m 647 185 l 647 388 l 494 185 l 647 185 m 208 804 l 71 803 l 71 864 l 104 864 q 196 896 164 864 q 228 988 228 928 l 293 988 l 293 449 l 208 449 l 208 804 z "},"¾":{"ha":833,"x_min":24,"x_max":793,"o":"m 664 986 l 772 986 l 579 631 l 471 631 l 664 986 m 249 354 l 357 354 l 164 0 l 56 0 l 249 354 m 647 106 l 386 106 l 386 175 l 661 539 l 732 539 l 732 185 l 793 185 l 793 106 l 732 106 l 732 0 l 647 0 l 647 106 m 647 185 l 647 388 l 494 185 l 647 185 m 219 429 q 84 474 140 429 q 24 585 28 519 l 108 589 q 144 532 111 556 q 219 508 176 508 q 292 535 263 508 q 321 599 321 561 q 290 668 321 639 q 213 697 258 697 l 178 697 l 178 776 l 213 776 q 276 799 250 776 q 303 850 303 822 q 278 906 303 882 q 218 929 253 929 q 156 912 182 929 q 128 869 131 894 l 43 875 q 102 970 51 932 q 218 1008 153 1008 q 339 967 290 1008 q 388 864 388 925 q 363 789 388 824 q 296 742 339 754 q 376 685 344 726 q 408 593 408 643 q 353 477 408 525 q 219 429 299 429 z "},"⅕":{"ha":833,"x_min":56,"x_max":779,"o":"m 664 986 l 772 986 l 579 631 l 471 631 l 664 986 m 249 354 l 357 354 l 164 0 l 56 0 l 249 354 m 590 -22 q 461 21 514 -22 q 403 132 408 64 l 490 136 q 526 79 497 101 q 593 57 556 57 q 663 89 635 57 q 692 169 692 121 q 663 250 692 217 q 593 283 635 283 q 540 272 564 283 q 496 226 517 260 l 413 226 l 456 540 l 742 540 l 742 461 l 529 461 l 511 319 q 554 350 532 340 q 606 360 576 360 q 730 305 681 360 q 779 169 779 250 q 726 31 779 85 q 590 -22 674 -22 m 208 804 l 71 803 l 71 864 l 104 864 q 196 896 164 864 q 228 988 228 928 l 293 988 l 293 449 l 208 449 l 208 804 z "},"⅛":{"ha":833,"x_min":56,"x_max":793,"o":"m 664 986 l 772 986 l 579 631 l 471 631 l 664 986 m 249 354 l 357 354 l 164 0 l 56 0 l 249 354 m 597 -22 q 457 22 513 -22 q 401 142 401 67 q 429 232 401 193 q 507 290 457 271 q 428 403 428 324 q 477 511 428 468 q 597 554 526 554 q 718 511 669 554 q 767 403 767 468 q 688 290 767 324 q 765 231 738 269 q 793 142 793 192 q 738 22 793 67 q 597 -22 682 -22 m 597 57 q 676 82 646 57 q 706 147 706 107 q 675 219 706 192 q 597 247 644 247 q 519 219 550 247 q 489 147 489 192 q 519 82 489 107 q 597 57 549 57 m 597 322 q 656 344 633 322 q 679 403 679 367 q 656 455 679 435 q 597 475 633 475 q 538 455 561 475 q 515 403 515 435 q 538 344 515 367 q 597 322 561 322 m 208 804 l 71 803 l 71 864 l 104 864 q 196 896 164 864 q 228 988 228 928 l 293 988 l 293 449 l 208 449 l 208 804 z "},"⅜":{"ha":833,"x_min":28,"x_max":793,"o":"m 664 986 l 772 986 l 579 631 l 471 631 l 664 986 m 249 354 l 357 354 l 164 0 l 56 0 l 249 354 m 597 -22 q 457 22 513 -22 q 401 142 401 67 q 429 232 401 193 q 507 290 457 271 q 428 403 428 324 q 477 511 428 468 q 597 554 526 554 q 718 511 669 554 q 767 403 767 468 q 688 290 767 324 q 765 231 738 269 q 793 142 793 192 q 738 22 793 67 q 597 -22 682 -22 m 597 57 q 676 82 646 57 q 706 147 706 107 q 675 219 706 192 q 597 247 644 247 q 519 219 550 247 q 489 147 489 192 q 519 82 489 107 q 597 57 549 57 m 597 322 q 656 344 633 322 q 679 403 679 367 q 656 455 679 435 q 597 475 633 475 q 538 455 561 475 q 515 403 515 435 q 538 344 515 367 q 597 322 561 322 m 215 425 q 86 468 139 425 q 28 579 33 511 l 115 583 q 151 526 122 549 q 218 504 181 504 q 288 536 260 504 q 317 617 317 568 q 288 697 317 664 q 218 731 260 731 q 165 719 189 731 q 121 674 142 707 l 38 674 l 81 988 l 367 988 l 367 908 l 154 908 l 136 767 q 179 797 157 788 q 231 807 201 807 q 355 752 306 807 q 404 617 404 697 q 351 478 404 532 q 215 425 299 425 z "},"⅝":{"ha":833,"x_min":24,"x_max":793,"o":"m 664 986 l 772 986 l 579 631 l 471 631 l 664 986 m 249 354 l 357 354 l 164 0 l 56 0 l 249 354 m 597 -22 q 457 22 513 -22 q 401 142 401 67 q 429 232 401 193 q 507 290 457 271 q 428 403 428 324 q 477 511 428 468 q 597 554 526 554 q 718 511 669 554 q 767 403 767 468 q 688 290 767 324 q 765 231 738 269 q 793 142 793 192 q 738 22 793 67 q 597 -22 682 -22 m 597 57 q 676 82 646 57 q 706 147 706 107 q 675 219 706 192 q 597 247 644 247 q 519 219 550 247 q 489 147 489 192 q 519 82 489 107 q 597 57 549 57 m 597 322 q 656 344 633 322 q 679 403 679 367 q 656 455 679 435 q 597 475 633 475 q 538 455 561 475 q 515 403 515 435 q 538 344 515 367 q 597 322 561 322 m 219 429 q 84 474 140 429 q 24 585 28 519 l 108 589 q 144 532 111 556 q 219 508 176 508 q 292 535 263 508 q 321 599 321 561 q 290 668 321 639 q 213 697 258 697 l 178 697 l 178 776 l 213 776 q 276 799 250 776 q 303 850 303 822 q 278 906 303 882 q 218 929 253 929 q 156 912 182 929 q 128 869 131 894 l 43 875 q 102 970 51 932 q 218 1008 153 1008 q 339 967 290 1008 q 388 864 388 925 q 363 789 388 824 q 296 742 339 754 q 376 685 344 726 q 408 593 408 643 q 353 477 408 525 q 219 429 299 429 z "},"⅞":{"ha":833,"x_min":56,"x_max":793,"o":"m 664 986 l 772 986 l 579 631 l 471 631 l 664 986 m 249 354 l 357 354 l 164 0 l 56 0 l 249 354 m 597 -22 q 457 22 513 -22 q 401 142 401 67 q 429 232 401 193 q 507 290 457 271 q 428 403 428 324 q 477 511 428 468 q 597 554 526 554 q 718 511 669 554 q 767 403 767 468 q 688 290 767 324 q 765 231 738 269 q 793 142 793 192 q 738 22 793 67 q 597 -22 682 -22 m 597 57 q 676 82 646 57 q 706 147 706 107 q 675 219 706 192 q 597 247 644 247 q 519 219 550 247 q 489 147 489 192 q 519 82 489 107 q 597 57 549 57 m 597 322 q 656 344 633 322 q 679 403 679 367 q 656 455 679 435 q 597 475 633 475 q 538 455 561 475 q 515 403 515 435 q 538 344 515 367 q 597 322 561 322 m 192 447 q 235 688 192 571 q 357 907 278 804 l 78 907 l 78 986 l 461 986 l 461 925 q 276 447 276 688 l 192 447 z "},"₀":{"ha":833,"x_min":207,"x_max":626,"o":"m 417 -21 q 263 57 319 -21 q 207 267 207 135 q 263 478 207 400 q 417 557 319 557 q 570 478 514 557 q 626 267 626 400 q 570 57 626 135 q 417 -21 514 -21 m 417 61 q 509 115 476 61 q 542 267 542 169 q 529 375 542 329 l 349 86 q 417 61 376 61 m 292 267 q 304 163 292 207 l 485 450 q 417 475 458 475 q 324 421 357 475 q 292 267 292 367 z "},"₁":{"ha":833,"x_min":249,"x_max":471,"o":"m 386 356 l 249 354 l 249 415 l 282 415 q 374 447 342 415 q 406 539 406 479 l 471 539 l 471 0 l 386 0 l 386 356 z "},"₂":{"ha":833,"x_min":222,"x_max":589,"o":"m 222 0 q 257 140 222 75 q 386 267 292 204 l 431 296 q 484 346 468 321 q 500 401 500 371 q 476 456 500 435 q 419 478 453 478 q 315 378 335 478 l 232 382 q 292 510 242 463 q 419 557 343 557 q 539 513 493 557 q 585 401 585 469 q 551 304 585 353 q 454 215 518 256 l 415 190 q 324 79 325 129 l 589 79 l 589 0 l 222 0 z "},"₃":{"ha":833,"x_min":222,"x_max":607,"o":"m 418 -22 q 283 23 339 -22 q 222 133 226 68 l 307 138 q 342 81 310 104 q 418 57 375 57 q 490 83 461 57 q 519 147 519 110 q 488 217 519 188 q 411 246 457 246 l 376 246 l 376 325 l 411 325 q 475 348 449 325 q 501 399 501 371 q 476 454 501 431 q 417 478 451 478 q 355 460 381 478 q 326 418 329 443 l 242 424 q 301 519 250 481 q 417 557 351 557 q 538 515 489 557 q 586 413 586 474 q 562 338 586 372 q 494 290 538 303 q 575 233 543 275 q 607 142 607 192 q 552 26 607 74 q 418 -22 497 -22 z "},"₄":{"ha":833,"x_min":183,"x_max":590,"o":"m 444 106 l 183 106 l 183 175 l 458 539 l 529 539 l 529 185 l 590 185 l 590 106 l 529 106 l 529 0 l 444 0 l 444 106 m 444 185 l 444 388 l 292 185 l 444 185 z "},"₅":{"ha":833,"x_min":229,"x_max":606,"o":"m 417 -22 q 288 21 340 -22 q 229 132 235 64 l 317 136 q 353 79 324 101 q 419 57 382 57 q 490 89 461 57 q 518 169 518 121 q 490 250 518 217 q 419 283 461 283 q 367 272 390 283 q 322 226 343 260 l 239 226 l 282 540 l 568 540 l 568 461 l 356 461 l 338 319 q 381 350 358 340 q 432 360 403 360 q 556 305 507 360 q 606 169 606 250 q 553 31 606 85 q 417 -22 500 -22 z "},"₆":{"ha":833,"x_min":229,"x_max":604,"o":"m 418 -22 q 278 51 326 -22 q 229 233 229 125 q 280 462 229 369 q 433 554 331 554 q 538 520 492 554 q 603 424 585 486 l 521 415 q 488 460 511 444 q 433 475 465 475 q 351 427 382 475 q 310 307 319 379 q 433 367 356 367 q 556 313 507 367 q 604 174 604 260 q 551 31 604 85 q 418 -22 499 -22 m 419 57 q 490 90 464 57 q 517 174 517 122 q 492 256 517 225 q 421 288 467 288 q 351 254 379 288 q 322 171 322 221 q 349 89 322 121 q 419 57 376 57 z "},"₇":{"ha":833,"x_min":231,"x_max":614,"o":"m 344 -1 q 388 239 344 122 q 510 458 431 356 l 231 458 l 231 538 l 614 538 l 614 476 q 429 -1 429 239 l 344 -1 z "},"₈":{"ha":833,"x_min":221,"x_max":613,"o":"m 417 -22 q 276 22 332 -22 q 221 142 221 67 q 249 232 221 193 q 326 290 276 271 q 247 403 247 324 q 297 511 247 468 q 417 554 346 554 q 538 511 489 554 q 586 403 586 468 q 507 290 586 324 q 585 231 557 269 q 613 142 613 192 q 557 22 613 67 q 417 -22 501 -22 m 417 57 q 495 82 465 57 q 525 147 525 107 q 494 219 525 192 q 417 247 464 247 q 339 219 369 247 q 308 147 308 192 q 338 82 308 107 q 417 57 368 57 m 417 322 q 476 344 453 322 q 499 403 499 367 q 476 455 499 435 q 417 475 453 475 q 358 455 381 475 q 335 403 335 435 q 358 344 335 367 q 417 322 381 322 z "},"₉":{"ha":833,"x_min":233,"x_max":608,"o":"m 404 -22 q 299 12 346 -22 q 235 108 253 46 l 317 117 q 349 72 326 88 q 404 57 372 57 q 487 102 454 57 q 529 226 519 147 q 476 181 508 197 q 404 165 444 165 q 282 219 331 165 q 233 358 233 272 q 286 501 233 447 q 419 554 339 554 q 560 481 511 554 q 608 299 608 407 q 553 65 608 151 q 404 -22 499 -22 m 417 244 q 487 278 458 244 q 515 361 515 311 q 488 443 515 411 q 418 475 460 475 q 347 442 374 475 q 321 358 321 410 q 346 276 321 307 q 417 244 371 244 z "},"⁰":{"ha":833,"x_min":207,"x_max":626,"o":"m 417 431 q 263 508 319 431 q 207 718 207 586 q 263 930 207 851 q 417 1008 319 1008 q 570 930 514 1008 q 626 718 626 851 q 570 508 626 586 q 417 431 514 431 m 417 513 q 509 567 476 513 q 542 718 542 621 q 529 826 542 781 l 349 538 q 417 513 376 513 m 292 718 q 304 614 292 658 l 485 901 q 417 926 458 926 q 324 872 357 926 q 292 718 292 818 z "},"¹":{"ha":833,"x_min":249,"x_max":471,"o":"m 386 804 l 249 803 l 249 864 l 282 864 q 374 896 342 864 q 406 988 406 928 l 471 988 l 471 449 l 386 449 l 386 804 z "},"²":{"ha":833,"x_min":222,"x_max":589,"o":"m 222 451 q 257 591 222 526 q 386 718 292 656 l 431 747 q 484 797 468 772 q 500 853 500 822 q 476 908 500 886 q 419 929 453 929 q 315 829 335 929 l 232 833 q 292 961 242 914 q 419 1008 343 1008 q 539 965 493 1008 q 585 853 585 921 q 551 756 585 804 q 454 667 518 707 l 415 642 q 324 531 325 581 l 589 531 l 589 451 l 222 451 z "},"³":{"ha":833,"x_min":222,"x_max":607,"o":"m 418 429 q 283 474 339 429 q 222 585 226 519 l 307 589 q 342 532 310 556 q 418 508 375 508 q 490 535 461 508 q 519 599 519 561 q 488 668 519 639 q 411 697 457 697 l 376 697 l 376 776 l 411 776 q 475 799 449 776 q 501 850 501 822 q 476 906 501 882 q 417 929 451 929 q 355 912 381 929 q 326 869 329 894 l 242 875 q 301 970 250 932 q 417 1008 351 1008 q 538 967 489 1008 q 586 864 586 925 q 562 789 586 824 q 494 742 538 754 q 575 685 543 726 q 607 593 607 643 q 552 477 607 525 q 418 429 497 429 z "},"⁴":{"ha":833,"x_min":183,"x_max":590,"o":"m 444 554 l 183 554 l 183 624 l 458 988 l 529 988 l 529 633 l 590 633 l 590 554 l 529 554 l 529 449 l 444 449 l 444 554 m 444 633 l 444 836 l 292 633 l 444 633 z "},"⁵":{"ha":833,"x_min":229,"x_max":606,"o":"m 417 425 q 288 468 340 425 q 229 579 235 511 l 317 583 q 353 526 324 549 q 419 504 382 504 q 490 536 461 504 q 518 617 518 568 q 490 697 518 664 q 419 731 461 731 q 367 719 390 731 q 322 674 343 707 l 239 674 l 282 988 l 568 988 l 568 908 l 356 908 l 338 767 q 381 797 358 788 q 432 807 403 807 q 556 752 507 807 q 606 617 606 697 q 553 478 606 532 q 417 425 500 425 z "},"⁶":{"ha":833,"x_min":229,"x_max":604,"o":"m 418 432 q 278 506 326 432 q 229 688 229 579 q 280 916 229 824 q 433 1008 331 1008 q 538 974 492 1008 q 603 878 585 940 l 521 869 q 488 914 511 899 q 433 929 465 929 q 351 881 382 929 q 310 761 319 833 q 433 821 356 821 q 556 767 507 821 q 604 628 604 714 q 551 485 604 539 q 418 432 499 432 m 419 511 q 490 544 464 511 q 517 628 517 576 q 492 710 517 679 q 421 742 467 742 q 351 708 379 742 q 322 625 322 675 q 349 543 322 575 q 419 511 376 511 z "},"⁷":{"ha":833,"x_min":231,"x_max":614,"o":"m 344 447 q 388 688 344 571 q 510 907 431 804 l 231 907 l 231 986 l 614 986 l 614 925 q 429 447 429 688 l 344 447 z "},"⁸":{"ha":833,"x_min":221,"x_max":613,"o":"m 417 432 q 276 476 332 432 q 221 596 221 521 q 249 686 221 647 q 326 744 276 725 q 247 857 247 778 q 297 965 247 922 q 417 1008 346 1008 q 538 965 489 1008 q 586 857 586 922 q 507 744 586 778 q 585 685 557 724 q 613 596 613 646 q 557 476 613 521 q 417 432 501 432 m 417 511 q 495 536 465 511 q 525 601 525 561 q 494 674 525 646 q 417 701 464 701 q 339 674 369 701 q 308 601 308 646 q 338 536 308 561 q 417 511 368 511 m 417 776 q 476 799 453 776 q 499 857 499 821 q 476 909 499 889 q 417 929 453 929 q 358 909 381 929 q 335 857 335 889 q 358 799 335 821 q 417 776 381 776 z "},"⁹":{"ha":833,"x_min":233,"x_max":608,"o":"m 404 432 q 299 466 346 432 q 235 563 253 500 l 317 571 q 349 526 326 542 q 404 511 372 511 q 487 556 454 511 q 529 681 519 601 q 476 635 508 651 q 404 619 444 619 q 282 673 331 619 q 233 813 233 726 q 286 955 233 901 q 419 1008 339 1008 q 560 935 511 1008 q 608 753 608 861 q 553 519 608 606 q 404 432 499 432 m 417 699 q 487 732 458 699 q 515 815 515 765 q 488 897 515 865 q 418 929 460 929 q 347 897 374 929 q 321 813 321 864 q 346 730 321 761 q 417 699 371 699 z "}," ":{"ha":833,"x_min":0,"x_max":0,"o":""},".":{"ha":833,"x_min":333,"x_max":500,"o":"m 333 167 l 500 167 l 500 0 l 333 0 l 333 167 z "},",":{"ha":833,"x_min":333,"x_max":500,"o":"m 408 0 l 333 0 l 333 167 l 500 167 l 500 19 l 413 -217 l 343 -217 l 408 0 z "},":":{"ha":833,"x_min":333,"x_max":500,"o":"m 333 167 l 500 167 l 500 0 l 333 0 l 333 167 m 333 703 l 500 703 l 500 536 l 333 536 l 333 703 z "},";":{"ha":833,"x_min":333,"x_max":500,"o":"m 333 703 l 500 703 l 500 536 l 333 536 l 333 703 m 408 0 l 333 0 l 333 167 l 500 167 l 500 19 l 413 -217 l 343 -217 l 408 0 z "},"…":{"ha":833,"x_min":76,"x_max":757,"o":"m 76 167 l 243 167 l 243 0 l 76 0 l 76 167 m 332 167 l 499 167 l 499 0 l 332 0 l 332 167 m 590 167 l 757 167 l 757 0 l 590 0 l 590 167 z "},"!":{"ha":833,"x_min":333,"x_max":500,"o":"m 333 167 l 500 167 l 500 0 l 333 0 l 333 167 m 379 290 q 357 667 357 550 l 357 986 l 476 986 l 476 667 q 456 290 476 533 l 379 290 z "},"¡":{"ha":833,"x_min":333,"x_max":500,"o":"m 500 761 l 500 594 l 333 594 l 333 761 l 500 761 m 456 471 q 476 94 476 228 l 476 -225 l 357 -225 l 357 94 q 379 471 357 211 l 456 471 z "},"?":{"ha":833,"x_min":93,"x_max":740,"o":"m 329 167 l 496 167 l 496 0 l 329 0 l 329 167 m 353 279 q 384 437 353 378 q 488 542 415 496 q 569 601 543 575 q 606 653 596 626 q 615 725 615 681 q 567 845 615 799 q 438 892 519 892 q 218 686 239 892 l 93 694 q 197 922 106 835 q 438 1008 289 1008 q 595 972 526 1008 q 702 872 664 936 q 740 725 740 807 q 701 585 740 640 q 568 469 663 531 q 492 393 513 433 q 472 279 472 353 l 353 279 z "},"¿":{"ha":833,"x_min":93,"x_max":740,"o":"m 504 617 l 338 617 l 338 783 l 504 783 l 504 617 m 481 504 q 449 347 481 406 q 346 242 418 288 q 264 183 290 208 q 228 130 238 157 q 218 58 218 103 q 266 -62 218 -15 q 396 -108 314 -108 q 615 97 594 -108 l 740 89 q 636 -138 728 -51 q 396 -225 544 -225 q 238 -189 307 -225 q 131 -88 169 -153 q 93 58 93 -24 q 132 198 93 143 q 265 314 171 253 q 341 390 321 350 q 361 504 361 431 l 481 504 z "},"·":{"ha":833,"x_min":333,"x_max":500,"o":"m 333 504 l 500 504 l 500 338 l 333 338 l 333 504 z "},"•":{"ha":833,"x_min":271,"x_max":563,"o":"m 417 276 q 314 319 357 276 q 271 421 271 361 q 314 524 271 482 q 417 567 357 567 q 520 524 478 567 q 563 421 563 482 q 519 319 563 361 q 417 276 476 276 z "},"*":{"ha":833,"x_min":181,"x_max":653,"o":"m 253 578 l 346 719 l 181 719 l 181 808 l 346 808 l 253 950 l 325 997 l 417 849 l 510 997 l 582 950 l 488 808 l 653 808 l 653 719 l 488 719 l 582 578 l 510 531 l 417 678 l 325 531 l 253 578 z "},"〃":{"ha":833,"x_min":224,"x_max":610,"o":"m 481 668 l 610 668 l 485 390 l 394 390 l 481 668 m 311 668 l 439 668 l 314 390 l 224 390 l 311 668 z "},"#":{"ha":833,"x_min":75,"x_max":758,"o":"m 464 274 l 290 274 l 239 0 l 139 0 l 190 274 l 75 274 l 92 371 l 208 371 l 253 611 l 131 611 l 147 708 l 271 708 l 322 986 l 422 986 l 371 708 l 544 708 l 596 986 l 696 986 l 644 708 l 758 708 l 742 611 l 626 611 l 582 371 l 703 371 l 686 274 l 564 274 l 513 0 l 413 0 l 464 274 m 482 371 l 526 611 l 353 611 l 308 371 l 482 371 z "},"/":{"ha":833,"x_min":150,"x_max":683,"o":"m 572 1042 l 683 1042 l 261 -153 l 150 -153 l 572 1042 z "},"\\":{"ha":833,"x_min":150,"x_max":683,"o":"m 572 -153 l 150 1042 l 261 1042 l 683 -153 l 572 -153 z "},"-":{"ha":833,"x_min":208,"x_max":625,"o":"m 208 458 l 625 458 l 625 350 l 208 350 l 208 458 z "},"–":{"ha":833,"x_min":139,"x_max":694,"o":"m 139 460 l 694 460 l 694 351 l 139 351 l 139 460 z "},"—":{"ha":833,"x_min":56,"x_max":778,"o":"m 56 460 l 778 460 l 778 351 l 56 351 l 56 460 z "},"_":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 0 l 722 0 l 722 -108 l 111 -108 l 111 0 z "},"〜":{"ha":833,"x_min":8,"x_max":825,"o":"m 586 343 q 417 403 511 343 q 247 467 314 467 q 89 353 167 467 l 8 433 q 113 543 49 503 q 247 583 178 583 q 417 524 322 583 q 586 460 519 460 q 744 574 665 460 l 825 493 q 722 383 786 424 q 586 343 657 343 z "},"(":{"ha":833,"x_min":263,"x_max":624,"o":"m 524 -125 q 328 159 394 6 q 263 472 263 313 q 328 785 263 632 q 524 1069 394 939 l 624 1069 q 446 781 506 933 q 386 472 386 629 q 446 163 386 315 q 624 -125 506 11 l 524 -125 z "},")":{"ha":833,"x_min":210,"x_max":571,"o":"m 210 -125 q 388 163 328 11 q 447 472 447 315 q 388 781 447 629 q 210 1069 328 933 l 310 1069 q 505 786 439 939 q 571 472 571 633 q 505 159 571 313 q 310 -125 439 6 l 210 -125 z "},"{":{"ha":833,"x_min":132,"x_max":686,"o":"m 608 -132 q 479 -101 536 -132 q 391 -17 422 -71 q 360 107 360 38 l 360 222 q 307 368 360 324 q 132 411 254 413 l 132 526 q 307 569 254 525 q 360 715 360 614 l 360 831 q 391 954 360 900 q 479 1039 422 1008 q 608 1069 536 1069 l 686 1069 l 686 961 q 519 936 567 965 q 471 833 471 907 l 471 706 q 424 549 471 613 q 301 471 376 486 l 301 469 q 424 390 378 454 q 471 232 471 326 l 471 104 q 519 3 471 32 q 686 -24 567 -26 l 686 -132 l 608 -132 z "},"}":{"ha":833,"x_min":147,"x_max":701,"o":"m 147 -132 l 147 -24 q 315 3 267 -26 q 363 104 363 32 l 363 232 q 409 390 363 326 q 532 469 456 454 l 532 471 q 410 549 457 486 q 363 706 363 613 l 363 833 q 315 936 363 907 q 147 961 267 965 l 147 1069 l 225 1069 q 354 1039 297 1069 q 442 954 411 1008 q 474 831 474 900 l 474 715 q 526 569 474 614 q 701 526 579 525 l 701 411 q 526 368 579 413 q 474 222 474 324 l 474 107 q 442 -17 474 38 q 354 -101 411 -71 q 225 -132 297 -132 l 147 -132 z "},"[":{"ha":833,"x_min":235,"x_max":636,"o":"m 235 -125 l 235 1069 l 636 1069 l 636 961 l 346 961 l 346 -17 l 636 -17 l 636 -125 l 235 -125 z "},"]":{"ha":833,"x_min":197,"x_max":599,"o":"m 197 -17 l 488 -17 l 488 961 l 197 961 l 197 1069 l 599 1069 l 599 -125 l 197 -125 l 197 -17 z "},"〈":{"ha":833,"x_min":238,"x_max":603,"o":"m 238 492 l 492 1140 l 603 1140 l 349 492 l 603 -158 l 492 -158 l 238 492 z "},"〉":{"ha":833,"x_min":231,"x_max":596,"o":"m 231 -158 l 485 492 l 231 1140 l 342 1140 l 596 492 l 342 -158 l 231 -158 z "},"【":{"ha":833,"x_min":257,"x_max":638,"o":"m 257 -87 l 257 1146 l 638 1146 q 450 529 450 849 q 638 -87 450 213 l 257 -87 z "},"】":{"ha":833,"x_min":196,"x_max":576,"o":"m 196 -87 q 383 529 383 213 q 196 1146 383 849 l 576 1146 l 576 -87 l 196 -87 z "},"「":{"ha":833,"x_min":236,"x_max":657,"o":"m 236 1147 l 657 1147 l 657 1039 l 349 1039 l 349 326 l 236 326 l 236 1147 z "},"」":{"ha":833,"x_min":176,"x_max":597,"o":"m 597 -143 l 176 -143 l 176 -35 l 485 -35 l 485 678 l 597 678 l 597 -143 z "},"《":{"ha":833,"x_min":117,"x_max":714,"o":"m 117 492 l 371 1140 l 482 1140 l 228 492 l 482 -158 l 371 -158 l 117 492 m 349 492 l 603 1140 l 714 1140 l 460 492 l 714 -158 l 603 -158 l 349 492 z "},"》":{"ha":833,"x_min":119,"x_max":717,"o":"m 351 -158 l 606 492 l 351 1140 l 463 1140 l 717 492 l 463 -158 l 351 -158 m 119 -158 l 374 492 l 119 1140 l 231 1140 l 485 492 l 231 -158 l 119 -158 z "},"〔":{"ha":833,"x_min":244,"x_max":665,"o":"m 244 111 l 244 871 l 590 1181 l 663 1101 l 356 826 l 356 156 l 665 -124 l 593 -203 l 244 111 z "},"〕":{"ha":833,"x_min":168,"x_max":589,"o":"m 168 -124 l 478 156 l 478 826 l 171 1101 l 243 1181 l 589 871 l 589 111 l 240 -203 l 168 -124 z "},"『":{"ha":833,"x_min":236,"x_max":657,"o":"m 236 1147 l 657 1147 l 657 925 l 458 925 l 458 324 l 236 324 l 236 1147 m 404 379 l 404 981 l 603 981 l 603 1092 l 292 1092 l 292 379 l 404 379 z "},"』":{"ha":833,"x_min":176,"x_max":597,"o":"m 597 -143 l 176 -143 l 176 79 l 375 79 l 375 681 l 597 681 l 597 -143 m 429 625 l 429 24 l 231 24 l 231 -87 l 542 -87 l 542 625 l 429 625 z "},"〖":{"ha":833,"x_min":257,"x_max":625,"o":"m 257 -87 l 257 1146 l 625 1146 q 438 529 438 849 q 625 -87 438 213 l 257 -87 m 528 -32 q 383 529 383 250 q 528 1090 383 810 l 313 1090 l 313 -32 l 528 -32 z "},"〗":{"ha":833,"x_min":208,"x_max":576,"o":"m 208 -87 q 396 529 396 213 q 208 1146 396 849 l 576 1146 l 576 -87 l 208 -87 m 521 -32 l 521 1090 l 306 1090 q 450 529 450 814 q 306 -32 450 247 l 521 -32 z "},"‚":{"ha":833,"x_min":342,"x_max":508,"o":"m 417 0 l 342 0 l 342 167 l 508 167 l 508 19 l 421 -217 l 351 -217 l 417 0 z "},"„":{"ha":833,"x_min":219,"x_max":617,"o":"m 294 0 l 219 0 l 219 167 l 386 167 l 386 19 l 299 -217 l 229 -217 l 294 0 m 525 0 l 450 0 l 450 167 l 617 167 l 617 19 l 529 -217 l 460 -217 l 525 0 z "},"“":{"ha":833,"x_min":217,"x_max":614,"o":"m 539 763 l 614 763 l 614 596 l 447 596 l 447 743 l 535 979 l 604 979 l 539 763 m 308 763 l 383 763 l 383 596 l 217 596 l 217 743 l 304 979 l 374 979 l 308 763 z "},"”":{"ha":833,"x_min":219,"x_max":617,"o":"m 294 826 l 219 826 l 219 993 l 386 993 l 386 846 l 299 610 l 229 610 l 294 826 m 525 826 l 450 826 l 450 993 l 617 993 l 617 846 l 529 610 l 460 610 l 525 826 z "},"‘":{"ha":833,"x_min":324,"x_max":490,"o":"m 415 763 l 490 763 l 490 596 l 324 596 l 324 743 l 411 979 l 481 979 l 415 763 z "},"’":{"ha":833,"x_min":342,"x_max":508,"o":"m 417 826 l 342 826 l 342 993 l 508 993 l 508 846 l 421 610 l 351 610 l 417 826 z "},"«":{"ha":833,"x_min":110,"x_max":724,"o":"m 300 106 l 110 332 l 110 435 l 300 661 l 443 661 l 207 383 l 443 106 l 300 106 m 581 106 l 390 332 l 390 435 l 581 661 l 724 661 l 488 383 l 724 106 l 581 106 z "},"»":{"ha":833,"x_min":110,"x_max":724,"o":"m 626 383 l 390 661 l 533 661 l 724 435 l 724 332 l 533 106 l 390 106 l 626 383 m 346 383 l 110 661 l 253 661 l 443 435 l 443 332 l 253 106 l 110 106 l 346 383 z "},"‹":{"ha":833,"x_min":276,"x_max":610,"o":"m 467 106 l 276 332 l 276 435 l 467 661 l 610 661 l 374 383 l 610 106 l 467 106 z "},"›":{"ha":833,"x_min":224,"x_max":557,"o":"m 460 383 l 224 661 l 367 661 l 557 435 l 557 332 l 367 106 l 224 106 l 460 383 z "},"\"":{"ha":833,"x_min":238,"x_max":596,"o":"m 474 986 l 596 986 l 596 875 l 564 532 l 479 532 l 474 986 m 238 875 l 238 986 l 360 986 l 354 532 l 269 532 l 238 875 z "},"'":{"ha":833,"x_min":356,"x_max":478,"o":"m 356 881 l 356 986 l 478 986 l 478 881 l 458 532 l 375 532 l 356 881 z "},"ƒ":{"ha":833,"x_min":69,"x_max":725,"o":"m 69 0 l 69 103 l 215 103 q 275 122 253 103 q 301 179 297 142 l 351 633 l 172 633 l 172 736 l 363 736 l 369 800 q 578 986 390 986 l 725 986 l 725 883 l 568 883 q 508 863 531 883 q 482 806 486 843 l 474 736 l 725 736 l 725 633 l 463 633 l 413 185 q 204 -1 392 -3 l 69 0 z "},"฿":{"ha":833,"x_min":125,"x_max":772,"o":"m 342 0 l 125 0 l 125 986 l 342 986 l 342 1118 l 446 1118 l 446 985 q 658 905 586 975 q 731 717 731 835 q 682 580 731 636 q 554 508 633 524 q 715 433 657 494 q 772 275 772 371 q 685 75 772 149 q 446 0 597 1 l 446 -126 l 342 -126 l 342 0 m 342 114 l 342 444 l 244 444 l 244 114 l 342 114 m 342 558 l 342 872 l 244 872 l 244 558 l 342 558 m 446 114 q 596 158 544 115 q 647 275 647 200 q 596 399 647 354 q 446 444 544 443 l 446 114 m 446 560 q 606 714 606 574 q 446 871 606 857 l 446 560 z "},"":{"ha":833,"x_min":11,"x_max":822,"o":"m 146 0 q 49 38 88 0 q 11 135 11 76 l 11 676 q 49 773 11 735 q 146 811 88 811 l 688 811 q 784 773 746 811 q 822 676 822 735 l 822 135 q 784 38 822 76 q 688 0 746 0 l 146 0 m 713 172 l 415 688 l 119 172 l 713 172 z "},"@":{"ha":833,"x_min":31,"x_max":803,"o":"m 599 -40 q 381 -97 489 -97 q 203 -40 282 -97 q 77 125 124 17 q 31 382 31 233 q 80 673 31 535 q 228 899 129 811 q 467 986 326 986 q 642 923 565 986 q 760 747 718 860 q 803 489 803 633 q 778 299 803 385 q 708 163 753 213 q 610 114 664 114 q 537 143 568 114 q 492 228 506 172 q 433 143 471 174 q 349 113 396 113 q 236 176 279 113 q 193 342 193 239 q 217 500 193 418 q 290 638 242 582 q 407 693 339 693 q 469 676 442 693 q 507 631 496 660 l 511 681 l 613 681 l 588 361 l 585 301 q 624 203 585 203 q 680 276 658 203 q 701 482 701 350 q 672 692 701 600 q 588 834 642 783 q 464 885 535 885 q 281 812 356 885 q 169 624 206 739 q 132 383 132 508 q 164 173 132 260 q 253 42 196 86 q 385 -1 311 -1 q 557 50 465 -1 l 599 -40 m 381 200 q 447 248 422 200 q 483 358 472 296 q 493 464 493 419 q 474 576 493 535 q 419 617 454 617 q 354 572 381 617 q 315 466 328 526 q 303 358 303 406 q 324 243 303 286 q 381 200 344 200 z "},"&":{"ha":833,"x_min":42,"x_max":819,"o":"m 390 -17 q 137 56 232 -17 q 42 261 42 129 q 64 377 42 331 q 121 456 86 424 q 235 546 156 488 q 142 781 142 669 q 173 899 142 846 q 256 981 204 951 q 365 1010 307 1010 q 478 981 425 1010 q 562 903 531 951 q 593 796 593 854 q 582 728 593 760 q 517 619 564 672 q 400 521 471 565 l 628 231 q 662 322 651 264 q 668 442 672 381 l 790 431 q 766 271 790 349 q 701 143 742 193 l 819 0 l 678 0 l 628 63 q 524 3 586 24 q 390 -17 463 -17 m 390 92 q 483 107 442 92 q 547 150 525 122 l 307 454 q 203 367 243 411 q 163 261 163 322 q 224 138 163 183 q 390 92 286 92 m 331 610 q 438 685 399 640 q 476 776 476 731 q 448 860 476 828 q 371 892 419 892 q 293 858 325 892 q 261 779 261 825 q 331 610 261 706 z "},"¶":{"ha":833,"x_min":58,"x_max":700,"o":"m 396 425 q 149 501 240 425 q 58 706 58 576 q 149 910 58 835 q 396 986 240 986 l 700 986 l 700 0 l 597 0 l 597 881 l 500 881 l 500 0 l 396 0 l 396 425 z "},"§":{"ha":833,"x_min":111,"x_max":722,"o":"m 431 -151 q 276 -119 347 -151 q 162 -31 206 -87 q 111 97 118 25 l 233 106 q 297 -1 243 39 q 431 -40 351 -40 q 550 -12 504 -40 q 596 63 596 15 q 553 135 596 104 q 413 190 510 165 q 197 273 260 226 q 135 399 135 319 q 267 576 135 521 q 139 728 139 631 q 220 888 139 831 q 424 946 301 946 q 551 917 486 946 q 663 835 615 889 q 719 710 710 782 l 600 701 q 540 799 589 764 q 410 835 490 835 q 303 808 346 835 q 261 738 261 781 q 276 689 261 708 q 329 653 292 669 q 438 618 367 636 q 608 561 547 592 q 696 488 669 531 q 722 381 722 446 q 604 229 722 275 q 693 156 668 199 q 718 53 718 113 q 677 -58 718 -12 q 570 -128 636 -104 q 431 -151 504 -151 m 433 292 q 554 318 508 292 q 600 390 600 344 q 539 477 600 449 q 406 506 478 506 q 299 478 342 506 q 257 410 257 451 q 433 292 257 311 z "},"©":{"ha":833,"x_min":6,"x_max":826,"o":"m 415 -17 q 203 49 296 -17 q 58 231 110 114 q 6 496 6 347 q 58 761 6 644 q 203 943 110 878 q 415 1008 296 1008 q 628 943 535 1008 q 774 761 722 878 q 826 496 826 644 q 774 231 826 347 q 628 49 722 114 q 415 -17 535 -17 m 415 79 q 583 130 511 79 q 693 275 654 181 q 732 496 732 369 q 693 717 732 624 q 583 862 654 811 q 415 913 511 913 q 248 862 319 913 q 138 717 176 811 q 100 496 100 624 q 138 275 100 369 q 248 130 176 181 q 415 79 319 79 m 425 181 q 247 266 314 181 q 181 494 181 351 q 211 660 181 589 q 297 771 242 732 q 425 810 351 810 q 528 784 479 810 q 608 710 576 758 q 647 600 640 663 l 551 593 q 509 683 543 651 q 425 714 475 714 q 314 658 351 714 q 276 494 276 601 q 314 333 276 389 q 425 276 351 276 q 511 310 476 276 q 556 408 546 344 l 651 401 q 575 241 639 301 q 425 181 511 181 z "},"®":{"ha":833,"x_min":67,"x_max":767,"o":"m 417 306 q 241 353 321 306 q 114 481 161 400 q 67 658 67 563 q 114 833 67 753 q 241 961 161 914 q 417 1008 321 1008 q 592 961 513 1008 q 719 833 672 914 q 767 658 767 753 q 719 481 767 563 q 592 353 672 400 q 417 306 513 306 m 417 385 q 553 422 490 385 q 652 522 615 458 q 689 658 689 585 q 652 794 689 732 q 553 892 615 856 q 417 929 490 929 q 281 892 343 929 q 181 794 218 856 q 144 658 144 732 q 181 522 144 585 q 281 422 218 458 q 417 385 343 385 m 278 846 l 404 846 q 520 815 478 846 q 563 728 563 783 q 540 660 563 688 q 479 622 518 632 l 578 465 l 497 465 l 408 613 l 349 613 l 349 465 l 278 465 l 278 846 m 415 672 q 473 688 453 672 q 493 731 493 703 q 473 771 493 757 q 415 785 453 785 l 349 785 l 349 672 l 415 672 z "},"℗":{"ha":833,"x_min":67,"x_max":767,"o":"m 417 306 q 241 353 321 306 q 114 481 161 400 q 67 658 67 563 q 114 833 67 753 q 241 961 161 914 q 417 1008 321 1008 q 592 961 513 1008 q 719 833 672 914 q 767 658 767 753 q 719 481 767 563 q 592 353 672 400 q 417 306 513 306 m 417 385 q 553 422 490 385 q 652 522 615 458 q 689 658 689 585 q 652 794 689 732 q 553 892 615 856 q 417 929 490 929 q 281 892 343 929 q 181 794 218 856 q 144 658 144 732 q 181 522 144 585 q 281 422 218 458 q 417 385 343 385 m 299 846 l 425 846 q 540 814 497 846 q 583 728 583 782 q 543 642 583 672 q 428 613 503 613 l 369 613 l 369 465 l 299 465 l 299 846 m 436 672 q 493 688 472 672 q 514 731 514 703 q 493 771 514 757 q 436 785 472 785 l 369 785 l 369 672 l 436 672 z "},"™":{"ha":833,"x_min":24,"x_max":801,"o":"m 457 750 l 457 499 l 375 499 l 375 986 l 464 986 l 588 607 l 714 986 l 801 986 l 801 499 l 719 499 l 719 749 l 635 499 l 542 499 l 457 750 m 138 908 l 24 908 l 24 986 l 335 986 l 335 908 l 219 908 l 219 499 l 138 499 l 138 908 z "},"°":{"ha":833,"x_min":208,"x_max":625,"o":"m 417 542 q 313 570 361 542 q 237 647 265 599 q 208 750 208 694 q 237 853 208 806 q 313 930 265 901 q 417 958 361 958 q 520 930 472 958 q 597 853 568 901 q 625 750 625 806 q 597 647 625 694 q 520 570 568 599 q 417 542 472 542 m 417 643 q 491 674 460 643 q 522 750 522 706 q 491 826 522 794 q 417 857 460 857 q 342 826 374 857 q 311 750 311 794 q 342 674 311 706 q 417 643 374 643 z "},"′":{"ha":833,"x_min":308,"x_max":525,"o":"m 396 1026 l 525 1026 l 400 568 l 308 568 l 396 1026 z "},"″":{"ha":833,"x_min":197,"x_max":638,"o":"m 285 1026 l 414 1026 l 289 568 l 197 568 l 285 1026 m 508 1026 l 638 1026 l 513 568 l 421 568 l 508 1026 z "},"|":{"ha":833,"x_min":360,"x_max":474,"o":"m 360 1069 l 474 1069 l 474 -125 l 360 -125 l 360 1069 z "},"¦":{"ha":833,"x_min":363,"x_max":471,"o":"m 363 986 l 471 986 l 471 493 l 363 493 l 363 986 m 363 285 l 471 285 l 471 -208 l 363 -208 l 363 285 z "},"†":{"ha":833,"x_min":149,"x_max":685,"o":"m 364 633 l 149 633 l 149 736 l 364 736 l 364 986 l 472 986 l 472 736 l 685 736 l 685 633 l 472 633 l 472 0 l 364 0 l 364 633 z "},"‡":{"ha":833,"x_min":149,"x_max":685,"o":"m 363 19 l 149 19 l 149 124 l 363 124 l 363 633 l 149 633 l 149 736 l 363 736 l 363 986 l 471 986 l 471 736 l 685 736 l 685 633 l 471 633 l 471 124 l 685 124 l 685 19 l 471 19 l 471 -208 l 363 -208 l 363 19 z "},"№":{"ha":833,"x_min":7,"x_max":828,"o":"m 7 986 l 167 986 l 335 304 l 335 986 l 451 986 l 451 0 l 297 0 l 124 694 l 124 0 l 7 0 l 7 986 m 517 108 l 806 108 l 806 0 l 517 0 l 517 108 m 661 196 q 541 262 588 196 q 494 436 494 328 q 541 610 494 544 q 661 675 588 675 q 781 610 735 675 q 828 436 828 544 q 781 262 828 328 q 661 196 735 196 m 661 301 q 706 338 689 301 q 724 436 724 375 q 706 533 724 497 q 661 569 689 569 q 617 533 635 569 q 600 436 600 496 q 617 338 600 375 q 661 301 635 301 z "},"␣":{"ha":833,"x_min":53,"x_max":781,"o":"m 53 0 l 163 0 l 163 -156 l 671 -156 l 671 0 l 781 0 l 781 -264 l 53 -264 l 53 0 z "},"␌":{"ha":833,"x_min":110,"x_max":744,"o":"m 461 557 l 744 557 l 744 489 l 531 489 l 531 357 l 732 357 l 732 289 l 531 289 l 531 99 l 461 99 l 461 557 m 110 875 l 393 875 l 393 807 l 181 807 l 181 675 l 381 675 l 381 607 l 181 607 l 181 417 l 110 417 l 110 875 z "},"⌧":{"ha":833,"x_min":19,"x_max":814,"o":"m 19 986 l 814 986 l 814 0 l 19 0 l 19 986 m 718 96 l 718 890 l 115 890 l 115 96 l 718 96 m 214 365 l 340 492 l 213 618 l 290 696 l 418 569 l 543 694 l 619 618 l 494 493 l 621 365 l 543 288 l 417 415 l 290 289 l 214 365 z "},"⌫":{"ha":833,"x_min":6,"x_max":822,"o":"m 6 490 l 219 986 l 822 986 l 822 0 l 219 0 l 6 490 m 726 96 l 726 890 l 285 890 l 111 492 l 285 96 l 726 96 m 251 365 l 378 492 l 250 618 l 328 696 l 454 568 l 581 694 l 656 618 l 531 493 l 657 365 l 581 288 l 453 415 l 328 289 l 251 365 z "},"⌦":{"ha":833,"x_min":11,"x_max":828,"o":"m 11 0 l 11 986 l 614 986 l 828 490 l 614 0 l 11 0 m 722 492 l 549 890 l 107 890 l 107 96 l 549 96 l 722 492 m 381 415 l 253 288 l 176 365 l 303 493 l 178 618 l 253 694 l 379 568 l 506 696 l 583 618 l 457 492 l 582 365 l 506 289 l 381 415 z "},"⏎":{"ha":833,"x_min":6,"x_max":824,"o":"m 6 419 l 419 847 l 419 581 l 501 581 l 501 986 l 824 986 l 824 258 l 419 258 l 419 -11 l 6 419 m 715 367 l 715 878 l 610 878 l 610 472 l 332 472 l 332 608 l 150 418 l 332 229 l 332 367 l 715 367 z "},"␋":{"ha":833,"x_min":72,"x_max":761,"o":"m 72 875 l 146 875 l 250 521 l 354 875 l 428 875 l 288 417 l 213 417 l 72 875 m 558 489 l 426 489 l 426 557 l 761 557 l 761 489 l 629 489 l 629 99 l 558 99 l 558 489 z "},"¢":{"ha":833,"x_min":97,"x_max":750,"o":"m 332 -3 q 160 128 222 31 q 97 368 97 225 q 178 635 97 532 q 397 751 260 738 l 408 874 l 513 874 l 501 747 q 662 667 599 732 q 744 497 725 603 l 622 489 q 574 583 608 546 q 492 635 540 621 l 444 94 q 563 140 514 97 q 628 261 611 182 l 750 253 q 642 56 726 129 q 436 -17 558 -17 l 435 -17 l 425 -126 l 321 -126 l 332 -3 m 219 368 q 251 208 219 274 q 342 114 283 143 l 388 638 q 263 551 307 621 q 219 368 219 481 z "},"¤":{"ha":833,"x_min":78,"x_max":754,"o":"m 415 135 q 258 183 332 135 l 150 75 l 78 147 l 186 256 q 138 413 138 329 q 186 569 138 499 l 78 679 l 150 751 l 260 643 q 415 690 332 690 q 571 642 500 690 l 682 751 l 754 679 l 644 568 q 693 413 693 497 q 646 257 693 329 l 754 147 l 682 75 l 572 183 q 415 135 501 135 m 415 236 q 503 260 463 236 q 567 324 543 283 q 592 413 592 365 q 567 501 592 460 q 503 565 543 542 q 415 589 463 589 q 327 565 368 589 q 263 501 286 542 q 239 413 239 460 q 263 324 239 365 q 327 260 286 283 q 415 236 368 236 z "},"$":{"ha":833,"x_min":65,"x_max":776,"o":"m 369 -17 q 158 92 240 1 q 65 315 76 182 l 190 324 q 249 177 203 233 q 369 103 294 121 l 369 457 q 205 522 265 488 q 117 606 144 557 q 89 728 89 654 q 164 917 89 842 q 369 1006 239 993 l 369 1118 l 472 1118 l 472 1004 q 669 904 594 988 q 758 694 743 821 l 633 686 q 582 817 622 764 q 472 886 542 869 l 472 549 q 651 474 585 515 q 747 378 717 433 q 776 242 776 322 q 692 60 776 132 q 472 -21 608 -11 l 472 -126 l 369 -126 l 369 -17 m 214 733 q 247 643 214 675 q 369 581 279 611 l 369 889 q 256 837 297 879 q 214 733 214 794 m 472 96 q 606 140 560 101 q 651 244 651 179 q 615 347 651 307 q 472 425 578 388 l 472 96 z "},"€":{"ha":833,"x_min":14,"x_max":786,"o":"m 14 643 l 78 643 q 197 913 106 817 q 433 1008 289 1008 q 656 923 564 1008 q 778 685 747 838 l 650 676 q 569 836 626 781 q 433 892 513 892 q 283 828 342 892 q 204 643 225 764 l 529 643 l 514 540 l 193 540 l 192 492 l 193 429 l 501 429 l 486 326 l 207 326 q 288 153 231 213 q 433 94 344 94 q 580 156 521 94 q 660 332 639 217 l 786 325 q 665 70 758 163 q 433 -22 572 -22 q 201 69 292 -22 q 81 326 110 161 l 14 326 l 14 429 l 68 429 l 67 492 l 68 540 l 14 540 l 14 643 z "},"₴":{"ha":833,"x_min":31,"x_max":803,"o":"m 31 643 l 600 643 q 635 732 635 678 q 578 847 635 801 q 432 892 521 892 q 308 849 363 892 q 219 729 253 807 l 89 742 q 209 937 121 865 q 415 1008 297 1008 q 667 933 574 1008 q 760 732 760 858 q 733 643 760 685 l 803 643 l 803 540 l 31 540 l 31 643 m 407 -17 q 231 18 308 -17 q 111 115 154 53 q 68 256 68 176 q 85 326 68 296 l 31 326 l 31 429 l 803 429 l 803 326 l 218 326 q 193 256 193 293 q 251 143 193 186 q 400 100 308 100 q 538 139 476 100 q 628 243 600 178 l 764 232 q 635 50 731 117 q 407 -17 539 -17 z "},"₽":{"ha":833,"x_min":46,"x_max":792,"o":"m 46 272 l 153 272 l 153 986 l 453 986 q 700 907 608 986 q 792 688 792 828 q 700 468 792 547 q 453 389 608 389 l 272 389 l 272 272 l 396 272 l 396 169 l 272 169 l 272 0 l 153 0 l 153 169 l 46 169 l 46 272 m 439 503 q 667 688 667 503 q 439 872 667 872 l 272 872 l 272 503 l 439 503 z "},"₹":{"ha":833,"x_min":51,"x_max":779,"o":"m 51 383 l 51 500 l 321 500 q 539 632 507 500 l 51 632 l 51 736 l 540 736 q 321 869 511 869 l 51 869 l 51 986 l 779 986 l 779 883 l 601 883 q 667 736 654 825 l 779 736 l 779 632 l 665 632 q 558 449 647 514 q 321 383 468 383 l 215 383 l 636 0 l 468 0 l 51 383 z "},"₪":{"ha":833,"x_min":28,"x_max":804,"o":"m 247 771 l 367 771 l 367 115 l 599 115 q 660 138 638 115 q 683 201 683 161 l 683 986 l 804 986 l 804 208 q 748 56 804 113 q 596 0 692 0 l 247 0 l 247 771 m 28 986 l 378 986 q 528 930 472 986 q 585 778 585 874 l 585 215 l 465 215 l 465 785 q 442 847 465 824 q 381 871 419 871 l 149 871 l 149 0 l 28 0 l 28 986 z "},"£":{"ha":833,"x_min":65,"x_max":772,"o":"m 67 121 q 226 386 226 215 l 224 440 l 65 440 l 65 543 l 200 543 l 192 571 q 160 735 160 669 q 199 874 160 811 q 305 972 238 936 q 453 1008 372 1008 q 673 941 592 1008 q 772 778 754 874 l 635 768 q 569 859 617 826 q 458 892 521 892 q 372 869 413 892 q 308 806 332 846 q 283 715 283 765 q 321 543 283 660 l 567 543 l 567 440 l 343 440 q 346 400 346 413 q 302 234 346 308 q 172 121 258 160 l 757 121 l 757 4 l 67 0 l 67 121 z "},"¥":{"ha":833,"x_min":61,"x_max":772,"o":"m 358 199 l 131 199 l 131 307 l 358 307 l 358 399 l 131 399 l 131 507 l 306 507 l 61 986 l 197 986 l 417 542 l 636 986 l 772 986 l 531 507 l 706 507 l 706 399 l 478 399 l 478 307 l 706 307 l 706 199 l 478 199 l 478 0 l 358 0 l 358 199 z "},"+":{"ha":833,"x_min":86,"x_max":747,"o":"m 363 369 l 86 369 l 86 478 l 363 478 l 363 754 l 471 754 l 471 478 l 747 478 l 747 369 l 471 369 l 471 93 l 363 93 l 363 369 z "},"−":{"ha":833,"x_min":86,"x_max":747,"o":"m 86 507 l 747 507 l 747 399 l 86 399 l 86 507 z "},"×":{"ha":833,"x_min":175,"x_max":719,"o":"m 371 389 l 175 583 l 251 660 l 447 464 l 643 660 l 719 583 l 524 389 l 719 193 l 643 117 l 447 313 l 251 117 l 175 193 l 371 389 z "},"÷":{"ha":833,"x_min":86,"x_max":747,"o":"m 86 507 l 747 507 l 747 399 l 86 399 l 86 507 m 417 117 q 358 141 382 117 q 333 200 333 165 q 358 259 333 235 q 417 283 382 283 q 476 259 451 283 q 500 200 500 235 q 476 141 500 165 q 417 117 451 117 m 417 635 q 358 659 382 635 q 333 718 333 683 q 358 777 333 753 q 417 801 382 801 q 476 777 451 801 q 500 718 500 753 q 476 659 500 683 q 417 635 451 635 z "},"=":{"ha":833,"x_min":111,"x_max":722,"o":"m 111 628 l 722 628 l 722 519 l 111 519 l 111 628 m 111 321 l 722 321 l 722 213 l 111 213 l 111 321 z "},"≠":{"ha":833,"x_min":111,"x_max":722,"o":"m 303 213 l 111 213 l 111 321 l 336 321 l 396 519 l 111 519 l 111 628 l 429 628 l 472 768 l 579 768 l 536 628 l 722 628 l 722 519 l 503 519 l 443 321 l 722 321 l 722 213 l 410 213 l 363 60 l 256 60 l 303 213 z "},">":{"ha":833,"x_min":101,"x_max":733,"o":"m 101 193 l 629 429 l 101 665 l 101 785 l 733 503 l 733 356 l 101 74 l 101 193 z "},"<":{"ha":833,"x_min":100,"x_max":732,"o":"m 100 356 l 100 503 l 732 785 l 732 665 l 204 429 l 732 193 l 732 74 l 100 356 z "},"≥":{"ha":833,"x_min":86,"x_max":747,"o":"m 86 364 l 629 508 l 86 651 l 86 765 l 747 582 l 747 435 l 86 251 l 86 364 m 86 108 l 747 108 l 747 0 l 86 0 l 86 108 z "},"≤":{"ha":833,"x_min":86,"x_max":747,"o":"m 86 435 l 86 582 l 747 765 l 747 651 l 204 508 l 747 364 l 747 251 l 86 435 m 86 0 l 86 108 l 747 108 l 747 0 l 86 0 z "},"±":{"ha":833,"x_min":86,"x_max":747,"o":"m 86 158 l 747 158 l 747 50 l 86 50 l 86 158 m 86 568 l 363 568 l 363 750 l 471 750 l 471 568 l 747 568 l 747 460 l 471 460 l 471 278 l 363 278 l 363 460 l 86 460 l 86 568 z "},"≈":{"ha":833,"x_min":110,"x_max":724,"o":"m 543 419 q 457 442 489 419 q 392 506 425 464 q 344 560 363 543 q 294 576 325 576 q 237 538 256 576 q 218 432 218 500 l 110 432 q 159 610 110 542 q 290 679 208 679 q 372 659 340 679 q 435 601 404 639 q 465 563 450 582 q 498 533 481 543 q 539 522 515 522 q 597 560 578 522 q 615 665 615 599 l 724 665 q 674 488 724 557 q 543 419 625 419 m 543 115 q 454 139 486 115 q 388 207 422 163 q 342 256 361 240 q 294 272 322 272 q 237 235 256 272 q 218 129 218 197 l 110 129 q 159 306 110 238 q 290 375 208 375 q 365 360 335 375 q 418 318 394 344 l 444 285 q 489 234 469 250 q 539 218 508 218 q 597 257 578 218 q 615 363 615 296 l 724 363 q 674 185 724 254 q 543 115 625 115 z "},"~":{"ha":833,"x_min":108,"x_max":724,"o":"m 108 328 q 154 514 108 431 q 301 597 200 597 q 388 572 357 597 q 449 501 418 547 q 488 451 472 467 q 531 436 504 436 q 588 479 567 436 q 608 597 608 522 l 724 597 q 678 411 724 494 q 531 328 632 328 q 436 354 468 328 q 375 428 404 381 q 340 474 354 460 q 301 489 325 489 q 244 446 265 489 q 224 328 224 403 l 108 328 z "},"¬":{"ha":833,"x_min":125,"x_max":668,"o":"m 560 369 l 125 369 l 125 478 l 668 478 l 668 132 l 560 132 l 560 369 z "},"^":{"ha":833,"x_min":160,"x_max":674,"o":"m 343 986 l 490 986 l 674 463 l 557 463 l 417 875 l 276 463 l 160 463 l 343 986 z "},"∞":{"ha":833,"x_min":10,"x_max":822,"o":"m 210 160 q 107 189 153 160 q 35 267 61 218 q 10 376 10 317 q 35 485 10 436 q 107 564 61 535 q 210 593 153 593 q 325 555 268 593 q 417 454 382 517 q 510 556 451 519 q 629 592 568 592 q 728 563 683 592 q 797 484 772 533 q 822 376 822 435 q 797 267 822 317 q 726 189 771 218 q 626 160 682 160 q 508 197 567 160 q 417 299 450 233 q 326 197 383 233 q 210 160 268 160 m 210 265 q 296 297 251 265 q 367 376 340 328 q 296 455 340 424 q 210 488 251 486 q 142 456 168 488 q 115 376 115 425 q 142 297 115 328 q 210 265 168 265 m 624 265 q 690 297 664 265 q 717 376 717 328 q 690 455 717 424 q 624 486 664 486 q 540 456 583 486 q 465 376 496 425 q 539 297 494 328 q 624 265 583 265 z "},"∫":{"ha":833,"x_min":153,"x_max":679,"o":"m 153 -207 l 163 -103 l 229 -106 q 289 -86 267 -107 q 315 -28 311 -65 l 406 800 q 469 940 417 893 q 615 986 521 988 l 679 985 l 669 881 l 604 883 q 544 864 567 885 q 518 806 522 843 l 426 -22 q 363 -162 415 -115 q 217 -208 311 -210 l 153 -207 z "},"∆":{"ha":833,"x_min":21,"x_max":813,"o":"m 21 176 l 339 986 l 494 986 l 813 176 l 813 0 l 21 0 l 21 176 m 724 108 l 417 888 l 110 108 l 724 108 z "},"∏":{"ha":833,"x_min":24,"x_max":810,"o":"m 179 878 l 24 878 l 24 986 l 810 986 l 810 878 l 660 878 l 660 -208 l 551 -208 l 551 878 l 288 878 l 288 -208 l 179 -208 l 179 878 z "},"∑":{"ha":833,"x_min":97,"x_max":758,"o":"m 97 117 l 350 493 l 97 869 l 97 986 l 758 986 l 758 869 l 235 869 l 486 493 l 235 117 l 758 117 l 758 0 l 97 0 l 97 117 z "},"√":{"ha":833,"x_min":24,"x_max":811,"o":"m 24 467 l 136 467 l 232 1 l 421 986 l 811 986 l 811 883 l 514 883 l 303 -208 l 164 -208 l 24 467 z "},"∂":{"ha":833,"x_min":54,"x_max":750,"o":"m 369 -17 q 203 34 275 -10 q 93 149 132 78 q 54 310 54 221 q 56 346 54 333 q 155 576 68 493 q 379 660 242 660 q 535 620 468 660 q 638 506 603 581 q 531 806 635 690 q 244 939 426 922 l 272 1046 q 547 947 440 1029 q 702 747 654 865 q 750 492 750 628 q 708 240 750 358 q 597 61 665 121 q 399 -18 501 -18 q 369 -17 379 -18 m 368 90 q 488 118 433 85 q 578 217 543 151 q 618 368 613 282 q 540 500 604 451 q 388 549 476 549 q 244 490 301 549 q 179 336 186 431 q 178 303 178 325 q 229 156 178 213 q 368 90 281 100 z "},"µ":{"ha":833,"x_min":132,"x_max":708,"o":"m 132 736 l 249 736 l 249 297 q 400 86 249 86 q 540 143 489 86 q 590 297 590 200 l 590 736 l 708 736 l 708 0 l 596 0 l 596 122 q 517 21 569 58 q 400 -17 465 -17 q 307 7 347 -17 q 249 71 267 31 l 249 -208 l 132 -208 l 132 736 z "},"%":{"ha":833,"x_min":18,"x_max":815,"o":"m 651 1008 l 783 1008 l 158 -17 l 26 -17 l 651 1008 m 643 0 q 515 67 561 0 q 469 240 469 135 q 515 412 469 344 q 643 479 561 479 q 769 412 724 479 q 815 240 815 344 q 769 68 815 136 q 643 0 724 0 m 643 106 q 694 141 675 106 q 713 240 713 176 q 694 339 713 304 q 643 374 675 374 q 592 340 610 374 q 575 240 575 306 q 592 140 575 175 q 643 106 610 106 m 190 508 q 64 576 110 508 q 18 747 18 643 q 64 919 18 851 q 190 988 110 988 q 318 920 272 988 q 364 747 364 853 q 318 576 364 643 q 190 508 272 508 m 190 614 q 242 649 224 614 q 260 747 260 683 q 242 847 260 813 q 190 882 224 882 q 140 847 158 882 q 122 747 122 811 q 140 649 122 683 q 190 614 158 614 z "},"‰":{"ha":833,"x_min":6,"x_max":829,"o":"m 529 1008 l 651 1008 l 128 -17 l 6 -17 l 529 1008 m 469 0 q 361 62 401 0 q 321 217 321 124 q 361 374 321 311 q 469 436 401 436 q 575 371 538 436 q 681 436 613 436 q 789 374 749 436 q 829 217 829 311 q 789 62 829 124 q 681 0 749 0 q 575 65 613 0 q 469 0 538 0 m 476 96 q 524 127 507 96 q 540 217 540 158 q 524 307 540 275 q 476 339 507 339 q 431 308 447 339 q 415 217 415 276 q 431 127 415 158 q 476 96 447 96 m 672 96 q 719 128 701 96 q 736 217 736 160 q 719 307 736 275 q 672 339 701 339 q 626 308 642 339 q 611 217 611 276 q 626 127 611 158 q 672 96 642 96 m 174 551 q 60 613 101 551 q 18 769 18 675 q 60 926 18 864 q 174 988 101 988 q 290 926 249 988 q 332 769 332 864 q 290 613 332 674 q 174 551 249 551 m 174 647 q 221 679 204 647 q 238 769 238 711 q 221 859 238 828 q 174 890 204 890 q 128 859 144 890 q 113 769 113 828 q 128 679 113 711 q 174 647 144 647 z "},"↑":{"ha":833,"x_min":40,"x_max":793,"o":"m 471 761 l 471 25 l 363 25 l 363 764 l 40 442 l 40 589 l 417 964 l 793 588 l 793 440 l 471 761 z "},"↗":{"ha":833,"x_min":33,"x_max":736,"o":"m 631 804 l 110 283 l 33 360 l 556 882 l 100 882 l 204 986 l 736 986 l 736 453 l 632 349 l 631 804 z "},"→":{"ha":833,"x_min":24,"x_max":824,"o":"m 622 383 l 24 383 l 24 492 l 622 492 l 315 800 l 463 800 l 824 438 l 463 76 l 314 75 l 622 383 z "},"↘":{"ha":833,"x_min":51,"x_max":754,"o":"m 572 106 l 51 626 l 128 703 l 650 181 l 650 636 l 754 532 l 754 0 l 221 0 l 117 104 l 572 106 z "},"↓":{"ha":833,"x_min":40,"x_max":793,"o":"m 793 401 l 417 25 l 40 400 l 40 547 l 363 225 l 363 964 l 471 964 l 471 228 l 793 549 l 793 401 z "},"↙":{"ha":833,"x_min":79,"x_max":782,"o":"m 613 0 l 79 0 l 79 532 l 183 636 l 183 181 l 706 703 l 782 626 l 261 106 l 717 104 l 613 0 z "},"←":{"ha":833,"x_min":10,"x_max":810,"o":"m 371 76 l 10 438 l 371 800 l 518 800 l 211 492 l 810 492 l 810 383 l 211 383 l 519 75 l 371 76 z "},"↖":{"ha":833,"x_min":97,"x_max":800,"o":"m 97 453 l 97 986 l 629 986 l 733 882 l 278 882 l 800 360 l 724 283 l 203 804 l 201 349 l 97 453 z "},"↔":{"ha":833,"x_min":3,"x_max":831,"o":"m 3 476 l 228 701 l 374 701 l 204 531 l 629 531 l 460 701 l 606 701 l 831 476 l 604 249 l 458 249 l 629 422 l 204 422 l 375 249 l 229 249 l 3 476 z "},"↕":{"ha":833,"x_min":178,"x_max":656,"o":"m 178 226 l 178 374 l 364 189 l 364 892 l 178 707 l 178 854 l 418 1093 l 656 856 l 656 708 l 472 892 l 472 189 l 656 372 l 656 225 l 418 -12 l 178 226 z "},"↝":{"ha":833,"x_min":25,"x_max":804,"o":"m 403 257 q 343 313 361 281 q 319 389 325 346 l 315 421 q 301 484 310 461 q 268 521 292 507 q 238 529 254 529 q 182 503 208 529 q 140 431 156 476 l 25 431 q 69 535 35 488 q 150 609 103 582 q 246 636 197 636 q 318 617 283 636 q 382 553 365 589 q 406 464 399 517 q 419 392 410 418 q 454 351 428 367 q 483 343 469 343 q 537 367 510 343 q 589 436 564 392 l 476 503 l 774 640 l 804 311 l 683 382 q 588 274 642 313 q 479 236 535 236 q 403 257 440 236 z "},"⇤":{"ha":833,"x_min":28,"x_max":806,"o":"m 172 436 l 440 660 l 440 490 l 806 490 l 806 382 l 442 382 l 442 213 l 172 436 m 28 707 l 136 707 l 136 165 l 28 165 l 28 707 z "},"⇥":{"ha":833,"x_min":28,"x_max":806,"o":"m 392 382 l 28 382 l 28 490 l 393 490 l 393 660 l 661 436 l 392 213 l 392 382 m 697 165 l 697 707 l 806 707 l 806 165 l 697 165 z "},"↩":{"ha":833,"x_min":24,"x_max":810,"o":"m 226 342 l 403 164 l 256 164 l 24 396 l 253 626 l 401 626 l 224 450 l 504 450 q 603 474 558 450 q 674 540 649 499 q 700 632 700 582 q 674 724 700 682 q 603 791 649 767 q 504 815 558 815 l 94 815 l 94 924 l 504 924 q 658 885 589 924 q 769 779 728 846 q 810 632 810 713 q 769 485 810 551 q 658 380 728 418 q 504 342 589 342 l 226 342 z "},"↪":{"ha":833,"x_min":24,"x_max":810,"o":"m 329 342 q 175 380 244 342 q 65 485 106 418 q 24 632 24 551 q 65 779 24 713 q 175 885 106 846 q 329 924 244 924 l 739 924 l 739 815 l 329 815 q 230 791 275 815 q 159 724 185 767 q 133 632 133 682 q 159 540 133 582 q 230 474 185 499 q 329 450 275 450 l 610 450 l 432 626 l 581 626 l 810 396 l 578 164 l 431 164 l 607 342 l 329 342 z "},"↰":{"ha":833,"x_min":25,"x_max":792,"o":"m 683 564 l 294 564 l 294 394 l 25 618 l 293 842 l 293 672 l 792 672 l 792 0 l 683 0 l 683 564 z "},"↱":{"ha":833,"x_min":42,"x_max":808,"o":"m 42 0 l 42 672 l 540 672 l 540 842 l 808 618 l 539 394 l 539 564 l 150 564 l 150 0 l 42 0 z "},"↳":{"ha":833,"x_min":42,"x_max":808,"o":"m 150 422 l 539 422 l 539 592 l 808 368 l 540 144 l 540 314 l 42 314 l 42 986 l 150 986 l 150 422 z "},"↴":{"ha":833,"x_min":19,"x_max":821,"o":"m 375 269 l 543 269 l 543 876 l 19 876 l 19 985 l 653 985 l 653 268 l 821 268 l 597 0 l 375 269 z "},"↵":{"ha":833,"x_min":25,"x_max":792,"o":"m 792 986 l 792 314 l 293 314 l 293 144 l 25 368 l 294 592 l 294 422 l 683 422 l 683 986 l 792 986 z "},"⇧":{"ha":833,"x_min":3,"x_max":831,"o":"m 418 985 l 831 550 l 639 550 l 639 0 l 197 0 l 197 550 l 3 550 l 418 985 m 306 632 l 306 108 l 531 108 l 531 632 l 607 632 l 418 835 l 228 632 l 306 632 z "},"●":{"ha":833,"x_min":22,"x_max":813,"o":"m 418 110 q 217 159 307 110 q 74 297 126 208 q 22 494 22 385 q 74 694 22 606 q 217 832 126 782 q 418 882 307 882 q 618 832 528 882 q 760 694 708 782 q 813 494 813 606 q 760 297 813 385 q 618 159 708 208 q 418 110 528 110 z "},"○":{"ha":833,"x_min":22,"x_max":813,"o":"m 418 110 q 217 160 307 110 q 74 297 126 210 q 22 494 22 385 q 74 694 22 606 q 217 832 126 782 q 418 882 307 882 q 618 832 528 882 q 760 694 708 782 q 813 494 813 606 q 760 297 813 385 q 618 160 708 210 q 418 110 528 110 m 418 214 q 564 250 499 214 q 667 350 629 286 q 704 494 704 414 q 667 641 704 576 q 564 742 629 706 q 418 778 499 778 q 272 742 338 778 q 168 641 206 706 q 131 494 131 576 q 168 350 131 414 q 272 250 206 286 q 418 214 338 214 z "},"◌":{"ha":833,"x_min":3,"x_max":829,"o":"m 653 265 q 699 319 681 290 l 767 276 q 710 206 744 240 l 653 265 m 197 150 q 125 206 158 174 l 181 263 q 239 221 208 238 l 197 150 m 78 258 q 33 339 53 292 l 108 369 q 143 307 124 332 l 78 258 m 347 97 q 261 119 308 103 l 290 194 q 360 178 329 182 l 347 97 m 13 407 q 3 496 3 446 l 83 496 q 92 424 83 460 l 13 407 m 415 174 q 489 179 460 174 l 504 101 q 415 92 456 92 l 415 174 m 8 567 q 32 653 18 619 l 108 624 q 89 554 96 596 l 8 567 m 544 196 q 608 229 585 213 l 653 161 q 574 121 621 140 l 544 196 m 65 718 q 122 788 90 757 l 178 729 q 133 675 151 703 l 65 718 m 178 833 q 258 874 211 856 l 286 799 q 222 765 254 786 l 178 833 m 725 371 q 744 440 738 403 l 824 428 q 800 340 814 375 l 725 371 m 326 894 q 414 904 364 901 l 415 822 q 342 815 381 822 l 326 894 m 749 499 q 740 571 749 536 l 819 588 q 829 499 829 547 l 749 499 m 544 800 q 474 818 517 811 l 486 899 q 572 875 528 893 l 544 800 m 725 625 q 689 688 708 663 l 757 735 q 799 656 785 692 l 725 625 m 653 732 q 596 775 631 754 l 638 844 q 710 790 679 819 l 653 732 z "},"◊":{"ha":833,"x_min":110,"x_max":722,"o":"m 110 447 l 344 894 l 488 894 l 722 447 l 488 0 l 344 0 l 110 447 m 417 93 l 603 447 l 417 801 l 229 447 l 417 93 z "},"▲":{"ha":833,"x_min":11,"x_max":821,"o":"m 415 701 l 821 0 l 11 0 l 415 701 z "},"▶":{"ha":833,"x_min":-75,"x_max":626,"o":"m 626 497 l -75 92 l -75 901 l 626 497 z "},"▼":{"ha":833,"x_min":11,"x_max":821,"o":"m 821 986 l 415 285 l 11 986 l 821 986 z "},"◀":{"ha":833,"x_min":207,"x_max":908,"o":"m 908 92 l 207 497 l 908 901 l 908 92 z "},"△":{"ha":833,"x_min":11,"x_max":821,"o":"m 415 838 l 821 136 l 11 136 l 415 838 m 644 239 l 415 635 l 188 239 l 644 239 z "},"▷":{"ha":833,"x_min":61,"x_max":763,"o":"m 763 497 l 61 92 l 61 901 l 763 497 m 164 268 l 560 497 l 164 725 l 164 268 z "},"▽":{"ha":833,"x_min":11,"x_max":821,"o":"m 821 861 l 415 160 l 11 861 l 821 861 m 415 363 l 644 758 l 188 758 l 415 363 z "},"◁":{"ha":833,"x_min":71,"x_max":772,"o":"m 772 92 l 71 497 l 772 901 l 772 92 m 274 497 l 669 268 l 669 725 l 274 497 z "},"┌":{"ha":833,"x_min":357,"x_max":833,"o":"m 357 563 l 833 563 l 833 443 l 476 443 l 476 -343 l 357 -343 l 357 563 z "},"─":{"ha":833,"x_min":-56,"x_max":833,"o":"m -56 563 l 833 563 l 833 443 l -56 443 l -56 563 z "},"└":{"ha":833,"x_min":357,"x_max":833,"o":"m 357 1404 l 476 1404 l 476 563 l 833 563 l 833 443 l 357 443 l 357 1404 z "},"│":{"ha":833,"x_min":357,"x_max":476,"o":"m 357 1406 l 476 1406 l 476 -343 l 357 -343 l 357 1406 z "},"├":{"ha":833,"x_min":357,"x_max":833,"o":"m 357 1406 l 476 1406 l 476 563 l 833 563 l 833 443 l 476 443 l 476 -343 l 357 -343 l 357 1406 z "},"̈":{"ha":0,"x_min":222,"x_max":611,"o":"m 222 988 l 344 988 l 344 851 l 222 851 l 222 988 m 489 988 l 611 988 l 611 851 l 489 851 l 489 988 z "},"̇":{"ha":0,"x_min":356,"x_max":478,"o":"m 356 988 l 478 988 l 478 851 l 356 851 l 356 988 z "},"̀":{"ha":0,"x_min":306,"x_max":528,"o":"m 306 1014 l 436 1014 l 528 836 l 431 836 l 306 1014 z "},"́":{"ha":0,"x_min":306,"x_max":528,"o":"m 397 1014 l 528 1014 l 403 836 l 306 836 l 397 1014 z "},"̋":{"ha":0,"x_min":208,"x_max":625,"o":"m 300 1014 l 431 1014 l 306 836 l 208 836 l 300 1014 m 494 1014 l 625 1014 l 500 836 l 403 836 l 494 1014 z "},"̂":{"ha":0,"x_min":222,"x_max":611,"o":"m 351 1015 l 482 1015 l 611 831 l 514 831 l 417 956 l 319 831 l 222 831 l 351 1015 z "},"̌":{"ha":0,"x_min":222,"x_max":611,"o":"m 319 1014 l 417 889 l 514 1014 l 611 1014 l 482 829 l 351 829 l 222 1014 l 319 1014 z "},"̆":{"ha":0,"x_min":222,"x_max":611,"o":"m 417 838 q 279 885 332 838 q 222 1015 226 933 l 303 1015 q 417 924 315 924 q 531 1015 518 924 l 611 1015 q 554 885 607 933 q 417 838 501 838 z "},"̊":{"ha":0,"x_min":289,"x_max":544,"o":"m 417 814 q 326 851 363 814 q 289 942 289 888 q 326 1033 289 996 q 417 1069 363 1069 q 508 1033 471 1069 q 544 942 544 996 q 508 851 544 888 q 417 814 471 814 m 417 883 q 458 900 442 883 q 475 942 475 917 q 458 983 475 967 q 417 1000 442 1000 q 375 983 392 1000 q 358 942 358 967 q 375 900 358 917 q 417 883 392 883 z "},"̃":{"ha":0,"x_min":197,"x_max":636,"o":"m 500 847 q 403 889 454 847 q 363 918 376 911 q 331 925 350 925 q 269 838 274 925 l 197 838 q 235 958 199 915 q 331 1000 272 1000 q 385 989 361 1000 q 436 956 410 978 q 474 930 458 938 q 506 922 489 922 q 546 942 532 922 q 564 1014 560 963 l 636 1014 q 596 891 632 935 q 500 847 560 847 z "},"̄":{"ha":0,"x_min":231,"x_max":603,"o":"m 231 968 l 603 968 l 603 868 l 231 868 l 231 968 z "},"̒":{"ha":0,"x_min":333,"x_max":500,"o":"m 425 949 l 472 949 l 472 824 l 333 824 l 333 943 l 422 1074 l 500 1074 l 425 949 z "},"̦":{"ha":0,"x_min":333,"x_max":500,"o":"m 408 -172 l 361 -172 l 361 -47 l 500 -47 l 500 -167 l 411 -297 l 333 -297 l 408 -172 z "},"̧":{"ha":0,"x_min":268,"x_max":567,"o":"m 431 -306 q 347 -284 383 -306 q 268 -212 311 -262 l 325 -160 q 372 -211 350 -192 q 425 -231 394 -231 q 473 -215 456 -231 q 490 -171 490 -199 q 471 -129 490 -144 q 418 -114 451 -114 q 385 -118 404 -114 l 346 -67 l 419 14 l 479 0 l 432 -51 q 529 -86 492 -51 q 567 -176 567 -121 q 527 -268 567 -231 q 431 -306 488 -306 z "},"̨":{"ha":0,"x_min":294,"x_max":539,"o":"m 419 -292 q 328 -262 361 -292 q 294 -181 294 -232 q 351 -42 294 -112 l 396 14 l 492 0 l 443 -62 q 409 -117 418 -96 q 400 -156 400 -137 q 442 -197 400 -197 q 476 -192 458 -197 q 506 -178 494 -187 l 539 -256 q 490 -282 522 -272 q 419 -292 458 -292 z "},"̵":{"ha":0,"x_min":139,"x_max":694,"o":"m 139 447 l 694 447 l 694 344 l 139 344 l 139 447 z "},"¨":{"ha":833,"x_min":222,"x_max":611,"o":"m 222 988 l 344 988 l 344 851 l 222 851 l 222 988 m 489 988 l 611 988 l 611 851 l 489 851 l 489 988 z "},"˙":{"ha":833,"x_min":356,"x_max":478,"o":"m 356 988 l 478 988 l 478 851 l 356 851 l 356 988 z "},"`":{"ha":833,"x_min":306,"x_max":528,"o":"m 306 1014 l 436 1014 l 528 836 l 431 836 l 306 1014 z "},"´":{"ha":833,"x_min":306,"x_max":528,"o":"m 397 1014 l 528 1014 l 403 836 l 306 836 l 397 1014 z "},"˝":{"ha":833,"x_min":208,"x_max":625,"o":"m 300 1014 l 431 1014 l 306 836 l 208 836 l 300 1014 m 494 1014 l 625 1014 l 500 836 l 403 836 l 494 1014 z "},"ˆ":{"ha":833,"x_min":222,"x_max":611,"o":"m 351 1015 l 482 1015 l 611 831 l 514 831 l 417 956 l 319 831 l 222 831 l 351 1015 z "},"ˇ":{"ha":833,"x_min":222,"x_max":611,"o":"m 319 1014 l 417 889 l 514 1014 l 611 1014 l 482 829 l 351 829 l 222 1014 l 319 1014 z "},"˘":{"ha":833,"x_min":222,"x_max":611,"o":"m 417 838 q 279 885 332 838 q 222 1015 226 933 l 303 1015 q 417 924 315 924 q 531 1015 518 924 l 611 1015 q 554 885 607 933 q 417 838 501 838 z "},"˚":{"ha":833,"x_min":289,"x_max":544,"o":"m 417 814 q 326 851 363 814 q 289 942 289 888 q 326 1033 289 996 q 417 1069 363 1069 q 508 1033 471 1069 q 544 942 544 996 q 508 851 544 888 q 417 814 471 814 m 417 883 q 458 900 442 883 q 475 942 475 917 q 458 983 475 967 q 417 1000 442 1000 q 375 983 392 1000 q 358 942 358 967 q 375 900 358 917 q 417 883 392 883 z "},"˜":{"ha":833,"x_min":197,"x_max":636,"o":"m 500 847 q 403 889 454 847 q 363 918 376 911 q 331 925 350 925 q 269 838 274 925 l 197 838 q 235 958 199 915 q 331 1000 272 1000 q 385 989 361 1000 q 436 956 410 978 q 474 930 458 938 q 506 922 489 922 q 546 942 532 922 q 564 1014 560 963 l 636 1014 q 596 891 632 935 q 500 847 560 847 z "},"¯":{"ha":833,"x_min":231,"x_max":603,"o":"m 231 968 l 603 968 l 603 868 l 231 868 l 231 968 z "},"¸":{"ha":833,"x_min":268,"x_max":567,"o":"m 431 -306 q 347 -284 383 -306 q 268 -212 311 -262 l 325 -160 q 372 -211 350 -192 q 425 -231 394 -231 q 473 -215 456 -231 q 490 -171 490 -199 q 471 -129 490 -144 q 418 -114 451 -114 q 385 -118 404 -114 l 346 -67 l 419 14 l 479 0 l 432 -51 q 529 -86 492 -51 q 567 -176 567 -121 q 527 -268 567 -231 q 431 -306 488 -306 z "},"˛":{"ha":833,"x_min":294,"x_max":539,"o":"m 419 -292 q 328 -262 361 -292 q 294 -181 294 -232 q 351 -42 294 -112 l 396 14 l 492 0 l 443 -62 q 409 -117 418 -96 q 400 -156 400 -137 q 442 -197 400 -197 q 476 -192 458 -197 q 506 -178 494 -187 l 539 -256 q 490 -282 522 -272 q 419 -292 458 -292 z "},"ʼ":{"ha":833,"x_min":335,"x_max":501,"o":"m 410 826 l 335 826 l 335 993 l 501 993 l 501 846 l 414 610 l 344 610 l 410 826 z "},"ʹ":{"ha":833,"x_min":306,"x_max":528,"o":"m 397 1014 l 528 1014 l 403 836 l 306 836 l 397 1014 z "},"ˈ":{"ha":833,"x_min":356,"x_max":478,"o":"m 356 881 l 356 986 l 478 986 l 478 881 l 458 593 l 375 593 l 356 881 z "}},"familyName":"Geist Mono","ascender":1278,"descender":-306,"underlinePosition":-139,"underlineThickness":69,"boundingBox":{"yMin":-343,"xMin":-75,"yMax":1406,"xMax":4081},"resolution":1000,"original_font_information":{"format":0,"fontFamily":"Geist Mono","fontSubfamily":"Regular","uniqueID":"1.200;VRCL;GeistMono-Regular","fullName":"Geist Mono Regular","version":"Version 1.200","postScriptName":"GeistMono-Regular","manufacturer":"Basement.studio, Vercel, Andrés Briganti, Guido Ferreyra, Mateo Zaragoza","designer":"Basement.studio, Andrés Briganti, Mateo Zaragoza","manufacturerURL":"https://basement.studio/","designerURL":"https://basement.studio/","preferredFamily":"Geist Mono","preferredSubfamily":"Regular","unknown1":"No tail a","unknown2":"Alt a","unknown3":"Alt l","unknown4":"Alt R","unknown5":"Alt G","unknown6":"Alt arrows","unknown7":"Rounded dot","unknown8":"Slashed zero"},"cssFontWeight":"normal","cssFontStyle":"normal"} diff --git a/apps/ui/app/components/geometry/graphics/three/geometries/label-geometry.ts b/apps/ui/app/components/geometry/graphics/three/geometries/label-geometry.ts deleted file mode 100644 index e4228e30d..000000000 --- a/apps/ui/app/components/geometry/graphics/three/geometries/label-geometry.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { BufferGeometry } from 'three'; -import { FontGeometry } from '#components/geometry/graphics/three/geometries/font-geometry.js'; -import { RoundedRectangleGeometry } from '#components/geometry/graphics/three/geometries/rounded-rectangle-geometry.js'; - -// eslint-disable-next-line @typescript-eslint/naming-convention -- Three.js naming convention -export const LabelTextGeometry = ({ - text, - size = 30, - depth = 2, -}: { - text: string; - size?: number; - depth?: number; -}): BufferGeometry => - // eslint-disable-next-line new-cap -- Three.js geometry function - FontGeometry({ text, size, depth }); - -// eslint-disable-next-line @typescript-eslint/naming-convention -- Three.js naming convention -export const LabelBackgroundGeometry = ({ - text, - characterWidth = 18, - padding = 40, - height = 70, - radius = 20, - depth = 10, -}: { - text: string; - characterWidth?: number; - padding?: number; - height?: number; - radius?: number; - depth?: number; -}): BufferGeometry => { - // Calculate width based on character count (fixed-width mono font) - const width = text.length * characterWidth + padding * 2; - - // eslint-disable-next-line new-cap -- Three.js geometry function - return RoundedRectangleGeometry({ - width, - height, - radius, - smoothness: 16, - depth, - }); -}; diff --git a/apps/ui/app/components/geometry/graphics/three/geometries/rounded-rectangle-geometry.ts b/apps/ui/app/components/geometry/graphics/three/geometries/rounded-rectangle-geometry.ts deleted file mode 100644 index 194ad991d..000000000 --- a/apps/ui/app/components/geometry/graphics/three/geometries/rounded-rectangle-geometry.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { BufferGeometry } from 'three'; -import { ExtrudeGeometry, Shape, Vector2 } from 'three'; - -// eslint-disable-next-line @typescript-eslint/naming-convention -- Three.js naming convention -export const RoundedRectangleGeometry = ({ - width, - height, - radius, - smoothness, - depth, -}: { - width: number; - height: number; - radius: number; - smoothness: number; - depth: number; -}): BufferGeometry => { - const pi2 = Math.PI * 2; - const n = (smoothness + 1) * 4; // Number of segments - const points: Vector2[] = []; - let qu: number; - let sgx: number; - let sgy: number; - let x: number; - let y: number; - - // Generate contour points - for (let j = 0; j < n; j++) { - qu = Math.trunc((4 * j) / n) + 1; // Quadrant qu: 1..4 - sgx = qu === 1 || qu === 4 ? 1 : -1; // Signum left/right - sgy = qu < 3 ? 1 : -1; // Signum top / bottom - x = sgx * (width / 2 - radius) + radius * Math.cos((pi2 * (j - qu + 1)) / (n - 4)); // Corner center + circle - y = sgy * (height / 2 - radius) + radius * Math.sin((pi2 * (j - qu + 1)) / (n - 4)); - - points.push(new Vector2(x, y)); - } - - // Create shape from points - const shape = new Shape(); - const firstPoint = points[0]; - - if (firstPoint !== undefined) { - shape.moveTo(firstPoint.x, firstPoint.y); - - for (let i = 1; i < points.length; i++) { - const point = points[i]; - - if (point !== undefined) { - shape.lineTo(point.x, point.y); - } - } - - shape.closePath(); - } - - // Extrude the shape - const geometry = new ExtrudeGeometry(shape, { depth, bevelEnabled: false }); - geometry.center(); - - return geometry; -}; diff --git a/apps/ui/app/components/geometry/graphics/three/geometries/svg-geometry.ts b/apps/ui/app/components/geometry/graphics/three/geometries/svg-geometry.ts deleted file mode 100644 index 6b53af6ad..000000000 --- a/apps/ui/app/components/geometry/graphics/three/geometries/svg-geometry.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { BufferGeometry } from 'three'; -import { Vector2, Shape, ExtrudeGeometry } from 'three'; -import { SVGLoader } from 'three/examples/jsm/Addons.js'; - -// eslint-disable-next-line @typescript-eslint/naming-convention -- Three.js naming convention -export const SvgGeometry = ({ svg, depth }: { svg: string; depth: number }): BufferGeometry => { - let geometry: BufferGeometry; - const loader = new SVGLoader(); - const svgData = loader.parse(svg); - - for (const path of svgData.paths) { - const pts = path.subPaths[0]!.getPoints(10); - pts.pop(); - for (const p of pts) { - p.y *= -1; - } - - pts.reverse(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- SvgLoader adds this - const { strokeWidth } = path.userData!['style']; - - const pPrevious = new Vector2(); - const pNext = new Vector2(); - const c = new Vector2(); - const offsetPts = []; - for (const [idx, p] of pts.entries()) { - let idxPrevious = idx - 1; - if (idxPrevious < 0) { - idxPrevious = pts.length - 1; - } - - let idxNext = idx + 1; - if (idxNext === pts.length) { - idxNext = 0; - } - - pPrevious.subVectors(pts[idxPrevious]!, p).normalize(); - pNext.subVectors(pts[idxNext]!, p).normalize(); - const anglePrevious = pPrevious.angle(); - const angleNext = pNext.angle(); - const angleMid = (angleNext - anglePrevious) * 0.5; - pPrevious.rotateAround(c, angleMid); - const offsetDist = strokeWidth / Math.cos(angleMid - Math.PI * 0.5); - offsetPts.push(pPrevious.clone().setLength(offsetDist).add(p)); - } - - const shape = new Shape(offsetPts); - geometry = new ExtrudeGeometry(shape, { depth, bevelEnabled: false }); - geometry.center(); - } - - return geometry!; -}; diff --git a/apps/ui/app/components/geometry/graphics/three/grid.tsx b/apps/ui/app/components/geometry/graphics/three/grid.tsx deleted file mode 100644 index e702aa7e4..000000000 --- a/apps/ui/app/components/geometry/graphics/three/grid.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from 'react'; -import * as THREE from 'three'; -import { InfiniteGrid } from '#components/geometry/graphics/three/react/infinite-grid.js'; -import { Theme, useTheme } from '#hooks/use-theme.js'; -import { useGraphicsSelector } from '#hooks/use-graphics.js'; - -/** - * Grid component that renders the infinite grid using sizes from the graphics machine - * and handles theme-aware color selection and coordinate system orientation. - * Uses GraphicsProvider context for per-view state. - */ -export const Grid = React.memo(() => { - const gridSizes = useGraphicsSelector((state) => state.context.gridSizes); - const upDirection = useGraphicsSelector((state) => state.context.upDirection); - const { theme } = useTheme(); - - // Calculate theme-aware grid color - const gridColor = React.useMemo( - () => (theme === Theme.LIGHT ? new THREE.Color('lightgrey') : new THREE.Color('grey')), - [theme], - ); - - // Calculate grid axes based on the up direction - // x: X-up (1,0,0) -> grid on YZ plane -> 'zyx' - // y: Y-up (0,1,0) -> grid on XZ plane -> 'xzy' - // z: Z-up (0,0,1) -> grid on XY plane -> 'xyz' - const axes = upDirection === 'x' ? ('zyx' as const) : upDirection === 'y' ? ('xzy' as const) : ('xyz' as const); - - // Memoize materialProperties to prevent InfiniteGrid from recreating its - // ShaderMaterial on every Grid re-render (the inline object would be a new reference each time). - const materialProperties = React.useMemo( - () => ({ smallSize: gridSizes.smallSize, largeSize: gridSizes.largeSize, color: gridColor }), - [gridSizes.smallSize, gridSizes.largeSize, gridColor], - ); - - return ; -}); diff --git a/apps/ui/app/components/geometry/graphics/three/icons/rotate-icon-single.svg b/apps/ui/app/components/geometry/graphics/three/icons/rotate-icon-single.svg deleted file mode 100644 index 30d8b1065..000000000 --- a/apps/ui/app/components/geometry/graphics/three/icons/rotate-icon-single.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - diff --git a/apps/ui/app/components/geometry/graphics/three/icons/rotate-icon.svg b/apps/ui/app/components/geometry/graphics/three/icons/rotate-icon.svg deleted file mode 100644 index c4b75e2c6..000000000 --- a/apps/ui/app/components/geometry/graphics/three/icons/rotate-icon.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/apps/ui/app/components/geometry/graphics/three/icons/rotation-arrow.svg b/apps/ui/app/components/geometry/graphics/three/icons/rotation-arrow.svg deleted file mode 100644 index 8231edee2..000000000 --- a/apps/ui/app/components/geometry/graphics/three/icons/rotation-arrow.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/ui/app/components/geometry/graphics/three/icons/translation-arrow.svg b/apps/ui/app/components/geometry/graphics/three/icons/translation-arrow.svg deleted file mode 100644 index 59ec7f5c7..000000000 --- a/apps/ui/app/components/geometry/graphics/three/icons/translation-arrow.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/apps/ui/app/components/geometry/graphics/three/materials/gltf-edges.ts b/apps/ui/app/components/geometry/graphics/three/materials/gltf-edges.ts deleted file mode 100644 index e5ade1974..000000000 --- a/apps/ui/app/components/geometry/graphics/three/materials/gltf-edges.ts +++ /dev/null @@ -1,380 +0,0 @@ -import type { GLTF } from 'three/addons/loaders/GLTFLoader.js'; -import type { Group, LineSegments, Vector2 } from 'three'; -import { InterleavedBufferAttribute } from 'three'; -import { LineSegments2, LineSegmentsGeometry, LineMaterial } from 'three/addons'; - -/** - * Default line width in pixels for edge rendering. - * This is screen-space width, not world units. - */ -const defaultLineWidth = 1; - -/** - * Edge color for fat line materials. - * Default: black (matching middleware) - */ -const defaultEdgeColor = 0x00_00_00; - -/** - * Base depth bias factor for edge lines with logarithmic depth buffer. - * - * This multiplicative factor is applied to vFragDepth BEFORE taking log2. - * Due to logarithm properties (log(a*b) = log(a) + log(b)), this produces - * a constant additive offset in log space, which scales correctly with - * scene size and depth precision. - * - * FOV-ADAPTIVE SCALING: - * - * The shader dynamically adjusts this bias based on camera FOV to maintain - * correct occlusion at all FOV values. At low FOV (near-orthographic), the - * camera is far away, and the depth buffer range for geometry becomes - * compressed. A fixed bias would be too aggressive, causing lines to - * incorrectly show through occluding geometry. - * - * The adjustment formula uses: adjustedBias = pow(baseBias, fovScale) - * where fovScale = tan(fov/2) / tan(30°) - * - * Effective values at different FOV: - * - At 60° FOV: adjustedBias = 0.9999 (unchanged, this is the reference) - * - At 6° FOV: adjustedBias ≈ 0.99999 (10x less bias) - * - At 0.6° FOV: adjustedBias ≈ 0.999999 (100x less bias) - * - * IMPORTANT TRADE-OFF (MSAA vs Occlusion): - * - * Writing to gl_FragDepth (required for logarithmic depth buffer) breaks MSAA - * anti-aliasing at the subsample level: - * - * - With subtle bias (0.999): Some subsamples see the line, some don't due to - * z-fighting. This causes partial coverage → lines appear thinner/rougher. - * - * - With aggressive bias (0.99): ALL subsamples agree the line is visible. - * Full coverage → smooth lines. BUT lines may incorrectly show through - * geometry that should occlude them. - * - * This is a fundamental limitation of gl_FragDepth + MSAA. Alternatives: - * - Use `reverseDepthBuffer: true` instead of `logarithmicDepthBuffer: true` - * (avoids gl_FragDepth, restores MSAA, requires EXT_clip_control support) - * - Use post-process AA (FXAA/SMAA) instead of MSAA - * - Accept the trade-off and tune this value for your use case - */ -const depthBiasFactor = 0.999; - -/** - * Extract positions from indexed geometry with InterleavedBufferAttribute. - */ -function extractFromInterleavedIndexed( - positionAttribute: InterleavedBufferAttribute, - indices: Iterable, -): number[] { - const interleavedBuffer = positionAttribute.data; - const { stride } = interleavedBuffer; - const { offset } = positionAttribute; - const { array } = interleavedBuffer; - const positions: number[] = []; - - for (const index of indices) { - const vertexIndex = index * stride + offset; - const x = array[vertexIndex] ?? 0; - const y = array[vertexIndex + 1] ?? 0; - const z = array[vertexIndex + 2] ?? 0; - positions.push(x, y, z); - } - - return positions; -} - -/** - * Extract positions from non-indexed geometry with InterleavedBufferAttribute. - */ -function extractFromInterleavedNonIndexed(positionAttribute: InterleavedBufferAttribute): number[] { - const interleavedBuffer = positionAttribute.data; - const { stride } = interleavedBuffer; - const { offset } = positionAttribute; - const { array } = interleavedBuffer; - const { count } = positionAttribute; - const positions: number[] = []; - - for (let i = 0; i < count; i++) { - const vertexIndex = i * stride + offset; - const x = array[vertexIndex] ?? 0; - const y = array[vertexIndex + 1] ?? 0; - const z = array[vertexIndex + 2] ?? 0; - positions.push(x, y, z); - } - - return positions; -} - -/** - * Extract positions from indexed geometry with regular BufferAttribute. - */ -function extractFromRegularIndexed(array: Float32Array, indices: Iterable): number[] { - const positions: number[] = []; - - for (const index of indices) { - const vertexIndex = index * 3; - const x = array[vertexIndex] ?? 0; - const y = array[vertexIndex + 1] ?? 0; - const z = array[vertexIndex + 2] ?? 0; - positions.push(x, y, z); - } - - return positions; -} - -/** - * Extract positions from a LineSegments geometry, handling both regular and interleaved buffers. - * Expands indexed geometry to non-indexed positions as required by LineSegmentsGeometry. - * - * @param lineSegments - The LineSegments object to extract positions from - * @returns Array of position values [x1, y1, z1, x2, y2, z2, ...] or undefined if extraction fails - */ -function extractPositions(lineSegments: LineSegments): number[] | undefined { - const { geometry } = lineSegments; - const positionAttribute = geometry.attributes['position']; - - if (!positionAttribute) { - console.warn('[FatLines] No position attribute found on LineSegments'); - return undefined; - } - - const indexAttribute = geometry.index; - - // Handle InterleavedBufferAttribute (GLTFLoader optimization) - if (positionAttribute instanceof InterleavedBufferAttribute) { - if (indexAttribute) { - return extractFromInterleavedIndexed(positionAttribute, indexAttribute.array); - } - - return extractFromInterleavedNonIndexed(positionAttribute); - } - - // Regular BufferAttribute - const array = positionAttribute.array as Float32Array; - - if (indexAttribute) { - return extractFromRegularIndexed(array, indexAttribute.array); - } - - // Non-indexed regular buffer - copy directly - return [...array]; -} - -/** - * Create a LineMaterial with FOV-adaptive logarithmic depth bias for edge rendering. - * - * Uses onBeforeCompile to modify the fragment shader's logarithmic depth - * calculation, applying a multiplicative bias to vFragDepth before the log2 - * operation. The bias is dynamically scaled based on camera FOV (derived from - * the projection matrix) to maintain correct occlusion at all FOV values, - * from wide-angle perspective to near-orthographic views. - * - * @param resolution - The viewport resolution for line width calculation - * @returns A configured LineMaterial with FOV-adaptive depth bias - */ -function createEdgeLineMaterial(resolution: Vector2): LineMaterial { - const material = new LineMaterial({ - color: defaultEdgeColor, - linewidth: defaultLineWidth, - worldUnits: false, // Screen-space pixels - resolution: resolution.clone(), - // Keep depth test enabled for proper occlusion - }); - - // Apply depth bias in fragment shader for logarithmic depth buffer. - // This prevents z-fighting between edge lines and co-planar mesh surfaces. - // - // MATHEMATICAL REASONING: - // The logarithmic depth formula is: gl_FragDepth = log2(vFragDepth) * logDepthBufFC * 0.5 - // Our biased version multiplies vFragDepth by a factor < 1.0: - // gl_FragDepth = log2(vFragDepth * depthBias) * logDepthBufFC * 0.5 - // - // Due to logarithm properties: log2(a * b) = log2(a) + log2(b) - // So: log2(vFragDepth * 0.999) = log2(vFragDepth) + log2(0.999) - // - // Since log2(0.999) ≈ -0.00144 is a CONSTANT, this multiplicative approach - // produces a CONSTANT ADDITIVE OFFSET in logarithmic space. - // - // This is ideal because: - // 1. Logarithmic depth distributes precision logarithmically - near objects - // get high precision, far objects get less. - // 2. A constant offset in log space means the bias is always proportional - // to the available precision at that depth. - // 3. It scales automatically with scene size (logDepthBufFC is derived from - // the camera's far plane). - // - // Result: The bias works consistently for any scene size - from tiny precision - // parts to massive architectural models - without needing depth-dependent tuning. - // Create shared uniform object so it can be updated at runtime - const depthBiasUniform = { value: depthBiasFactor }; - - material.onBeforeCompile = (shader) => { - // Add depthBias uniform for runtime adjustment (shared reference) - shader.uniforms['depthBias'] = depthBiasUniform; - - // Add varying to pass FOV scale from vertex to fragment shader. - // projectionMatrix is only available in vertex shader for LineMaterial. - shader.vertexShader = shader.vertexShader.replace( - '#include ', - `#include - varying float vFovScale;`, - ); - - // Calculate FOV scale in vertex shader and pass to fragment - shader.vertexShader = shader.vertexShader.replace( - '#include ', - `#include - // FOV scale for adaptive depth bias - // Check if perspective camera (projectionMatrix[3][3] == 0 for perspective) - if (projectionMatrix[3][3] == 0.0) { - // projectionMatrix[1][1] = 1 / tan(fov/2) for perspective cameras - float tanHalfFov = 1.0 / projectionMatrix[1][1]; - float tanHalfRefFov = 0.57735; // tan(30°) for 60° reference FOV - vFovScale = tanHalfFov / tanHalfRefFov; - } else { - // Orthographic camera - use default scale - vFovScale = 1.0; - }`, - ); - - // Declare the varying and uniform in fragment shader - shader.fragmentShader = shader.fragmentShader.replace( - '#include ', - `#include - uniform float depthBias; - varying float vFovScale;`, - ); - - // Use the varying in the depth calculation - shader.fragmentShader = shader.fragmentShader.replace( - '#include ', - `#if defined( USE_LOGDEPTHBUF ) - // FOV-adaptive depth bias for correct occlusion at all FOV values. - // At low FOV (near-orthographic), camera distance is large and a fixed bias - // becomes too aggressive relative to geometry depth separation. - // vFovScale is calculated in vertex shader from projectionMatrix. - float adjustedBias = pow(depthBias, vFovScale); - - float biasedFragDepth = vFragDepth * adjustedBias; - #if defined( USE_LOGDEPTHBUF_EXT ) - gl_FragDepthEXT = log2( biasedFragDepth ) * logDepthBufFC * 0.5; - #else - gl_FragDepth = log2( biasedFragDepth ) * logDepthBufFC * 0.5; - #endif - #endif`, - ); - }; - - // Store reference to uniform for runtime updates - // To adjust: material.userData['depthBiasUniform'].value = 0.995; - material.userData['depthBiasUniform'] = depthBiasUniform; - - return material; -} - -/** - * Convert a LineSegments object to LineSegments2 for fat line rendering. - * - * @param lineSegments - The LineSegments object to convert - * @param resolution - The viewport resolution for line width calculation - * @returns A LineSegments2 object or undefined if conversion fails - */ -function convertToLineSegments2(lineSegments: LineSegments, resolution: Vector2): LineSegments2 | undefined { - const positions = extractPositions(lineSegments); - - if (!positions || positions.length === 0) { - console.warn('[FatLines] Failed to extract positions from LineSegments'); - return undefined; - } - - // Create LineSegmentsGeometry with expanded positions - const geometry = new LineSegmentsGeometry(); - geometry.setPositions(positions); - - // Create LineMaterial with custom depth bias via onBeforeCompile - const material = createEdgeLineMaterial(resolution); - - const lineSegments2 = new LineSegments2(geometry, material); - - // Copy transforms from original - lineSegments2.position.copy(lineSegments.position); - lineSegments2.rotation.copy(lineSegments.rotation); - lineSegments2.scale.copy(lineSegments.scale); - lineSegments2.quaternion.copy(lineSegments.quaternion); - - // Copy name and userData - lineSegments2.name = lineSegments.name; - lineSegments2.userData = { ...lineSegments.userData }; - - // Render lines after meshes - lineSegments2.renderOrder = 1; - - return lineSegments2; -} - -/** - * Apply fat line segments to a GLTF scene by converting LineSegments to LineSegments2. - * - * This function traverses the GLTF scene, finds all LineSegments objects (created by - * the edge detection middleware), and converts them to LineSegments2 for fat line - * rendering with constant screen-space width. - * - * @param gltf - The GLTF scene to process - * @param resolution - The viewport resolution for line width calculation - */ -export function applyFatLineSegments(gltf: GLTF, resolution: Vector2): void { - // Collect LineSegments for replacement (avoid modifying during traversal) - const replacements: Array<{ - parent: Group; - oldChild: LineSegments; - newChild: LineSegments2; - }> = []; - - gltf.scene.traverse((object) => { - if (object.type === 'LineSegments') { - const lineSegments = object as LineSegments; - const parent = lineSegments.parent as Group | undefined; - - if (parent) { - const lineSegments2 = convertToLineSegments2(lineSegments, resolution); - if (lineSegments2) { - replacements.push({ parent, oldChild: lineSegments, newChild: lineSegments2 }); - } - } - } - }); - - // Perform replacements - for (const { parent, oldChild, newChild } of replacements) { - parent.remove(oldChild); - parent.add(newChild); - - // Dispose old geometry and material - oldChild.geometry.dispose(); - if (Array.isArray(oldChild.material)) { - for (const material of oldChild.material) { - material.dispose(); - } - } else { - oldChild.material.dispose(); - } - } -} - -/** - * Update the resolution of all LineMaterial instances in a scene. - * Call this when the viewport size changes to maintain correct line widths. - * - * @param scene - The scene to update - * @param resolution - The new viewport resolution - */ -export function updateLineMaterialResolution(scene: Group, resolution: Vector2): void { - scene.traverse((object) => { - if (object instanceof LineSegments2) { - const { material } = object; - if ('resolution' in material) { - (material as { resolution: Vector2 }).resolution.copy(resolution); - } - } - }); -} diff --git a/apps/ui/app/components/geometry/graphics/three/materials/gltf-matcap.ts b/apps/ui/app/components/geometry/graphics/three/materials/gltf-matcap.ts deleted file mode 100644 index bf9c9fbca..000000000 --- a/apps/ui/app/components/geometry/graphics/three/materials/gltf-matcap.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { GLTF } from 'three/addons/loaders/GLTFLoader.js'; -import type { Mesh, Material, Scene, Texture } from 'three'; -import { DoubleSide, MeshMatcapMaterial } from 'three'; -import { LineSegments2 } from 'three/addons'; -import { matcapMaterial } from '#components/geometry/graphics/three/materials/matcap-material.js'; - -/** - * Dispose a material or array of materials, releasing GPU resources. - */ -function disposeMaterials(material: Material | Material[]): void { - const materials = Array.isArray(material) ? material : [material]; - for (const mat of materials) { - mat.dispose(); - } -} - -/** - * Apply Three.js matcap to a GLTF scene, respecting vertex colors and material colors. - * - * Note: LineSegments2 extends Mesh but uses LineMaterial for fat line rendering. - * We must exclude LineSegments2 from matcap application to preserve edge rendering. - * - * @param gltf - The GLTF scene to apply matcap to. - */ -export const applyMatcap = async (gltf: GLTF): Promise => { - // Load matcap texture - const matcapTexture = matcapMaterial(); - - gltf.scene.traverse((child) => { - // Skip LineSegments2 - they extend Mesh but use LineMaterial for fat lines - if (child instanceof LineSegments2) { - return; - } - - if ('isMesh' in child && child.isMesh) { - const meshMatcap = new MeshMatcapMaterial({ - matcap: matcapTexture, - side: DoubleSide, - }); - const mesh = child as Mesh; - - const hasVertexColors = Boolean(mesh.geometry.attributes['color'] ?? mesh.geometry.attributes['COLOR_0']); - - if (hasVertexColors) { - meshMatcap.vertexColors = true; - } else { - if ('color' in mesh.material) { - const material = mesh.material as { color: { getHexString(): string } }; - meshMatcap.color.set(`#${material.color.getHexString()}`); - } - - if ('opacity' in mesh.material) { - const material = mesh.material as { opacity: number }; - meshMatcap.opacity = material.opacity; - if (material.opacity < 1) { - meshMatcap.transparent = true; - } - } - } - - // Dispose the old material(s) before replacing to prevent GPU memory leaks - disposeMaterials(mesh.material); - - mesh.material = meshMatcap; - } - }); -}; - -/** - * Apply matcap materials to a cloned scene for screenshot rendering. - * - * Unlike {@link applyMatcap}, this function does **not** dispose the original - * materials because `scene.clone()` creates meshes that share material - * references with the live scene. Disposing them would corrupt the original. - * - * @param scene - The cloned THREE.Scene to apply matcap materials to. - * @param matcapTexture - A fully-loaded matcap texture (use `ensureMatcapTextureLoaded()`). - */ -export function applyMatcapToClonedScene(scene: Scene, matcapTexture: Texture): void { - scene.traverse((child) => { - // Skip LineSegments2 — they extend Mesh but use LineMaterial for fat lines - if (child instanceof LineSegments2) { - return; - } - - if ('isMesh' in child && child.isMesh) { - const mesh = child as Mesh; - const meshMatcap = new MeshMatcapMaterial({ - matcap: matcapTexture, - side: DoubleSide, - }); - - const hasVertexColors = Boolean(mesh.geometry.attributes['color'] ?? mesh.geometry.attributes['COLOR_0']); - - if (hasVertexColors) { - meshMatcap.vertexColors = true; - } else { - if ('color' in mesh.material) { - const material = mesh.material as { color: { getHexString(): string } }; - meshMatcap.color.set(`#${material.color.getHexString()}`); - } - - if ('opacity' in mesh.material) { - const material = mesh.material as { opacity: number }; - meshMatcap.opacity = material.opacity; - if (material.opacity < 1) { - meshMatcap.transparent = true; - } - } - } - - // Do NOT dispose — materials are shared references with the original live scene - mesh.material = meshMatcap; - } - }); -} - -/** - * Dispose matcap materials that were applied to a cloned screenshot scene. - * - * Traverses the scene and disposes each mesh's material. The shared matcap - * texture singleton is unaffected — `Material.dispose()` only releases the - * compiled shader program, not referenced textures. - */ -export function disposeClonedSceneMaterials(scene: Scene): void { - scene.traverse((child) => { - if ('isMesh' in child && child.isMesh) { - const mesh = child as Mesh; - disposeMaterials(mesh.material); - } - }); -} diff --git a/apps/ui/app/components/geometry/graphics/three/materials/infinite-grid-material.ts b/apps/ui/app/components/geometry/graphics/three/materials/infinite-grid-material.ts deleted file mode 100644 index 51b5397e3..000000000 --- a/apps/ui/app/components/geometry/graphics/three/materials/infinite-grid-material.ts +++ /dev/null @@ -1,348 +0,0 @@ -import * as THREE from 'three'; - -export type InfiniteGridMaterialProperties = { - /** - * The distance between the lines of the small grid. - * Increasing makes the small grid lines more sparse/farther apart. - */ - readonly smallSize?: number; - /** - * The thickness of the lines of the small grid in screen pixels. - * Increasing makes small grid lines thicker and more prominent. - * @default 1.25 - */ - readonly smallThickness?: number; - /** - * The distance between the lines of the large grid. - * Increasing makes large grid lines more sparse/farther apart. - */ - readonly largeSize?: number; - /** - * The thickness of the lines of the large grid in screen pixels. - * Increasing makes large grid lines thicker and more prominent. - * @default 2 - */ - readonly largeThickness?: number; - /** - * The color of the grid. - * Use darker colors for better visibility against light backgrounds. - * Use lighter colors for better visibility against dark backgrounds. - */ - readonly color?: THREE.Color; - /** - * The axes to use for the grid. - * Defines the plane orientation of the grid. - * - 'xyz': Grid on XY plane with Z as normal (standard top-down view) - * - 'xzy': Grid on XZ plane with Y as normal (standard front view) - * - 'zyx': Grid on ZY plane with X as normal (standard side view) - * @default 'xyz' - */ - readonly axes?: 'xyz' | 'xzy' | 'zyx'; - /** - * The base opacity of the grid lines. - * Increasing makes the entire grid more visible/opaque. - * @default 0.3 - */ - readonly lineOpacity?: number; - /** - * Minimum grid distance to ensure visibility. - * Increasing ensures grid is always drawn at least this far from camera. - * @default 10 - */ - readonly minGridDistance?: number; - /** - * Controls how far the grid extends from the camera. - * Increasing extends the grid farther from the camera, creating a larger visible area. - * @default 10 - */ - readonly gridDistanceMultiplier?: number; - /** - * Alpha threshold for fragment discard (transparency cutoff). - * Increasing makes semi-transparent areas of the grid fully transparent. - * @default 0.01 - */ - readonly alphaThreshold?: number; - /** - * The fade start value for grid smoothstep (0-1). Lower values start fading closer to the camera. - * @default 0.05 - */ - readonly fadeStart?: number; - /** - * The fade end value for grid smoothstep (0-1). Higher values end fading further from the camera. - * @default 0.2 - */ - readonly fadeEnd?: number; - /** - * Offset applied to the grid along its normal axis to prevent z-fighting with other geometry. - * Increasing this value pushes the grid further away from the plane. - * @default 0.001 - */ - readonly normalOffset?: number; -}; - -/** - * Maps string-based axes to numeric indices for the shader uniform. - * This provides a user-friendly API while maintaining shader security by avoiding string interpolation. - */ -function mapAxesToIndex(axes: 'xyz' | 'xzy' | 'zyx'): 0 | 1 | 2 { - const mapping = { - xyz: 0, - xzy: 1, - zyx: 2, - } as const; - return mapping[axes]; -} - -// Original Author: Fyrestar https://mevedia.com (https://github.com/Fyrestar/THREE.InfiniteGridHelper) -// Modified by @rifont to: -// - use varying thickness and enhanced distance falloff -// - work correctly with logarithmic depth buffer -// - use secure uniform-based axis configuration instead of string interpolation -export function infiniteGridMaterial(properties?: InfiniteGridMaterialProperties): THREE.ShaderMaterial { - const { - smallSize = 1, - largeSize = 100, - color = new THREE.Color('grey'), - axes = 'xyz', - smallThickness = 1.25, - largeThickness = 2, - lineOpacity = 0.3, - minGridDistance = 10, - gridDistanceMultiplier = 20, - fadeStart = 0.05, - fadeEnd = 0.2, - alphaThreshold = 0.01, - normalOffset = 0.001, - } = properties ?? {}; - - // Validate and convert axes parameter to numeric index - if (!['xyz', 'xzy', 'zyx'].includes(axes)) { - throw new Error('Invalid axes parameter: must be "xyz", "xzy", or "zyx"'); - } - - const axesIndex = mapAxesToIndex(axes); - - const material = new THREE.ShaderMaterial({ - side: THREE.DoubleSide, - transparent: true, - depthWrite: false, - uniforms: { - uSmallSize: { - value: smallSize, - }, - uLargeSize: { - value: largeSize, - }, - uColor: { - value: color, - }, - uSmallThickness: { - value: smallThickness, - }, - uLargeThickness: { - value: largeThickness, - }, - uLineOpacity: { - value: lineOpacity, - }, - uMinGridDistance: { - value: minGridDistance, - }, - uGridDistanceMultiplier: { - value: gridDistanceMultiplier, - }, - uAlphaThreshold: { - value: alphaThreshold, - }, - uFadeStart: { - value: fadeStart, - }, - uFadeEnd: { - value: fadeEnd, - }, - uAxes: { - value: axesIndex, - }, - uNormalOffset: { - value: normalOffset, - }, - }, - - vertexShader: ` - #include - #include - varying vec3 worldPosition; - - uniform float uGridDistanceMultiplier; - uniform float uMinGridDistance; - uniform float uNormalOffset; - uniform int uAxes; - - void main() { - // Calculate the camera distance - float cameraDistance = length(cameraPosition); - - // Calculate grid distance without distance normalization - float gridDistance = cameraDistance * uGridDistanceMultiplier; - - // Always ensure a reasonable minimum distance - gridDistance = max(gridDistance, uMinGridDistance); - - // Scale the grid based on the calculated distance - // Use conditional logic instead of string interpolation for security - vec3 pos; - if (uAxes == 0) { - // xyz: Grid on XY plane with Z as normal - pos = position.xyz * gridDistance; - pos.z -= uNormalOffset; - } else if (uAxes == 1) { - // xzy: Grid on XZ plane with Y as normal - pos = position.xzy * gridDistance; - pos.y -= uNormalOffset; - } else { - // zyx: Grid on ZY plane with X as normal - pos = position.zyx * gridDistance; - pos.x -= uNormalOffset; - } - - worldPosition = pos; - - gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0); - - #include - } - `, - - fragmentShader: ` - #include - #include - - varying vec3 worldPosition; - - uniform float uSmallSize; - uniform float uLargeSize; - uniform float uSmallThickness; - uniform float uLargeThickness; - uniform vec3 uColor; - uniform float uLineOpacity; - uniform float uGridDistanceMultiplier; - uniform float uMinGridDistance; - uniform float uAlphaThreshold; - uniform float uFadeStart; - uniform float uFadeEnd; - uniform int uAxes; - - // Pristine Grid — based on Ben Golus's "The Best Darn Grid Shader (Yet)" - // https://bgolus.medium.com/the-best-darn-grid-shader-yet-727f9278b9d8 - // Adapted for constant-pixel-width lines with phone-wire AA, - // draw width clamping, Moire suppression, and premultiplied alpha blending. - float pristineGrid(vec2 uv, float thickness) { - // Per-axis screen-space derivatives using length() instead of fwidth(). - // fwidth() = abs(dFdx) + abs(dFdy) overestimates on diagonals; - // length() gives the geometrically correct derivative magnitude per axis. - vec4 uvDDXY = vec4(dFdx(uv), dFdy(uv)); - vec2 uvDeriv = vec2(length(uvDDXY.xz), length(uvDDXY.yw)); - - // Convert pixel thickness to UV-space line width (fraction of cell). - // Clamp to [0, 1] since a line cannot be wider than the cell itself. - vec2 targetWidth = clamp(uvDeriv * thickness, 0.0, 1.0); - - // Phone-wire AA + draw width clamping: - // - min = uvDeriv: line is never thinner than 1 screen pixel - // (prevents sub-pixel aliasing; instead lines stay 1px and fade) - // - max = 0.5: ensures correct brightness convergence at the horizon - // (at 0.5, average intensity matches the target, preventing dark gutters) - vec2 drawWidth = clamp(targetWidth, uvDeriv, vec2(0.5)); - - // 1.5px AA border — smoothstep with 1.5 pixel width produces - // a similar perceived sharpness to a 1px linear gradient, but smoother. - vec2 lineAA = max(uvDeriv, 0.000001) * 1.5; - - // Distance to nearest grid line (0 at line center, 0.5 at midpoint) - vec2 gridUV = 1.0 - abs(fract(uv) * 2.0 - 1.0); - - // Smooth antialiased grid lines - vec2 grid2 = smoothstep(drawWidth + lineAA, drawWidth - lineAA, gridUV); - - // Phone-wire AA intensity fade: when lines were expanded beyond their - // target width to stay at minimum 1px, reduce opacity proportionally. - // This creates the illusion of sub-pixel lines fading out gracefully - // rather than aliasing as they recede into the distance. - grid2 *= clamp(targetWidth / drawWidth, 0.0, 1.0); - - // Moire suppression: when grid cells approach sub-pixel size - // (uvDeriv > 0.5), smoothly transition from individual lines to a - // solid average color. This eliminates interference patterns that - // appear when multiple grid cells fall within a single pixel. - grid2 = mix(grid2, targetWidth, clamp(uvDeriv * 2.0 - 1.0, 0.0, 1.0)); - - // Premultiplied alpha blend to combine both axes. - // Equivalent to: grid2.x * (1.0 - grid2.y) + grid2.y - // This correctly composites overlapping transparent lines, - // unlike max() which loses intensity at intersections. - return mix(grid2.x, 1.0, grid2.y); - } - - void main() { - #include - - // Extract plane axes based on configuration - // Use conditional logic instead of string interpolation for security - vec2 worldPlane; - vec2 cameraPlane; - - if (uAxes == 0) { - // xyz: Grid on XY plane - worldPlane = worldPosition.xy; - cameraPlane = cameraPosition.xy; - } else if (uAxes == 1) { - // xzy: Grid on XZ plane - worldPlane = worldPosition.xz; - cameraPlane = cameraPosition.xz; - } else { - // zyx: Grid on ZY plane - worldPlane = worldPosition.zy; - cameraPlane = cameraPosition.zy; - } - - // Calculate planar distance - distance in the grid plane - float planarDistance = distance(cameraPlane, worldPlane); - - // Calculate the camera distance - float cameraDistance = length(cameraPosition); - - // Calculate grid distance with scaling factors - float gridDistance = cameraDistance * uGridDistanceMultiplier; - - // Ensure minimum distance - gridDistance = max(gridDistance, uMinGridDistance); - - // Calculate distance ratio - float distanceRatio = planarDistance / gridDistance; - - // Calculate fade factor using smoothstep for cleaner fade - float fadeFactor = smoothstep(uFadeEnd, uFadeStart, distanceRatio); - - // Compute grid for both scales using Pristine Grid algorithm. - // Each grid gets its own UV space (worldPlane / size) so the - // derivative-based antialiasing is computed per-scale. - float gridSmall = pristineGrid(worldPlane / uSmallSize, uSmallThickness); - float gridLarge = pristineGrid(worldPlane / uLargeSize, uLargeThickness); - - // Combine grids using premultiplied alpha blend (large over small). - // Where large grid lines exist, they take priority; elsewhere the - // small grid shows through. This is equivalent to layered alpha - // compositing and produces correct brightness at intersections. - float grid = mix(gridSmall, 1.0, gridLarge); - - // Apply final color with basic opacity - gl_FragColor = vec4(uColor.rgb, grid * fadeFactor * uLineOpacity); - - // Use a simple alpha threshold - if (gl_FragColor.a < uAlphaThreshold) discard; - } - `, - }); - - return material; -} diff --git a/apps/ui/app/components/geometry/graphics/three/materials/matcap-material.ts b/apps/ui/app/components/geometry/graphics/three/materials/matcap-material.ts deleted file mode 100644 index 5b80d48e8..000000000 --- a/apps/ui/app/components/geometry/graphics/three/materials/matcap-material.ts +++ /dev/null @@ -1,42 +0,0 @@ -import * as THREE from 'three'; -import { TextureLoader } from 'three'; - -/** - * Cached matcap texture singleton. - * Loaded once and reused across all calls to avoid redundant I/O and GPU uploads. - */ -let cachedMatcapTexture: THREE.Texture | undefined; - -export const matcapMaterial = (): THREE.Texture => { - if (cachedMatcapTexture) { - return cachedMatcapTexture; - } - - const textureLoader = new TextureLoader(); - const matcapTexture = textureLoader.load('/textures/matcap-soft.png'); - matcapTexture.colorSpace = THREE.SRGBColorSpace; - cachedMatcapTexture = matcapTexture; - return matcapTexture; -}; - -/** - * Ensure the matcap texture is fully loaded before use. - * - * The synchronous `matcapMaterial()` returns a texture object immediately but - * loads pixel data asynchronously. This async variant guarantees the texture - * image is available, which is required for offline rendering (screenshots) - * where the GPU must sample real texels on the first draw call. - * - * Uses the same singleton cache — subsequent calls resolve instantly. - */ -export async function ensureMatcapTextureLoaded(): Promise { - if (cachedMatcapTexture?.image) { - return cachedMatcapTexture; - } - - const textureLoader = new TextureLoader(); - const texture = await textureLoader.loadAsync('/textures/matcap-soft.png'); - texture.colorSpace = THREE.SRGBColorSpace; - cachedMatcapTexture = texture; - return texture; -} diff --git a/apps/ui/app/components/geometry/graphics/three/materials/striped-material.ts b/apps/ui/app/components/geometry/graphics/three/materials/striped-material.ts deleted file mode 100644 index 726949b38..000000000 --- a/apps/ui/app/components/geometry/graphics/three/materials/striped-material.ts +++ /dev/null @@ -1,133 +0,0 @@ -import * as THREE from 'three'; - -type StripedMaterialProperties = { - /** - * The frequency of the stripes (distance between stripes in pixels). - * @default 2 - */ - readonly stripeFrequency?: number; - /** - * The width of each stripe in pixels. - * @default 0.25 - */ - readonly stripeWidth?: number; - /** - * The base color of the material. - * @default 0xffffff (white) - */ - readonly baseColor?: number; - /** - * The color of the stripes. - * @default 0xffffff (white) - */ - readonly stripeColor?: number; - /** - * Stripe angle in radians (screen space). 0 = horizontal, PI/2 = vertical. - * @default Math.PI / 4 (45° diagonal) - */ - readonly stripeAngle?: number; -}; - -/** - * Creates a striped material for cap planes. - * - * Default behavior: diagonal stripes that are locked to the cap plane's - * surface (object space), so they do not slide when the camera moves. - * - * This material uses stencil operations for cross-section capping, ensuring it only - * renders at mesh/plane intersections when used with the Cutter component. - * - * @param stripeFrequency - Distance between stripes in plane units (same units as geometry) - * @param baseColor - Base color of the material - * @param stripeColor - Color of the stripes - * @returns A THREE.ShaderMaterial with striped pattern - */ -export function createStripedMaterial(properties?: StripedMaterialProperties): THREE.ShaderMaterial { - const { - stripeFrequency = 2, - stripeWidth = 0.25, - baseColor = 0xdd_dd_dd, - stripeColor = 0xbb_bb_bb, - stripeAngle = Math.PI / 4, - } = properties ?? {}; - - const stripedMaterial = new THREE.ShaderMaterial({ - side: THREE.DoubleSide, - transparent: true, - stencilWrite: true, - stencilRef: 0, - stencilFunc: THREE.NotEqualStencilFunc, - stencilFail: THREE.ReplaceStencilOp, - // eslint-disable-next-line @typescript-eslint/naming-convention -- Three.js API naming - stencilZFail: THREE.ReplaceStencilOp, - // eslint-disable-next-line @typescript-eslint/naming-convention -- Three.js API naming - stencilZPass: THREE.ReplaceStencilOp, - uniforms: { - uBaseColor: { - value: new THREE.Color(baseColor), - }, - uStripeFrequency: { - value: stripeFrequency, - }, - uStripeColor: { - value: new THREE.Color(stripeColor), - }, - uStripeWidth: { - value: stripeWidth, - }, - uStripeAngle: { - value: stripeAngle, - }, - }, - - vertexShader: ` - #include - #include - - varying vec2 vSurfacePos; // plane-local XY in geometry units - - void main() { - vSurfacePos = position.xy; // lock pattern to the plane surface - gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); - - #include - } - `, - - fragmentShader: ` - #include - #include - - uniform vec3 uBaseColor; - uniform float uStripeFrequency; - uniform vec3 uStripeColor; - uniform float uStripeWidth; - uniform float uStripeAngle; - - varying vec2 vSurfacePos; - - mat2 rotation2D(float angle) { - float s = sin(angle); - float c = cos(angle); - return mat2(c, -s, s, c); - } - - void main() { - #include - - // Rotate plane-local coordinates so the stripes are anchored to the plane - vec2 rotated = rotation2D(uStripeAngle) * vSurfacePos; - float pattern = mod(rotated.y, uStripeFrequency); - - // Antialiased stripe edge using screen-space derivatives - float aa = fwidth(pattern) * 1.5; - float stripeMask = smoothstep(uStripeWidth - aa, uStripeWidth + aa, pattern); - vec3 finalColor = mix(uStripeColor, uBaseColor, stripeMask); - - gl_FragColor = vec4(finalColor, 1.0); - } - `, - }); - - return stripedMaterial; -} diff --git a/apps/ui/app/components/geometry/graphics/three/post-processing.tsx b/apps/ui/app/components/geometry/graphics/three/post-processing.tsx deleted file mode 100644 index 6d0cb1ee3..000000000 --- a/apps/ui/app/components/geometry/graphics/three/post-processing.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { EffectComposer, N8AO } from '@react-three/postprocessing'; -import { useGraphicsSelector } from '#hooks/use-graphics.js'; - -/** - * Conditionally renders the EffectComposer with N8AO ambient occlusion. - * When disabled, the EffectComposer unmounts and SceneOverlay auto-adapts - * to render the full scene itself via `state.internal.priority` detection. - * - * N8AO is configured with `screenSpaceRadius={true}`, which means `aoRadius` - * is measured in **pixels** (not world units). This makes the ambient occlusion - * effect scale-independent -- models of any size receive visually consistent AO - * without needing access to `sceneRadius`. If `screenSpaceRadius` were `false`, - * `aoRadius` would need to be proportional to the scene bounding sphere radius - * (typically 1-2 orders of magnitude smaller than the scene scale). - * - * `distanceFalloff` is set to `0` to disable distance-based AO attenuation. - * N8AO reconstructs screen-space normals from the depth buffer, but its gradient - * selection logic (`dl < dr` comparison in `computeNormal`) operates in raw depth - * space without compensating for the logarithmic depth buffer encoding. This causes - * the left/right normal gradient to flip at certain depth contours on smooth curved - * surfaces, producing visible contour-line artifacts. The `distanceFalloff` parameter - * amplifies these errors because its distance weighting relies on the same imprecise - * depth reconstruction. Setting it to `0` bypasses that code path entirely. For - * single-body CAD geometry this has negligible visual impact -- AO still darkens - * corners and crevices correctly via angular occlusion alone. - */ -export function PostProcessing(): React.JSX.Element | undefined { - const enablePostProcessing = useGraphicsSelector((state) => state.context.enablePostProcessing); - - if (!enablePostProcessing) { - return undefined; - } - - return ( - - - - ); -} diff --git a/apps/ui/app/components/geometry/graphics/three/react/axes-helper.tsx b/apps/ui/app/components/geometry/graphics/three/react/axes-helper.tsx deleted file mode 100644 index d1486a2e7..000000000 --- a/apps/ui/app/components/geometry/graphics/three/react/axes-helper.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import * as THREE from 'three'; -import { Line } from '@react-three/drei'; -import React, { Fragment } from 'react'; - -type CustomAxesHelperProps = { - /** - * The size of the axes - * @default 5000 - */ - readonly size?: number; - /** - * The color of the X axis - * @default 'red' - */ - readonly xAxisColor?: string; - /** - * The color of the Y axis - * @default 'green' - */ - readonly yAxisColor?: string; - /** - * The color of the Z axis - * @default 'blue' - */ - readonly zAxisColor?: string; - /** - * The thickness of the axes - * @default 5 - */ - readonly thickness?: number; - /** - * The thickness of the axes when hovered - * @default 2 - */ - readonly hoverThickness?: number; -}; - -export function AxesHelper({ - size = 50_000, - xAxisColor = 'rgb(125, 56, 50)', - yAxisColor = 'rgb(64, 115, 63)', - zAxisColor = 'rgb(37, 78, 136)', - thickness = 1.25, - hoverThickness = 2, -}: CustomAxesHelperProps): React.JSX.Element { - const [hoveredAxis, setHoveredAxis] = React.useState<'x' | 'y' | 'z' | undefined>(undefined); - - // Static axis definitions - only recreated when size or colors change, NOT on hover - const axes = React.useMemo( - () => [ - { - id: 'x' as const, - origin: new THREE.Vector3(0, 0, 0), - negativeEnd: new THREE.Vector3(-size, 0, 0), - positiveEnd: new THREE.Vector3(size, 0, 0), - color: xAxisColor, - }, - { - id: 'y' as const, - origin: new THREE.Vector3(0, 0, 0), - negativeEnd: new THREE.Vector3(0, -size, 0), - positiveEnd: new THREE.Vector3(0, size, 0), - color: yAxisColor, - }, - { - id: 'z' as const, - origin: new THREE.Vector3(0, 0, 0), - negativeEnd: new THREE.Vector3(0, 0, -size), - positiveEnd: new THREE.Vector3(0, 0, size), - color: zAxisColor, - }, - ], - [size, xAxisColor, yAxisColor, zAxisColor], - ); - - return ( - - {axes.map((axis) => { - const isHovered = hoveredAxis === axis.id; - const points = [isHovered ? axis.negativeEnd : axis.origin, axis.positiveEnd]; - return ( - - - { - setHoveredAxis(axis.id); - }} - onPointerOut={() => { - setHoveredAxis(undefined); - }} - /> - - ); - })} - - ); -} diff --git a/apps/ui/app/components/geometry/graphics/three/react/gltf-mesh.tsx b/apps/ui/app/components/geometry/graphics/three/react/gltf-mesh.tsx deleted file mode 100644 index 2bc83cd56..000000000 --- a/apps/ui/app/components/geometry/graphics/three/react/gltf-mesh.tsx +++ /dev/null @@ -1,335 +0,0 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; -import { GLTFLoader, LineSegments2 } from 'three/addons'; -import type { GLTF } from 'three/addons/loaders/GLTFLoader.js'; -import type { Group, Object3D, Material, BufferGeometry, Mesh, Texture } from 'three'; -import { Vector2 } from 'three'; -import { useThree } from '@react-three/fiber'; -import { applyMatcap } from '#components/geometry/graphics/three/materials/gltf-matcap.js'; -import { - applyFatLineSegments, - updateLineMaterialResolution, -} from '#components/geometry/graphics/three/materials/gltf-edges.js'; - -// Module-scoped GLTFLoader instance. GLTFLoader is stateless and fully reusable, -// so creating a fresh instance per parse wastes initialization overhead and GC pressure. -const gltfLoader = new GLTFLoader(); - -/** - * Dispose a material and all its texture properties. - */ -function disposeMaterialWithTextures(mat: Material): void { - for (const value of Object.values(mat)) { - if (value && typeof value === 'object' && 'isTexture' in value) { - (value as Texture).dispose(); - } - } - - mat.dispose(); -} - -/** - * Recursively dispose all GPU resources (geometries, materials, textures) in a scene graph. - * This prevents GPU memory leaks when replacing or unmounting GLTF scenes. - */ -function disposeSceneResources(object: Object3D): void { - object.traverse((child) => { - // Dispose geometry - if ('geometry' in child) { - const { geometry } = child as { geometry?: BufferGeometry }; - geometry?.dispose(); - } - - // Dispose material(s) and their textures - if ('material' in child) { - const { material } = child as { material?: Material | Material[] }; - if (material) { - const materials = Array.isArray(material) ? material : [material]; - for (const mat of materials) { - disposeMaterialWithTextures(mat); - } - } - } - }); -} - -/** - * Clone and save all mesh materials from a scene so they can be restored - * after destructive operations like matcap application. - */ -function saveOriginalMaterials(scene: Group): Map { - const saved = new Map(); - scene.traverse((child) => { - if ('isMesh' in child && child.isMesh && !(child instanceof LineSegments2)) { - const mesh = child as Mesh; - if (Array.isArray(mesh.material)) { - saved.set( - mesh.id, - mesh.material.map((m) => m.clone()), - ); - } else { - saved.set(mesh.id, mesh.material.clone()); - } - } - }); - return saved; -} - -/** - * Restore saved original materials onto a scene. - * Disposes any current materials that differ from the originals (e.g. matcap materials). - */ -function restoreOriginalMaterials(scene: Group, saved: Map): void { - scene.traverse((child) => { - if ('isMesh' in child && child.isMesh && !(child instanceof LineSegments2)) { - const mesh = child as Mesh; - const original = saved.get(mesh.id); - if (!original) { - return; - } - - // Dispose current material if it was replaced (e.g. matcap) - if (mesh.material !== original) { - const currentMats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; - for (const mat of currentMats) { - disposeMaterialWithTextures(mat); - } - } - - // Assign saved clones directly (they are pristine copies never used as active materials). - // Re-clone the saved copies so the stored originals remain untouched for future restores. - if (Array.isArray(original)) { - mesh.material = original; - saved.set( - mesh.id, - original.map((m) => m.clone()), - ); - } else { - mesh.material = original; - saved.set(mesh.id, original.clone()); - } - } - }); -} - -/** - * Dispose saved material clones stored in the originals map. - */ -function disposeSavedMaterials(saved: Map): void { - for (const mat of saved.values()) { - if (Array.isArray(mat)) { - for (const m of mat) { - disposeMaterialWithTextures(m); - } - } else { - disposeMaterialWithTextures(mat); - } - } - - saved.clear(); -} - -type GltfMeshDisplayProperties = { - /** - * The GLTF file to load. - */ - readonly gltfFile: Uint8Array; - /** - * Whether to enable matcap material. - */ - readonly enableMatcap: boolean; - /** - * Whether to enable surfaces. - */ - readonly enableSurfaces?: boolean; - /** - * Whether to enable lines. - */ - readonly enableLines?: boolean; -}; - -/** - * Update visibility of surfaces and lines based on object type. - * - * Uses Three.js object type for identification: - * - Mesh objects (including subclasses like SkinnedMesh, InstancedMesh) are surfaces - * - LineSegments and LineSegments2 objects are edges - * - * @param scene - The GLTF scene - * @param enableSurfaces - Whether to show surfaces - * @param enableLines - Whether to show lines - */ -function updateVisibility(scene: Group, enableSurfaces: boolean, enableLines: boolean): void { - scene.traverse((object) => { - // Check line types first (LineSegments2 has custom type) - if (object.type === 'LineSegments' || object instanceof LineSegments2) { - object.visible = enableLines; - } else if ('isMesh' in object && object.isMesh) { - // `isMesh` is true for Mesh, SkinnedMesh, InstancedMesh, etc. - object.visible = enableSurfaces; - } - }); -} - -/** - * This component renders a GLTF mesh. - * - * Rather than using Drei's `Gltf` component, this component is optimized for performance - * and caters to the needs of a CAD application. - * - * It does the following: - * - Supports toggling visibility of surfaces and lines via object type - * - Supports matcap material (applied to all Mesh objects) - * - Converts LineSegments to LineSegments2 for fat line rendering with constant screen-space width - * - Edges are rendered as LineSegments from the GLTF (processed by edge detection middleware) - * - Detects and prioritizes vertex colors over material colors - * - When vertex colors (COLOR_0 attribute) are present: uses vertex colors exclusively - * - When no vertex colors are present: falls back to material colors and opacity - * - * @param gltfFile - The GLTF file to load - * @param enableMatcap - Whether to enable matcap material - * @param enableSurfaces - Whether to enable surfaces - * @param enableLines - Whether to enable lines - * @returns A React component with Three.js primitives that renders the GLTF mesh - */ -export function GltfMesh({ - gltfFile, - enableMatcap = false, - enableSurfaces = true, - enableLines = true, -}: GltfMeshDisplayProperties): React.JSX.Element | undefined { - // The "base scene" is the parsed GLTF with line segments converted but no material overrides. - // It serves as the template from which material modes (matcap/original) are derived. - const [baseScene, setBaseScene] = useState(undefined); - // The rendered scene has material mode applied and is what displays. - const [scene, setScene] = useState(undefined); - const { size, invalidate } = useThree(); - - // Memoize resolution vector to avoid creating new objects on each render - const resolutionRef = useRef(new Vector2(size.width, size.height)); - - // Saved clones of the original materials so we can restore them after matcap is toggled off. - const originalMaterialsRef = useRef>(new Map()); - - // Update resolution when size changes. Deferred via requestAnimationFrame - // so that rapid resize events (e.g. dragging a Dockview divider) batch into - // a single scene traversal + invalidation per animation frame. - useEffect(() => { - resolutionRef.current.set(size.width, size.height); - - if (!scene) { - return; - } - - const frameId = requestAnimationFrame(() => { - updateLineMaterialResolution(scene, resolutionRef.current); - invalidate(); - }); - - return () => { - cancelAnimationFrame(frameId); - }; - }, [size, scene, invalidate]); - - // ── Effect 1: Parse GLTF binary (expensive, only on gltfFile change) ────── - // Parses the GLTF, converts line segments, and saves original materials. - // Does not apply matcap or any material overrides -- that is handled by Effect 2. - useEffect(() => { - let cancelled = false; - - const loadGltf = async (): Promise => { - try { - if (typeof SharedArrayBuffer === 'function' && gltfFile.buffer instanceof SharedArrayBuffer) { - throw new TypeError('SharedArrayBuffer is not supported in '); - } - - const gltf = await gltfLoader.parseAsync( - gltfFile.buffer, - '', // Path (not needed for ArrayBuffer) - ); - - if (cancelled) { - disposeSceneResources(gltf.scene); - return; - } - - // Convert LineSegments to LineSegments2 for fat line rendering - applyFatLineSegments(gltf, resolutionRef.current); - - // Save clones of the original materials before any overrides - disposeSavedMaterials(originalMaterialsRef.current); - originalMaterialsRef.current = saveOriginalMaterials(gltf.scene); - - setBaseScene(gltf.scene); - invalidate(); - } catch (error) { - if (!cancelled) { - console.error('Failed to load GLTF:', error); - } - } - }; - - // Dispose previous base scene and saved materials before loading new one - setBaseScene((previous) => { - if (previous) { - disposeSceneResources(previous); - } - - return undefined; - }); - setScene(undefined); - - void loadGltf(); - - return () => { - cancelled = true; - }; - }, [gltfFile, invalidate]); - - // Cleanup on unmount: dispose base scene and saved materials - useEffect( - () => () => { - disposeSavedMaterials(originalMaterialsRef.current); - }, - [], - ); - - // Effect 2: Apply materials (lightweight, runs on matcap toggle or new base scene). - // Applies matcap or restores original materials on the base scene. - // When enableMatcap changes, only this effect runs (no GLTF re-parse). - // Visibility is NOT handled here -- it is handled by the dedicated visibility effect - // below to avoid expensive material re-application on visibility toggles. - const applyMaterials = useCallback( - (targetScene: Group): void => { - if (enableMatcap) { - void applyMatcap({ scene: targetScene } as GLTF); - } else { - restoreOriginalMaterials(targetScene, originalMaterialsRef.current); - } - }, - [enableMatcap], - ); - - useEffect(() => { - if (!baseScene) { - return; - } - - applyMaterials(baseScene); - setScene(baseScene); - invalidate(); - }, [baseScene, applyMaterials, invalidate]); - - // Toggle visibility when enableSurfaces or enableLines change - useEffect(() => { - if (scene) { - updateVisibility(scene, enableSurfaces, enableLines); - invalidate(); - } - }, [scene, enableSurfaces, enableLines, invalidate]); - - if (!scene) { - return undefined; - } - - return ; -} diff --git a/apps/ui/app/components/geometry/graphics/three/react/infinite-grid.tsx b/apps/ui/app/components/geometry/graphics/three/react/infinite-grid.tsx deleted file mode 100644 index 1e48964c2..000000000 --- a/apps/ui/app/components/geometry/graphics/three/react/infinite-grid.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { Plane } from '@react-three/drei'; -import React from 'react'; -import { infiniteGridMaterial } from '#components/geometry/graphics/three/materials/infinite-grid-material.js'; -import type { InfiniteGridMaterialProperties } from '#components/geometry/graphics/three/materials/infinite-grid-material.js'; - -type InfiniteGridProperties = { - /** - * The properties for the infinite grid material. - */ - readonly materialProperties?: InfiniteGridMaterialProperties; - /** - * The properties for the infinite grid plane. - */ - readonly planeProperties?: React.ComponentProps; - /** - * The axes to use for the grid orientation. - * - 'xyz': Grid on XY plane (Z-up coordinate system, CAD/engineering) - * - 'xzy': Grid on XZ plane (Y-up coordinate system, standard Three.js) - * - 'zyx': Grid on ZY plane (X-up coordinate system) - */ - readonly axes: 'xyz' | 'xzy' | 'zyx'; -}; - -/** - * An infinite grid component that renders a ground plane grid. - * The grid extends infinitely in all directions and scales dynamically - * based on camera distance for optimal visibility. - * - * ### Features: - * - **Infinite extent**: Grid extends as far as needed based on camera position - * - **Dynamic scaling**: Grid size adjusts to camera distance for consistent visibility - * - **Dual grid system**: Small and large grid lines with independent sizing and thickness - * - **Distance-based fading**: Grid fades out at edges to prevent visual artifacts - * - **Customizable appearance**: Configurable colors, opacity, and thickness - * - **Performance optimized**: Uses efficient shader-based rendering - * - * ### Grid Orientation: - * The grid orientation is controlled by the `axes` prop: - * - 'xyz': Grid on XY plane (Z-up coordinate system, CAD/engineering) - * - 'xzy': Grid on XZ plane (Y-up coordinate system, standard Three.js) - * - 'zyx': Grid on ZY plane (X-up coordinate system) - * - * ### Usage: - * ```tsx - * - * ``` - * - * @param properties - The properties for the infinite grid. - */ -export function InfiniteGrid(properties: InfiniteGridProperties): React.JSX.Element { - const { materialProperties = {}, planeProperties = {}, axes } = properties; - - // Create material with provided axes - const material = React.useMemo( - () => infiniteGridMaterial({ ...materialProperties, axes }), - [axes, materialProperties], - ); - - return ( - - ); -} diff --git a/apps/ui/app/components/geometry/graphics/three/react/lights.tsx b/apps/ui/app/components/geometry/graphics/three/react/lights.tsx deleted file mode 100644 index 99acd6914..000000000 --- a/apps/ui/app/components/geometry/graphics/three/react/lights.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import { useRef } from 'react'; -import type * as THREE from 'three'; -import { useThree, useFrame } from '@react-three/fiber'; -import { Environment, Lightformer } from '@react-three/drei'; -import { - applyLightingForCamera, - ambientBaseIntensity, - headlampBaseIntensity, - environmentBaseIntensity, - defaultHeadlampConfig, - lightingUserDataKeys, -} from '#components/geometry/graphics/three/utils/lights.utils.js'; -import type { SceneLightingConfig } from '#components/geometry/graphics/three/utils/lights.utils.js'; - -/** Environment cubemap resolution (px). Higher = sharper specular reflections. */ -const envResolution = 512; - -// Studio preset Lightformer intensities ────────────────────────────────────── -// Asymmetric camera-space rig matching Onshape's observed pattern. -// Key upper-left, fill right, top overhead, ground below, back-fill behind. - -/** Key panel (right-upper in camera space) -- brightest light, creates NE-bright gradient. */ -const studioKeyIntensity = 4; -/** Left-upper fill (left-upper in camera space) -- illuminates left-facing L sections (WNW/NW-left). */ -const studioLeftFillIntensity = 1.2; -/** Top panel (overhead in camera space) -- subtle overhead accent on sloped surfaces. */ -const studioTopIntensity = 0.25; -/** Ground panel (below in camera space) -- bright for bottom-view luminosity. */ -const studioGroundIntensity = 1.5; -/** Specular highlight panel (upper-right for bottom face) -- creates focused off-center specular on flat faces. */ -const studioBackFillIntensity = 8; - -// Neutral preset Lightformer intensities ───────────────────────────────────── -const neutralKeyIntensity = 0.6; -const neutralGroundIntensity = 0.2; - -type UpDirection = 'x' | 'y' | 'z'; - -type LightsProperties = { - readonly enableMatcap?: boolean; - readonly sceneRadius?: number; - readonly environmentPreset?: 'studio' | 'neutral' | 'soft' | 'performance'; - readonly upDirection?: UpDirection; -}; - -/** - * Professional CAD lighting setup matching Onshape's rendering style. - * - * Design principles: - * 1. **Azimuth-locked environment** — `scene.environmentRotation` is driven from - * only the azimuthal (yaw) component of the inverse camera quaternion each - * frame, so Lightformers stay stable during horizontal orbit but shift - * naturally when the camera tilts up/down, producing lighting variation. - * - * 2. **Asymmetric camera-space lightformers** — Key panel upper-left, fill right, - * top overhead, ground below, and back-fill behind camera. This matches Onshape's - * observed lighting pattern (upper-left brightest, lower-right darkest). - * - * 3. **FOV compensation** — As FOV decreases toward orthographic, specular highlights - * wash out (parallel view rays → uniform reflection). A multi-lever system scales - * down `scene.environmentIntensity` at low FOV while boosting headlamp and ambient - * to compensate diffuse loss. No material changes. - * - * 4. **Camera-space headlamp** — A subtle directional light offset in camera-up - * and camera-right directions so the highlight remains biased toward screen - * upper-right. - * - * 5. **Scale-adaptive** — All Lightformer positions and scales are expressed as - * multiples of `sceneRadius` so lighting adapts to model size. - */ -export function Lights({ - enableMatcap = false, - sceneRadius = 0, - environmentPreset = 'studio', - upDirection = 'z', -}: LightsProperties): React.JSX.Element { - const { camera, scene } = useThree(); - const cameraLightReference = useRef(null); - const ambientReference = useRef(null); - - // Clamp sceneRadius to avoid zero/tiny values before geometry loads - const clampedSceneRadius = Math.max(sceneRadius, 1); - - // Keep clamped radius accessible in useFrame without re-subscribing - const radiusRef = useRef(clampedSceneRadius); - radiusRef.current = clampedSceneRadius; - - // Per-frame updates delegated to the shared applyLightingForCamera utility. - // This ensures the live renderer and the offline screenshot renderer apply - // identical lighting for any camera orientation. - useFrame(() => { - // Persist lighting config on scene.userData so the screenshot capture - // system can read it from a cloned scene without prop-drilling. - scene.userData[lightingUserDataKeys.config] = { - sceneRadius: radiusRef.current, - upDirection, - } satisfies SceneLightingConfig; - - applyLightingForCamera({ - scene, - camera, - headlamp: cameraLightReference.current ?? undefined, - ambient: ambientReference.current ?? undefined, - config: { - sceneRadius: radiusRef.current, - upDirection, - headlampIntensity: headlampBaseIntensity, - ambientIntensity: ambientBaseIntensity, - environmentIntensity: environmentBaseIntensity, - headlampConfig: defaultHeadlampConfig, - }, - }); - }); - - const showEnvironment = !enableMatcap && (environmentPreset === 'studio' || environmentPreset === 'neutral'); - - return ( - <> - {/* Base ambient fill -- always present for minimum illumination */} - - - {/* Headlamp -- positioned above camera in world space for top-down gradients */} - - - {showEnvironment ? ( - - {environmentPreset === 'studio' ? ( - <> - {/* ── Key panel (right-upper in camera space) ── */} - {/* Brightest side light. Positioned primarily to the right of the - camera with moderate upward offset. Creates the NE-bright - gradient (NNE, ENE lit) while keeping NNW dark. */} - - {/* ── Left-upper fill (left-upper in camera space) ── */} - {/* Illuminates left-facing L sections (WNW = NW-left) that the - rightward key cannot reach. Env_x dominant negative with moderate - +env_y so WNW (env_y=0.38) gets more than WSW (env_y=-0.38). */} - - {/* ── Top panel (overhead in camera space) ── */} - {/* Reduced overhead accent — kept low to avoid over-brightening - NNW (D section) which has high env_y normal component. */} - - {/* ── Ground panel (below-right in camera space) ── */} - {/* Bright ground for bottom-view luminosity. Offset in +X so that - the bottom-face specular shifts toward the right (matching the - asymmetric rig's "brighter on right" pattern). */} - - {/* ── Specular highlight panel (upper-right in camera space) ── */} - {/* Positioned in the (+X, -Y, +Z) octant to create a focused specular - highlight in the upper-right area of bottom-facing surfaces when - viewed from below. In Z-up screen coords for the bottom face: - +X → screen right, -Y → screen top, +Z → close to the reflection - pole. Equal X and -Y offsets place the specular at 45° toward the - top-right corner. Negligible contribution to front/side face - speculars (~61° from front reflection direction). */} - - - ) : ( - <> - {/* Neutral preset: reduced intensity, minimal reflections */} - - - - )} - - ) : null} - - {/* Soft preset: hemisphere + ambient only, no environment map */} - {!enableMatcap && environmentPreset === 'soft' ? : null} - - {/* Performance preset: minimal lights, no environment (equivalent to legacy setup) */} - {!enableMatcap && environmentPreset === 'performance' ? ( - <> - - - - - ) : null} - - ); -} diff --git a/apps/ui/app/components/geometry/graphics/three/react/measure-tool.tsx b/apps/ui/app/components/geometry/graphics/three/react/measure-tool.tsx deleted file mode 100644 index 78b4973b1..000000000 --- a/apps/ui/app/components/geometry/graphics/three/react/measure-tool.tsx +++ /dev/null @@ -1,901 +0,0 @@ -/* eslint-disable complexity -- Label/line sizing and camera-facing math in a single component */ -import { useEffect, useRef, useState, useMemo } from 'react'; -import * as THREE from 'three'; -import { useFrame, useThree } from '@react-three/fiber'; -import { - LabelTextGeometry, - LabelBackgroundGeometry, -} from '#components/geometry/graphics/three/geometries/label-geometry.js'; -import { - detectSnapPoints, - findClosestSnapPoint, -} from '#components/geometry/graphics/three/utils/snap-detection.utils.js'; -import type { SnapPoint } from '#components/geometry/graphics/three/utils/snap-detection.utils.js'; -import { computeAxisRotationForCamera } from '#components/geometry/graphics/three/utils/rotation.utils.js'; -import { matcapMaterial } from '#components/geometry/graphics/three/materials/matcap-material.js'; -import { useGraphics, useGraphicsSelector } from '#hooks/use-graphics.js'; - -function calculateScaleFromCamera(position: THREE.Vector3, camera: THREE.Camera): number { - const distanceToCamera = camera.position.distanceTo(position); - - let factor: number; - - // Handle orthographic camera - if ('isOrthographicCamera' in camera && camera.isOrthographicCamera) { - const orthoCamera = camera as THREE.OrthographicCamera; - factor = (orthoCamera.top - orthoCamera.bottom) / orthoCamera.zoom; - } else { - // Handle perspective camera with FOV consideration - const perspCamera = camera as THREE.PerspectiveCamera; - factor = distanceToCamera * Math.min((1.9 * Math.tan((Math.PI * perspCamera.fov) / 360)) / perspCamera.zoom, 7); - } - - const size = 1; // Base size (equivalent to this.size in transform-controls) - return (factor * size) / 4000; -} - -// ── Module-scope scratch objects for useFrame callbacks (avoids per-frame GC pressure) ── - -// SnapPointIndicator scratch -const _snapDirection = new THREE.Vector3(); -const _snapQuaternion = new THREE.Quaternion(); -const _snapUp = new THREE.Vector3(0, 1, 0); - -// MeasurementLine scratch -const _baseQuat = new THREE.Quaternion(); -const _currentNormal = new THREE.Vector3(); -const _axisRotation = new THREE.Quaternion(); -const _finalQuat = new THREE.Quaternion(); -const _flipQuat = new THREE.Quaternion(); -const _labelNormal = new THREE.Vector3(); -const _labelUp = new THREE.Vector3(); -const _cameraUp = new THREE.Vector3(); -const _cameraUpProjected = new THREE.Vector3(); -const _lineDir = new THREE.Vector3(); -const _coneOffset = new THREE.Vector3(); - -export function MeasureTool(): React.JSX.Element { - const { camera, gl, scene } = useThree(); - const graphicsActor = useGraphics(); - const geometryKey = useGraphicsSelector((state) => state.context.geometryKey); - const measurements = useGraphicsSelector((state) => state.context.measurements); - const currentStart = useGraphicsSelector((state) => state.context.currentMeasurementStart); - const snapDistance = useGraphicsSelector((state) => state.context.measureSnapDistance); - const lengthFactor = useGraphicsSelector((state) => state.context.units.length.factor); - const lengthSymbol = useGraphicsSelector((state) => state.context.units.length.symbol); - const hoveredMeasurementId = useGraphicsSelector((state) => state.context.hoveredMeasurementId); - const isMeasureActive = useGraphicsSelector((state) => state.context.isMeasureActive); - - const [hoveredSnapPoints, setHoveredSnapPoints] = useState([]); - const [activeSnapPoint, setActiveSnapPoint] = useState(); - const [mousePosition, setMousePosition] = useState(); - const lastSnapPointsRef = useRef(undefined); - - // Refs for values that change rapidly (every mouse move) so the event-listener - // effect doesn't tear down and re-add 4 DOM listeners per mouse event. - const activeSnapPointRef = useRef(activeSnapPoint); - activeSnapPointRef.current = activeSnapPoint; - const mousePositionRef = useRef(mousePosition); - mousePositionRef.current = mousePosition; - const currentStartRef = useRef(currentStart); - currentStartRef.current = currentStart; - - const raycasterRef = useRef(new THREE.Raycaster()); - const mouseRef = useRef(new THREE.Vector2()); - const pointerDownOnMeshRef = useRef(false); - const mouseIsDownRef = useRef(false); - const startCameraQuatRef = useRef(new THREE.Quaternion()); - const startCameraPosRef = useRef(new THREE.Vector3()); - - // Cache mesh list to avoid expensive scene.traverse() on every mouse event. - // Invalidated when geometryKey changes (new geometry loaded/unloaded). - const cachedMeshesRef = useRef([]); - const cachedMeshKeyRef = useRef(undefined); - // Keep scene ref in sync for getCachedMeshes (stable callback reference) - const sceneRef = useRef(scene); - sceneRef.current = scene; - const geometryKeyRef = useRef(geometryKey); - geometryKeyRef.current = geometryKey; - - // Cache detectSnapPoints results keyed by (mesh.id, faceIndex) to avoid - // running the expensive geometry pipeline on every mouse move over the same face. - const snapCacheRef = useRef(new Map()); - - const getCachedMeshes = useRef((): THREE.Mesh[] => { - const currentKey = geometryKeyRef.current; - if (currentKey === cachedMeshKeyRef.current) { - return cachedMeshesRef.current; - } - - const meshes: THREE.Mesh[] = []; - sceneRef.current.traverse((object) => { - if (object instanceof THREE.Mesh && object.visible && !object.userData['isMeasurementUi']) { - meshes.push(object as THREE.Mesh); - } - }); - cachedMeshesRef.current = meshes; - cachedMeshKeyRef.current = currentKey; - // Invalidate snap point cache when geometry changes - snapCacheRef.current.clear(); - return meshes; - }).current; - - // Handle mouse move for snapping - useEffect(() => { - // Only enable interactive listeners when measure mode is active - if (!isMeasureActive) { - return undefined; - } - - const handleMouseMove = (event: MouseEvent): void => { - const rect = gl.domElement.getBoundingClientRect(); - mouseRef.current.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; - mouseRef.current.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; - - raycasterRef.current.setFromCamera(mouseRef.current, camera); - - // Get all meshes in scene, using cached list when geometry hasn't changed - const meshes = getCachedMeshes(); - - // Find the closest intersected mesh (top-most object) - const intersects = raycasterRef.current.intersectObjects(meshes, true); - const firstIntersection = intersects[0]; - - // Only show snap points for the closest/top-most intersected object. - // If there is no intersection, fall back to the last detected face's snap points. - let allSnapPoints: SnapPoint[] = []; - if (firstIntersection?.object) { - const topMesh = firstIntersection.object as THREE.Mesh; - // Cache by mesh ID + face index to avoid re-running the expensive geometry - // pipeline when hovering over the same face on consecutive mouse moves. - const cacheKey = `${topMesh.id}:${firstIntersection.faceIndex ?? -1}`; - const cached = snapCacheRef.current.get(cacheKey); - if (cached) { - allSnapPoints = cached; - } else { - allSnapPoints = detectSnapPoints(topMesh, raycasterRef.current); - snapCacheRef.current.set(cacheKey, allSnapPoints); - } - - lastSnapPointsRef.current = allSnapPoints; - } else if (lastSnapPointsRef.current?.length) { - allSnapPoints = lastSnapPointsRef.current; - } - - setHoveredSnapPoints(allSnapPoints); - - const closest = findClosestSnapPoint(allSnapPoints, { - mousePos: mouseRef.current, - camera, - canvas: gl.domElement, - snapDistancePx: snapDistance, - snapPointBufferPx: 15, // Add buffer for hover persistence - }); - setActiveSnapPoint(closest); - - // Update mouse position for preview line - if (closest) { - setMousePosition(closest.position); - } else if (firstIntersection) { - setMousePosition(firstIntersection.point); - } else if (lastSnapPointsRef.current?.[0]) { - // Use the first snap point as a stable mouse position proxy when off-face - setMousePosition(lastSnapPointsRef.current[0].position); - } - }; - - const handlePointerDown = (event: MouseEvent): void => { - // Track camera state at mouse down to detect rotations/translations during drag - if (event.button === 0 || event.button === 2) { - startCameraQuatRef.current.copy(camera.quaternion); - startCameraPosRef.current.copy(camera.position); - mouseIsDownRef.current = true; - } - - // Only handle left clicks for measurement from here - if (event.button !== 0) { - return; - } - - // Track if pointerdown happens on a mesh - raycasterRef.current.setFromCamera(mouseRef.current, camera); - - const meshes = getCachedMeshes(); - const intersects = raycasterRef.current.intersectObjects(meshes, true); - // Consider a valid pointerdown when either on a mesh or over a valid snap indicator - pointerDownOnMeshRef.current = intersects.length > 0 || Boolean(activeSnapPointRef.current); - }; - - const handlePointerUp = (event: MouseEvent): void => { - // Handle right click - cancel current measurement only if no camera movement - if (event.button === 2) { - if (mouseIsDownRef.current) { - const endQuat = camera.quaternion.clone(); - const endPos = camera.position.clone(); - const dot = Math.abs(startCameraQuatRef.current.dot(endQuat)); - const angle = 2 * Math.acos(Math.min(1, Math.max(-1, dot))); // Radians - const rotated = angle > 0.01; // ~0.57° - const translated = startCameraPosRef.current.distanceTo(endPos) > 1e-3; - - if (!rotated && !translated && currentStartRef.current) { - // No camera movement: treat as explicit cancel - graphicsActor.send({ type: 'cancelCurrentMeasurement' }); - } - } - - pointerDownOnMeshRef.current = false; - mouseIsDownRef.current = false; - return; - } - - // Only handle left clicks for measurement - if (event.button !== 0) { - return; - } - - // If the camera rotated or translated while the mouse was held down, treat this as a view manipulation, - // not a measurement click. This avoids registering a start/end point upon releasing the drag. - if (mouseIsDownRef.current) { - const endQuat = camera.quaternion.clone(); - const endPos = camera.position.clone(); - - const dot = Math.abs(startCameraQuatRef.current.dot(endQuat)); - const angle = 2 * Math.acos(Math.min(1, Math.max(-1, dot))); // Radians - const rotated = angle > 0.001; // ~0.057° - - const translated = startCameraPosRef.current.distanceTo(endPos) > 1e-3; - - if (rotated || translated) { - pointerDownOnMeshRef.current = false; - mouseIsDownRef.current = false; - return; - } - } - - // Only process if interaction started on mesh OR we still have a valid snap indicator - if (!pointerDownOnMeshRef.current && !activeSnapPointRef.current) { - pointerDownOnMeshRef.current = false; - return; - } - - // Verify pointerup is also on a mesh by performing a fresh raycast - raycasterRef.current.setFromCamera(mouseRef.current, camera); - - const meshes = getCachedMeshes(); - const intersects = raycasterRef.current.intersectObjects(meshes, true); - if (intersects.length === 0 && !activeSnapPointRef.current) { - // No intersection and no active snap target, ignore - pointerDownOnMeshRef.current = false; - return; - } - - // Use snap point if available, otherwise use intersection point - const point = activeSnapPointRef.current?.position ?? intersects[0]?.point; - if (!point) { - pointerDownOnMeshRef.current = false; - return; - } - - const pointArray: [number, number, number] = [point.x, point.y, point.z]; - - if (currentStartRef.current) { - // Disallow 0-length measurements by ignoring a completion click - // that lands effectively on the start point (within a small epsilon) - const startVec = new THREE.Vector3(...currentStartRef.current); - const endVec = new THREE.Vector3(...pointArray); - const zeroLengthEpsilon = 1e-4; // Scene units - if (startVec.distanceTo(endVec) <= zeroLengthEpsilon) { - pointerDownOnMeshRef.current = false; - mouseIsDownRef.current = false; - return; - } - - graphicsActor.send({ type: 'completeMeasurement', payload: pointArray }); - } else { - graphicsActor.send({ type: 'startMeasurement', payload: pointArray }); - } - - // Reset the pointerdown flag - pointerDownOnMeshRef.current = false; - mouseIsDownRef.current = false; - }; - - const handleContextMenu = (event: MouseEvent): void => { - // Prevent context menu from showing during measurement - event.preventDefault(); - }; - - gl.domElement.addEventListener('mousemove', handleMouseMove); - gl.domElement.addEventListener('pointerdown', handlePointerDown); - gl.domElement.addEventListener('pointerup', handlePointerUp); - gl.domElement.addEventListener('contextmenu', handleContextMenu); - - return () => { - gl.domElement.removeEventListener('mousemove', handleMouseMove); - gl.domElement.removeEventListener('pointerdown', handlePointerDown); - gl.domElement.removeEventListener('pointerup', handlePointerUp); - gl.domElement.removeEventListener('contextmenu', handleContextMenu); - }; - }, [camera, gl, scene, snapDistance, isMeasureActive, graphicsActor, getCachedMeshes]); - - // Choose which measurements to display: all during measure mode, otherwise only pinned - const visibleMeasurements = isMeasureActive ? measurements : measurements.filter((m) => m.isPinned); - - // Memoize currentStart Vector3 to avoid per-render allocation - const currentStartVec3 = useMemo( - () => (currentStart ? new THREE.Vector3(...currentStart) : undefined), - [currentStart], - ); - - return ( - - {/* Render snap point indicators */} - {isMeasureActive - ? hoveredSnapPoints.map((snapPoint) => { - const key = `snap-${snapPoint.position.x}-${snapPoint.position.y}-${snapPoint.position.z}`; - return ( - - ); - }) - : null} - - {/* Persistent indicator for the selected start point */} - {isMeasureActive && currentStartVec3 ? ( - - ) : null} - - {/* Render preview line */} - {isMeasureActive && currentStartVec3 && mousePosition ? ( - - ) : null} - - {/* Render completed measurements */} - {visibleMeasurements.map((measurement) => ( - - ))} - - ); -} - -type SnapPointIndicatorProps = { - readonly position: THREE.Vector3; - // Indicates hovered/selected state for color - readonly isActive: boolean; - readonly camera: THREE.Camera; -}; - -function SnapPointIndicator({ position, isActive, camera }: SnapPointIndicatorProps): React.JSX.Element { - const outerRef = useRef(null); - const innerRef = useRef(null); - - const borderSize = isActive ? 0.05 : 0.04; - const innerSize = isActive ? 0.04 : 0.03; - const height = 0.05; - const segments = 32; - - useFrame(() => { - const scale = calculateScaleFromCamera(position, camera); - - // Face camera -- reuse module-scope scratch objects - _snapDirection.subVectors(camera.position, position).normalize(); - _snapQuaternion.setFromUnitVectors(_snapUp.set(0, 1, 0), _snapDirection); - - if (outerRef.current) { - outerRef.current.quaternion.copy(_snapQuaternion); - outerRef.current.scale.set(scale * 500, scale * 500, scale * 500); - } - - if (innerRef.current) { - innerRef.current.quaternion.copy(_snapQuaternion); - innerRef.current.scale.set(scale * 500, scale * 500, scale * 500); - } - }); - - return ( - - {/* Outer border (black) */} - - - - - {/* Inner fill (white or green when active/hovered/selected) */} - - - - - - ); -} - -type MeasurementLineProps = { - readonly id?: string; - readonly start: THREE.Vector3 | readonly [number, number, number]; - readonly end: THREE.Vector3 | readonly [number, number, number]; - readonly distance?: number; - readonly lengthFactor?: number; - readonly lengthSymbol?: string; - readonly isPreview?: boolean; - readonly isExternallyHovered?: boolean; - readonly isPinned?: boolean; - readonly coneHeight?: number; // Base cone height in scene units - readonly coneRadius?: number; // Base cone radius in scene units - readonly cylinderRadius?: number; // Base cylinder radius in scene units - // Text sizing - readonly textSize?: number; - readonly textDepth?: number; - // Label/background sizing - readonly labelHeight?: number; - readonly labelPadding?: number; - readonly labelCornerRadius?: number; - readonly labelDepth?: number; - readonly labelCharWidth?: number; - // Formatting and behavior - readonly decimals?: number; - readonly enableUnits?: boolean; - readonly materials?: - | { - readonly backgroundMaterial: THREE.Material; - readonly textMaterial: THREE.Material; - readonly coneMaterial: THREE.Material; - } - | { - readonly backgroundColor: THREE.Color; - readonly textColor: THREE.Color; - readonly coneColor: THREE.Color; - }; -}; - -function MeasurementLine({ - id, - start, - end, - distance, - lengthFactor = 1, - lengthSymbol = 'mm', - isPreview = false, - isExternallyHovered = false, - isPinned = false, - coneHeight = 80, - coneRadius = 10, - cylinderRadius = 2, - textSize = 40, - textDepth = 2, - labelHeight = 80, - labelPadding = 50, - labelCornerRadius = 20, - labelDepth = 1, - labelCharWidth = 24, - decimals = 1, - enableUnits = true, - materials, -}: MeasurementLineProps): React.JSX.Element { - const { camera } = useThree(); - - // Memoize Vector3 conversion so tuples from state don't allocate per render - const startVec = useMemo(() => (start instanceof THREE.Vector3 ? start : new THREE.Vector3(...start)), [start]); - const endVec = useMemo(() => (end instanceof THREE.Vector3 ? end : new THREE.Vector3(...end)), [end]); - - const labelGroupRef = useRef(null); - const lineGroupRef = useRef(null); - const cylinderMeshRef = useRef(null); - const startConeMeshRef = useRef(null); - const endConeMeshRef = useRef(null); - const [isLabelHovered, setIsLabelHovered] = useState(false); - const isHovered = isLabelHovered || isExternallyHovered; - const graphicsActor = useGraphics(); - - // Create matcap materials following transform-controls pattern. - // Split into base materials (created once) and hover color update (cheap, per-hover). - const derivedMaterials = useMemo(() => { - if (materials && 'backgroundMaterial' in materials && 'textMaterial' in materials && 'coneMaterial' in materials) { - return { - backgroundMaterial: materials.backgroundMaterial, - textMaterial: materials.textMaterial, - coneMaterial: materials.coneMaterial, - }; - } - - const matcapTexture = matcapMaterial(); - - const baseMaterial = new THREE.MeshMatcapMaterial({ - matcap: matcapTexture, - depthTest: false, - depthWrite: false, - transparent: true, - side: THREE.DoubleSide, - fog: false, - toneMapped: false, - }); - const basicMaterial = new THREE.MeshBasicMaterial({ - color: materials?.backgroundColor ?? 0xff_ff_ff, // White - depthTest: false, - depthWrite: false, - transparent: true, - side: THREE.DoubleSide, - fog: false, - toneMapped: false, - }); - - const backgroundMaterial = basicMaterial.clone(); - backgroundMaterial.color.set(materials?.backgroundColor ?? 0xff_ff_ff); // White - - const textMaterial = baseMaterial.clone(); - textMaterial.color.set(materials?.textColor ?? 0x00_00_00); // Black - - const coneMaterial = baseMaterial.clone(); - coneMaterial.color.set(materials?.coneColor ?? 0x00_00_00); - - return { backgroundMaterial, textMaterial, coneMaterial }; - }, [materials]); - - // Memoize pin button matcap texture to avoid per-render texture creation - const pinMatcapTexture = useMemo(() => matcapMaterial(), []); - - // Update cone color on hover without recreating all materials - useEffect(() => { - if (materials && 'coneMaterial' in materials) { - return; // Externally provided materials manage their own color - } - - const coneColor = isHovered ? 0x00_ff_00 : materials && 'coneColor' in materials ? materials.coneColor : 0x00_00_00; - (derivedMaterials.coneMaterial as THREE.MeshMatcapMaterial).color.set(coneColor); - }, [isHovered, derivedMaterials, materials]); - - // Calculate label position (midpoint) - const midpoint = useMemo( - () => new THREE.Vector3().addVectors(startVec, endVec).multiplyScalar(0.5), - [startVec, endVec], - ); - - // Calculate distance if not provided - const calculatedDistance = distance ?? startVec.distanceTo(endVec); - const distanceInMm = calculatedDistance / lengthFactor; - const numericText = distanceInMm.toFixed(decimals); - const unitsText = enableUnits ? lengthSymbol : ''; - const labelText = `${numericText}${enableUnits ? ` ${unitsText}` : ''}`; - - // Keep a constant width box reserved for the units portion of the label background - const unitContainerChars = 3; // Reserve width for up to 3-char units - const backgroundCharsLength = numericText.length + (enableUnits ? 1 + unitContainerChars : 0); - const backgroundPlaceholderText = '0'.repeat(Math.max(1, backgroundCharsLength)); - - // Memoize geometries to avoid re-creating large buffers every render frame - const textGeometry = useMemo( - // eslint-disable-next-line new-cap -- Three.js convention - () => LabelTextGeometry({ text: labelText, size: textSize, depth: textDepth }), - [labelText, textSize, textDepth], - ); - - const backgroundGeometry = useMemo( - () => - // eslint-disable-next-line new-cap -- Three.js convention - LabelBackgroundGeometry({ - // Use placeholder string sized to reserve constant-width units area - text: backgroundPlaceholderText, - characterWidth: labelCharWidth, - padding: labelPadding, - height: labelHeight, - radius: labelCornerRadius, - depth: labelDepth, - }), - [backgroundPlaceholderText, labelCharWidth, labelPadding, labelHeight, labelCornerRadius, labelDepth], - ); - - const backgroundOutlineGeometry = useMemo( - () => - // eslint-disable-next-line new-cap -- Three.js convention - LabelBackgroundGeometry({ - text: backgroundPlaceholderText, - characterWidth: labelCharWidth, - padding: labelPadding + 5, - height: labelHeight + 10, - radius: labelCornerRadius + 5, - depth: labelDepth, - }), - [backgroundPlaceholderText, labelCharWidth, labelPadding, labelHeight, labelCornerRadius, labelDepth], - ); - - // Track current scale for UI sizing - const scaleRef = useRef(1); - - // Memoize measurement line direction and quaternions to avoid per-render allocations - const lineDirection = useMemo(() => new THREE.Vector3().subVectors(endVec, startVec).normalize(), [startVec, endVec]); - - // Billboard behavior - rotate around line axis to face camera - // All scratch objects are module-scoped to avoid per-frame GC pressure. - useFrame(() => { - const scale = calculateScaleFromCamera(midpoint, camera); - scaleRef.current = scale; - - // Scale and orient label group - if (labelGroupRef.current) { - // 1) Establish base orientation: align X-axis with the measurement line - _baseQuat.setFromUnitVectors(_currentNormal.set(1, 0, 0), lineDirection); - - // 2) Compute rotation around the line axis so the label's normal faces the camera - _currentNormal.set(0, 0, 1).applyQuaternion(_baseQuat); - const axisRotation = computeAxisRotationForCamera({ - axis: lineDirection, - position: midpoint, - camera, - referenceUp: _currentNormal, - }); - - // 3) Combine rotations: base alignment then axis rotation in world space - _finalQuat.multiplyQuaternions(axisRotation, _baseQuat); - - // 4) Ensure text is upright relative to the camera - _labelNormal.set(0, 0, 1).applyQuaternion(_finalQuat).normalize(); - _labelUp.set(0, 1, 0).applyQuaternion(_finalQuat).normalize(); - - _cameraUp.set(0, 1, 0).applyQuaternion(camera.quaternion).normalize(); - _cameraUpProjected.copy(_cameraUp).addScaledVector(_labelNormal, -_cameraUp.dot(_labelNormal)).normalize(); - - if (_labelUp.dot(_cameraUpProjected) < 0) { - // Flip around the label's normal so it stays facing the camera - _flipQuat.setFromAxisAngle(_labelNormal, Math.PI); - _finalQuat.copy(_axisRotation.multiplyQuaternions(_flipQuat, _finalQuat)); - } - - labelGroupRef.current.quaternion.copy(_finalQuat); - // Enlarge label by 20% when hovered (from UI or viewport) - labelGroupRef.current.scale.setScalar(scale * (isHovered ? 1.2 : 1)); - labelGroupRef.current.position.copy(midpoint); - } - - // Dynamically size cylinder and cones using transform scaling with unit geometries - _lineDir.subVectors(endVec, startVec).normalize(); - - // Derive UI dimensions from scale using component props - const coneHeightScaled = coneHeight * scale; // Height of arrow heads - const coneRadiusScaled = coneRadius * scale; // Radius of arrow heads - const cylinderRadiusScaled = cylinderRadius * scale; // Thickness of the line - - const effectiveCone = isPreview ? 0 : coneHeightScaled; - const cylinderHeight = Math.max(0.0001, lineDistance - 2 * effectiveCone); - - if (cylinderMeshRef.current) { - cylinderMeshRef.current.scale.set(cylinderRadiusScaled, cylinderHeight, cylinderRadiusScaled); - } - - _coneOffset.copy(_lineDir).multiplyScalar(coneHeightScaled / 2); - if (startConeMeshRef.current) { - startConeMeshRef.current.scale.set(coneRadiusScaled, coneHeightScaled, coneRadiusScaled); - startConeMeshRef.current.position.copy(startVec).add(_coneOffset); - } - - if (endConeMeshRef.current) { - endConeMeshRef.current.scale.set(coneRadiusScaled, coneHeightScaled, coneRadiusScaled); - endConeMeshRef.current.position.copy(endVec).sub(_coneOffset); - } - }); - - // Memoize direction, distance, and quaternions for cylinder/cone rotation - const lineDistance = useMemo(() => startVec.distanceTo(endVec), [startVec, endVec]); - const { startQuaternion, endQuaternion, cylinderQuaternion } = useMemo(() => { - const up = new THREE.Vector3(0, 1, 0); - const startQ = new THREE.Quaternion().setFromUnitVectors(up, lineDirection.clone().negate()); - const endQ = new THREE.Quaternion().setFromUnitVectors(up, lineDirection); - const cylinderQ = new THREE.Quaternion().setFromUnitVectors(up, lineDirection); - return { startQuaternion: startQ, endQuaternion: endQ, cylinderQuaternion: cylinderQ }; - }, [lineDirection]); - - return ( - - {/* Line group with scaling for cylinders and cones */} - - {/* Cylinder line */} - - {/* Unit geometry – scaled per-frame */} - - - - - {/* Cone at start */} - {!isPreview && ( - - {/* Unit geometry – scaled per-frame */} - - - - )} - - {/* Cone at end */} - {!isPreview && ( - - {/* Unit geometry – scaled per-frame */} - - - - )} - - - {/* Label */} - {!isPreview && ( - - {/* Stable invisible hit area to prevent hover flicker when pin appears */} - { - event.stopPropagation(); - setIsLabelHovered(true); - if (id) { - graphicsActor.send({ type: 'setHoveredMeasurement', payload: id }); - } - }} - onPointerLeave={(event) => { - event.stopPropagation(); - setIsLabelHovered(false); - graphicsActor.send({ type: 'setHoveredMeasurement', payload: undefined }); - }} - > - {(() => { - const totalChars = backgroundPlaceholderText.length; - const baseWidth = totalChars * labelCharWidth + 2 * labelPadding; - const buttonDiameter = 2 * labelCharWidth; - const hitWidth = baseWidth + buttonDiameter + Math.max(5, labelPadding * 0.2); - const hitHeight = labelHeight + 2 * labelPadding; - return ( - <> - - - - ); - })()} - - {/* Background */} - - - - - - - - - - {/* Text */} - - - - - - {/* Pin button in top-right over label */} - {id && isHovered ? ( - { - // Compute approximate background width from placeholder and char width/padding - const totalChars = backgroundPlaceholderText.length; - const width = totalChars * labelCharWidth + 2 * labelPadding; - const buttonDiameter = 2 * labelCharWidth; // 2 characters width - const offsetX = width / 2 - buttonDiameter / 2 - Math.max(5, labelPadding * 0.2); - const offsetY = 0; // Vertically centered - return [offsetX, offsetY, 0]; - })()} - renderOrder={3} - userData={{ isMeasurementUi: true }} - > - {/* Yellow/gold circular pin button (appears only on label hover) */} - { - event.stopPropagation(); - // Keep hover state active when over pin button - setIsLabelHovered(true); - if (id) { - graphicsActor.send({ type: 'setHoveredMeasurement', payload: id }); - } - }} - onPointerOut={(event) => { - event.stopPropagation(); - // Don't clear hover immediately - let the label group handle it - }} - onPointerDown={(event) => { - if (event.nativeEvent.button === 0 && id) { - graphicsActor.send({ type: 'toggleMeasurementPinned', id }); - } - - event.stopPropagation(); - }} - > - - - - - {/* Pin glyph using simple geometry */} - { - event.stopPropagation(); - }} - onPointerOut={(event) => { - event.stopPropagation(); - }} - > - - - - { - event.stopPropagation(); - }} - onPointerOut={(event) => { - event.stopPropagation(); - }} - > - - - - - ) : null} - - )} - - ); -} diff --git a/apps/ui/app/components/geometry/graphics/three/react/section-view-controls.tsx b/apps/ui/app/components/geometry/graphics/three/react/section-view-controls.tsx deleted file mode 100644 index 43f610e79..000000000 --- a/apps/ui/app/components/geometry/graphics/three/react/section-view-controls.tsx +++ /dev/null @@ -1,648 +0,0 @@ -import React, { useRef, useMemo } from 'react'; -import type { ThreeEvent } from '@react-three/fiber'; -import * as THREE from 'three'; -import { useFrame, useThree } from '@react-three/fiber'; -import { TransformControls } from '#components/geometry/graphics/three/react/transform-controls-drei.js'; -import { pixelsToWorldUnits } from '#components/geometry/graphics/three/utils/spatial.utils.js'; -import { matcapMaterial } from '#components/geometry/graphics/three/materials/matcap-material.js'; -import { FontGeometry } from '#components/geometry/graphics/three/geometries/font-geometry.js'; -import { RoundedRectangleGeometry } from '#components/geometry/graphics/three/geometries/rounded-rectangle-geometry.js'; -import { adjustHexColorBrightness } from '#utils/color.utils.js'; - -// Module-scope scratch vectors for PlaneSelector useFrame (avoids per-frame allocations) -const _normalizedDir = new THREE.Vector3(); -const _baseOffset = new THREE.Vector3(); -const _depthOffset = new THREE.Vector3(); - -export type PlaneId = 'xy' | 'xz' | 'yz'; -export type PlaneSelectorId = 'xy' | 'xz' | 'yz' | 'yx' | 'zx' | 'zy'; -export type UpDirection = 'x' | 'y' | 'z'; - -/** - * Get the plane position for a given plane ID and up direction. - * The position determines where the plane selector is placed in 3D space. - * - * In each coordinate system, we want the "horizontal" plane (Top/Bottom) to be - * positioned along the up axis, and the vertical planes to be positioned along - * the other two axes. - */ -function getPlanePositionForUpDirection(planeId: PlaneId, upDirection: UpDirection): [number, number, number] { - // Z-up (CAD/engineering default): XY is horizontal, XZ/YZ are vertical - if (upDirection === 'z') { - if (planeId === 'xy') { - return [0, 0, -1]; - } - - if (planeId === 'xz') { - return [0, 1, 0]; - } - - // Yz - return [-1, 0, 0]; - } - - // Y-up (standard Three.js): XZ is horizontal, XY/YZ are vertical - if (upDirection === 'y') { - if (planeId === 'xy') { - return [0, 0, -1]; - } - - if (planeId === 'xz') { - return [0, -1, 0]; - } - - // Yz - return [-1, 0, 0]; - } - - // X-up: YZ is horizontal, XY/XZ are vertical - if (planeId === 'xy') { - return [0, 0, -1]; - } - - if (planeId === 'xz') { - return [0, -1, 0]; - } - - // YZ - positioned at bottom (-X direction, like Y-up has XZ at -Y) - return [-1, 0, 0]; -} - -/** - * Get the plane rotation for a given plane ID and up direction. - * The rotation orients the plane selector to face the correct direction. - */ -function getPlaneRotationForUpDirection(planeId: PlaneId, upDirection: UpDirection): [number, number, number] { - // Z-up (CAD/engineering default) - if (upDirection === 'z') { - if (planeId === 'xy') { - return [0, 0, 0]; - } - - if (planeId === 'xz') { - return [-Math.PI / 2, 0, Math.PI]; - } - - // Yz - return [Math.PI / 2, Math.PI / 2, 0]; - } - - // Y-up (standard Three.js) - if (upDirection === 'y') { - if (planeId === 'xy') { - // XY plane at [0,0,-1] faces +Z, needs to face the camera with upright text - return [0, 0, 0]; - } - - if (planeId === 'xz') { - // XZ plane at [0,-1,0] - horizontal floor plane - // Rotate 90° around X to make it horizontal, facing +Y (up) - // Add 180° around Z to flip text orientation without mirroring - return [Math.PI / 2, 0, Math.PI]; - } - - // YZ plane at [-1,0,0] - vertical side plane - // Rotate to face +X direction - return [0, Math.PI / 2, 0]; - } - - // X-up - if (planeId === 'xy') { - // XY plane at [0,0,-1] faces +Z - return [0, 0, -Math.PI / 2]; - } - - if (planeId === 'xz') { - // XZ plane at [0,-1,0] - vertical side plane - // Rotate -90° around X to face +Y, add 180° Z for upright text - return [-Math.PI / 2, Math.PI, -Math.PI / 2]; - } - - // YZ plane at [-1,0,0] - horizontal floor plane in X-up - // Rotate 90° around Y to make it horizontal, facing +X (up) - // Add 90° around Z for upright text when viewed from above - return [0, Math.PI / 2, Math.PI]; -} - -/** - * Get the labels for a plane selector based on the up direction. - * The semantic meaning of "Top/Bottom/Front/Back/Left/Right" changes depending - * on which axis is "up" and the position of the selector in 3D space. - * - * The label mapping must match the physical position of the selector: - * - The selector at the "up" position should show "Top" - * - The selector at the "down" position should show "Bottom" - * - etc. - */ -function getLabelsForUpDirection( - id: PlaneSelectorId, - naming: 'cartesian' | 'face', - upDirection: UpDirection, -): [string, string] { - if (naming === 'cartesian') { - const label = id.toUpperCase(); - return [label, label]; - } - - const base = getBaseFromSelector(id); - // IsInverse is true for 'yx', 'zx', 'zy' (the reversed versions) - const isInverse = id !== base; - - if (upDirection === 'z') { - // Z-up: XY → Top/Bottom, XZ → Front/Back, YZ → Left/Right - // XY at [0,0,-1]: faces +Z (up), so 'xy' shows "Top" - // XZ at [0,1,0]: faces -Y, so 'xz' shows "Back" - // YZ at [-1,0,0]: faces +X, so 'yz' shows "Right" - if (base === 'xy') { - return isInverse ? ['Bottom', 'Top'] : ['Top', 'Bottom']; - } - - if (base === 'xz') { - return isInverse ? ['Front', 'Back'] : ['Back', 'Front']; - } - - // Yz - return isInverse ? ['Left', 'Right'] : ['Right', 'Left']; - } - - if (upDirection === 'y') { - // Y-up: XZ → Top/Bottom, XY → Front/Back, YZ → Left/Right - // XZ at [0,-1,0]: The selector is below the model, the visible face (facing +Y) should show "Top" - // But the 'xz' ID with isInverse=true render prop uses base rotation, making it face +Y - // So 'zx' (which faces -Y, away from viewer looking down) should show "Top" - // and 'xz' (facing +Y toward viewer) should show "Bottom" - if (base === 'xz') { - return isInverse ? ['Top', 'Bottom'] : ['Bottom', 'Top']; - } - - // XY at [0,0,-1]: Front/Back need to be on opposite faces - if (base === 'xy') { - return isInverse ? ['Back', 'Front'] : ['Front', 'Back']; - } - - // Yz - Right/Left remains the same - return isInverse ? ['Left', 'Right'] : ['Right', 'Left']; - } - - // X-up: YZ → Top/Bottom, XY → Front/Back, XZ → Left/Right - // YZ at [-1,0,0]: swap so 'yz' shows "Top", 'zy' shows "Bottom" - if (base === 'yz') { - return isInverse ? ['Bottom', 'Top'] : ['Top', 'Bottom']; - } - - // XY at [0,0,-1]: 'xy' faces +Z → shows "Front", 'yx' faces -Z → shows "Back" - if (base === 'xy') { - return isInverse ? ['Back', 'Front'] : ['Front', 'Back']; - } - - // XZ at [0,-1,0]: swap so 'xz' shows "Left", 'zx' shows "Right" - return isInverse ? ['Right', 'Left'] : ['Left', 'Right']; -} - -function getBaseFromSelector(id: PlaneSelectorId): PlaneId { - if (id === 'xy' || id === 'yx') { - return 'xy'; - } - - if (id === 'xz' || id === 'zx') { - return 'xz'; - } - - return 'yz'; -} - -type PlaneSelectorProperties = { - readonly planeId: PlaneSelectorId; - readonly position: [number, number, number]; - readonly color: string; - readonly onClick: (planeId: PlaneSelectorId) => void; - readonly onHover: (planeId: PlaneSelectorId | undefined) => void; - readonly matcapTexture: THREE.Texture; - readonly size: number; - readonly offset: number; - readonly naming: 'cartesian' | 'face'; - readonly isExternallyHovered?: boolean; - readonly textDepth: number; - readonly labelDepth: number; - readonly isInverse?: boolean; - readonly upDirection: UpDirection; -}; - -function PlaneSelector({ - planeId, - position, - color, - onClick, - onHover, - matcapTexture, - size, - offset, - naming, - isExternallyHovered, - textDepth, - labelDepth, - isInverse = false, - upDirection, -}: PlaneSelectorProperties): React.JSX.Element { - const { gl, camera, size: threeSize, viewport } = useThree(); - const [isHovered, setIsHovered] = React.useState(false); - const groupRef = useRef(null); - const baseDirection = useMemo( - () => new THREE.Vector3(position[0], position[1], position[2]), - [position], - ); - const origin = useMemo(() => new THREE.Vector3(0, 0, 0), []); - - // Keep the selector a constant screen size and screen offset by updating each frame - useFrame(() => { - const currentGroup = groupRef.current; - if (!currentGroup) { - return; - } - - const desiredWorldSize = pixelsToWorldUnits({ - viewport, - camera, - size: threeSize, - at: origin, - pixels: size, - }); - const desiredWorldOffset = pixelsToWorldUnits({ - viewport, - camera, - size: threeSize, - at: origin, - pixels: offset, - }); - - // Base geometry is 1x1, so scale directly to desired world size - const scale = desiredWorldSize; - currentGroup.scale.set(scale, scale, scale); - - if (baseDirection.lengthSq() > 0) { - _normalizedDir.copy(baseDirection).normalize(); - _baseOffset.copy(_normalizedDir).multiplyScalar(desiredWorldOffset); - - // For inverse faces, add an additional offset to account for label depth - // so they're truly back-to-back without overlapping - if (isInverse) { - _depthOffset.copy(_normalizedDir).multiplyScalar(-labelDepth * scale); - } else { - _depthOffset.set(0, 0, 0); - } - - if (planeId === 'xz' || planeId === 'zx') { - currentGroup.position.copy(_baseOffset.sub(_depthOffset)); - } else { - currentGroup.position.copy(_baseOffset.add(_depthOffset)); - } - } - }); - - const handleClick = (event: ThreeEvent): void => { - event.stopPropagation(); - onClick(planeId); - }; - - const handlePointerOver = (event: ThreeEvent): void => { - event.stopPropagation(); - setIsHovered(true); - gl.domElement.style.cursor = 'pointer'; - onHover(planeId); - }; - - const handlePointerOut = (event: ThreeEvent): void => { - event.stopPropagation(); - setIsHovered(false); - gl.domElement.style.cursor = 'auto'; - onHover(undefined); - }; - - const [forwardPlaneName] = getLabelsForUpDirection(planeId, naming, upDirection); - - const frontFontGeometry = useMemo( - // eslint-disable-next-line new-cap -- Three.js naming convention - () => FontGeometry({ text: forwardPlaneName, depth: textDepth, size: 0.2 }), - [forwardPlaneName, textDepth], - ); - const roundedRectangleGeometry = useMemo( - // eslint-disable-next-line new-cap -- Three.js naming convention - () => RoundedRectangleGeometry({ width: 1, height: 1, radius: 0.1, smoothness: 16, depth: labelDepth }), - [labelDepth], - ); - const darkenedColor = useMemo(() => adjustHexColorBrightness(color, -0.5), [color]); - const slightlyDarkenedColor = useMemo(() => adjustHexColorBrightness(color, -0.3), [color]); - - const baseRotation = getPlaneRotationForUpDirection(getBaseFromSelector(planeId), upDirection); - // For inverse faces, rotate 180 degrees to face the opposite direction - // The rotation axis depends on the up direction to maintain upright text - const rotation = isInverse - ? baseRotation - : ((): [number, number, number] => { - const base = getBaseFromSelector(planeId); - - // X-up needs different inverse rotations due to different base rotations - if (upDirection === 'x') { - if (base === 'xy') { - // Front→Back: flip around X to keep text upright - return [baseRotation[0] + Math.PI, baseRotation[1], baseRotation[2]]; - } - - if (base === 'xz') { - // Right→Left: flip around X - return [baseRotation[0] + Math.PI, baseRotation[1], baseRotation[2]]; - } - - // YZ (Top→Bottom): flip around Y - return [baseRotation[0], baseRotation[1] + Math.PI, baseRotation[2]]; - } - - // Z-up and Y-up use 180° Y rotation - if (base === 'xy') { - return [baseRotation[0], baseRotation[1] + Math.PI, baseRotation[2]]; - } - - if (base === 'xz') { - return [baseRotation[0], baseRotation[1] + Math.PI, baseRotation[2]]; - } - - // Base === 'yz' - return [baseRotation[0], baseRotation[1] + Math.PI, baseRotation[2]]; - })(); - const displayedHover = isHovered || Boolean(isExternallyHovered); - const actualColor = displayedHover ? darkenedColor : slightlyDarkenedColor; - - return ( - - - - - - - - - - - ); -} - -export type AvailablePlane = { id: PlaneId; normal: [number, number, number]; constant: number }; - -type SectionViewControlsProperties = { - readonly isActive: boolean; - readonly selectedPlaneId: PlaneId | undefined; - readonly availablePlanes: AvailablePlane[]; - readonly pivot?: [number, number, number]; - readonly rotation: [number, number, number]; - readonly planeName: 'cartesian' | 'face'; - readonly hoveredSectionViewId: PlaneSelectorId | undefined; - readonly upDirection: UpDirection; - readonly onSelectPlane: (planeId: PlaneSelectorId) => void; - readonly onHover: (planeId: PlaneSelectorId | undefined) => void; - readonly onSetRotation: (rotation: THREE.Euler) => void; - readonly onSetPivot?: (value: [number, number, number]) => void; -}; - -export function SectionViewControls({ - isActive, - selectedPlaneId, - availablePlanes, - pivot, - rotation, - planeName, - hoveredSectionViewId, - upDirection, - onSelectPlane, - onHover, - onSetRotation, - onSetPivot, -}: SectionViewControlsProperties): React.JSX.Element | undefined { - const transformControlsRef = useRef(undefined); - // Track the latest rotation locally to project translation along the rotated plane normal - const rotationRef = useRef(new THREE.Euler(0, 0, 0)); - // Keep an optional world-space anchor so the gizmo doesn't "jump" after rotations - const anchorPositionRef = useRef(undefined); - const matcapTexture = useMemo(() => matcapMaterial(), []); - // Track whether the user is actively dragging translate/rotate so we don't override the position mid-drag - const isTranslatingRef = useRef(false); - const isRotatingRef = useRef(false); - // World-space pivot point to keep the plane anchored during rotation - const pivotPointRef = useRef(new THREE.Vector3()); - - const planes = React.useMemo(() => { - const planes: Array<{ idPos: PlaneSelectorId; idNeg: PlaneSelectorId; normal: THREE.Vector3; color: string }> = [ - { idPos: 'xy', idNeg: 'yx', normal: new THREE.Vector3(0, 0, -1), color: '#3b82f6' }, - { idPos: 'xz', idNeg: 'zx', normal: new THREE.Vector3(0, -1, 0), color: '#22c55e' }, - { idPos: 'yz', idNeg: 'zy', normal: new THREE.Vector3(-1, 0, 0), color: '#ef4444' }, - ]; - - return planes; - }, []); - - // Find the selected plane configuration - const selectedPlane = availablePlanes.find((plane) => plane.id === selectedPlaneId); - - // Calculate plane properties before any conditional returns - const [nx, ny, nz] = selectedPlane?.normal ?? [0, 0, 1]; - const normal = new THREE.Vector3(nx, ny, nz); - - // Single frame loop to keep rotation and position in sync. - // - When not dragging rotate: sync object rotation from props - // - When not dragging translate/rotate: position object at pivot - useFrame(() => { - const { current } = transformControlsRef; - if (!current || !selectedPlane) { - return; - } - - // Sync external rotation when not rotating - if (!isRotatingRef.current) { - rotationRef.current.set(rotation[0], rotation[1], rotation[2]); - current.rotation.set(rotation[0], rotation[1], rotation[2]); - } - - // While dragging, do not override transform-controls position - if (isRotatingRef.current || isTranslatingRef.current) { - return; - } - - // Keep anchor if set (post-drag/rotate) - if (anchorPositionRef.current) { - current.position.copy(anchorPositionRef.current); - return; - } - - // Controlled position from pivot - if (pivot) { - current.position.set(pivot[0], pivot[1], pivot[2]); - } - }); - - // Keep transform controls controlled by external state changes. - // When plane selection, translation, or rotation are changed via the UI/state - // (not by dragging), clear any anchor so the gizmo snaps to the computed - // position in the next frame. - React.useEffect(() => { - if (isTranslatingRef.current || isRotatingRef.current) { - return; - } - - anchorPositionRef.current = undefined; - }, [selectedPlaneId, rotation, pivot]); - - if (!isActive) { - return undefined; - } - - // If no plane is selected, show the 6 plane selectors (3 base + 3 inverse faces) - // Constants for depth calculations - extracted to allow precise back-to-back positioning - const textDepth = 0.01; - const labelDepth = 0.02; - const offsetPx = 40; - if (!selectedPlane) { - return ( - - {planes.map(({ idPos, idNeg, color }) => { - // Use coordinate-aware position based on up direction - const baseId = getBaseFromSelector(idPos); - const position = getPlanePositionForUpDirection(baseId, upDirection); - - return ( - - - - - ); - })} - - ); - } - - return ( - - {/* Hidden transform controls for dragging logic */} - - - - - - } - mode="translate" - space="local" - size={1} - visible={false} - showX={Math.abs(normal.x) > 0.5} - showY={Math.abs(normal.y) > 0.5} - showZ={Math.abs(normal.z) > 0.5} - onChange={() => { - if (!isTranslatingRef.current) { - return; - } - - const currentObject = transformControlsRef.current; - if (currentObject) { - const { position } = currentObject; - if (onSetPivot) { - onSetPivot([position.x, position.y, position.z]); - } - } - }} - onPointerDown={() => { - // Keep current anchor so the gizmo does not snap to the plane projection - isTranslatingRef.current = true; - }} - onPointerUp={() => { - isTranslatingRef.current = false; - // Persist the final world position as the new anchor to avoid any post-drag snapping - if (transformControlsRef.current) { - anchorPositionRef.current = transformControlsRef.current.position.clone(); - } - }} - /> - } - mode="rotate" - space="local" - size={1} - visible={false} - showX={Math.abs(normal.y) > 0.5 || Math.abs(normal.z) > 0.5} - showY={Math.abs(normal.x) > 0.5 || Math.abs(normal.z) > 0.5} - showZ={Math.abs(normal.x) > 0.5 || Math.abs(normal.y) > 0.5} - onChange={() => { - if (!isRotatingRef.current) { - return; - } - - const currentObject = transformControlsRef.current; - if (currentObject) { - // Extract the rotation from the object - const rotation = currentObject.rotation.clone(); - rotationRef.current.copy(rotation); - onSetRotation(rotation); - // Do not change translation here; machine derives display value from pivot - } - }} - onPointerDown={() => { - isRotatingRef.current = true; - if (transformControlsRef.current) { - // Capture current gizmo world position as the rotation pivot - pivotPointRef.current.copy(transformControlsRef.current.position); - // Set anchor so when rotation ends the gizmo stays where it was left - anchorPositionRef.current = pivotPointRef.current.clone(); - } - }} - onPointerUp={() => { - isRotatingRef.current = false; - // Keep anchor until the next manipulation (or translation drag) - }} - /> - - ); -} diff --git a/apps/ui/app/components/geometry/graphics/three/react/section-view.tsx b/apps/ui/app/components/geometry/graphics/three/react/section-view.tsx deleted file mode 100644 index 7cec7b437..000000000 --- a/apps/ui/app/components/geometry/graphics/three/react/section-view.tsx +++ /dev/null @@ -1,279 +0,0 @@ -/** - * Credit to https://github.com/r3f-cutter/r3f-cutter for the original implementation. - * - * This has been modified to support conditional cutting of meshes and lines. - */ - -import * as React from 'react'; -import * as THREE from 'three'; -import { useFrame, useThree } from '@react-three/fiber'; -import { Plane } from '@react-three/drei'; - -// Reusable temporaries for per-frame plane positioning (avoids GC pressure) -const _defaultNormal = new THREE.Vector3(0, 0, 1); -const _quaternion = new THREE.Quaternion(); -const _worldPosition = new THREE.Vector3(); - -export type CutterProperties = { - readonly children: React.ReactNode; - readonly plane: THREE.Plane; - readonly enableSection?: boolean; - readonly enableLines?: boolean; - readonly enableMesh?: boolean; - readonly cappingMaterial: THREE.Material; -}; - -type PlaneStencilGroupProperties = { - readonly meshObj: THREE.Mesh; - readonly plane: THREE.Plane; - readonly renderOrder: number; -}; - -export const SectionView = React.forwardRef<{ update: () => void }, CutterProperties>( - ( - { children, plane, enableSection = true, enableLines = true, enableMesh = true, cappingMaterial }, - ref, - ): React.JSX.Element => { - const { gl } = useThree(); - const rootGroupRef = React.useRef(null); - - const [meshList, setMeshList] = React.useState([]); - const [planeSize, setPlaneSize] = React.useState(10); - // Track the previous set of mesh IDs to avoid unnecessary setMeshList calls - // when only clipping plane values changed (the mesh list itself is stable). - const previousMeshIdsRef = React.useRef(''); - - const update: () => void = React.useCallback(() => { - // Early return if cutting is disabled - if (!enableSection) { - setMeshList([]); - - // Remove clipping planes from all objects when cutting is disabled - const rootGroup = rootGroupRef.current; - - if (rootGroup) { - rootGroup.traverse((child: THREE.Object3D) => { - const isMeshOrLine = child instanceof THREE.Mesh || child instanceof THREE.LineSegments; - - if (isMeshOrLine && child.material) { - if (Array.isArray(child.material)) { - for (const mat of child.material) { - mat.clippingPlanes = []; - } - } else { - child.material.clippingPlanes = []; - } - } - }); - } - - return; - } - - const meshChildren: THREE.Mesh[] = []; - const rootGroup = rootGroupRef.current; - - if (rootGroup) { - rootGroup.traverse((child: THREE.Object3D) => { - // Handle LineSegments - apply/clear clipping but no caps - if (child instanceof THREE.LineSegments) { - if (child.material) { - if (Array.isArray(child.material)) { - for (const mat of child.material) { - mat.clippingPlanes = enableLines ? [plane] : []; - } - } else { - child.material.clippingPlanes = enableLines ? [plane] : []; - } - } - - return; // Lines don't get caps, so return early - } - - // Clear clipping planes from meshes if not enabled - if (child instanceof THREE.Mesh && !enableMesh) { - if (child.material) { - if (Array.isArray(child.material)) { - for (const mat of child.material) { - mat.clippingPlanes = []; - } - } else { - child.material.clippingPlanes = []; - } - } - - return; - } - - if (child instanceof THREE.Mesh && child.material && child.geometry) { - child.matrixAutoUpdate = false; - - // Add clipping planes to each mesh and make sure that the material is - // double sided. This is needed to create PlaneStencilGroup for the mesh. - if (Array.isArray(child.material)) { - for (const mat of child.material) { - mat.clippingPlanes = [plane]; - mat.side = THREE.DoubleSide; - } - } else { - child.material.clippingPlanes = [plane]; - child.material.side = THREE.DoubleSide; - } - - // Three.js mesh types are complex and involve generics - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- Mesh type generics are complex - meshChildren.push(child); - } - }); - - // Only update the mesh list and recompute bounds when the set of meshes - // actually changed (not on every plane drag). The mesh list is stable during - // plane manipulation -- only clipping plane values on materials change. - const meshIdsKey = meshChildren.map((m) => m.id).join(','); - if (meshIdsKey !== previousMeshIdsRef.current) { - previousMeshIdsRef.current = meshIdsKey; - - const bbox = new THREE.Box3(); - bbox.setFromObject(rootGroup); - - const boxSize = new THREE.Vector3(); - bbox.getSize(boxSize); - - const calculatedPlaneSize = 2 * boxSize.length(); - setPlaneSize(calculatedPlaneSize); - setMeshList(meshChildren); - } - } - // Depend on primitive values instead of plane object to avoid infinite loop - // eslint-disable-next-line react-hooks/exhaustive-deps -- plane.normal and plane.constant are extracted below - }, [ - plane.normal.x, - plane.normal.y, - plane.normal.z, - plane.constant, - enableSection, - enableLines, - enableMesh, - cappingMaterial, - ]); - - const planeListRef = React.useRef> | undefined>(undefined); - - // See - // https://react.dev/learn/manipulating-the-dom-with-refs#how-to-manage-a-list-of-refs-using-a-ref-callback - function getPlaneListMap(): Map> { - planeListRef.current ??= new Map>(); - return planeListRef.current; - } - - useFrame(() => { - if (enableSection && planeListRef.current && rootGroupRef.current) { - // Reuse module-scoped temporaries to avoid per-frame allocations - _defaultNormal.set(0, 0, 1); - _quaternion.setFromUnitVectors(_defaultNormal, plane.normal); - - for (const [, planeObject] of planeListRef.current) { - // Get a point on the clipping plane in world space - plane.coplanarPoint(_worldPosition); - - // Offset slightly opposite to the plane normal to prevent z-fighting with the mesh surface - const zFightingOffset = 0.1; - _worldPosition.addScaledVector(plane.normal, -zFightingOffset); - - // Transform the world position to the local space of the root group - // This accounts for any centering or translation applied to the parent group - rootGroupRef.current.worldToLocal(planeObject.position.copy(_worldPosition)); - - // Orient the plane to match the clipping plane's normal - planeObject.quaternion.copy(_quaternion); - } - } - }); - - React.useEffect(() => { - update(); - }, [update, children]); - - // Enable/disable local clipping and stencil based on cutting state - React.useEffect(() => { - const shouldEnable = enableSection && (meshList.length > 0 || enableLines); - gl.localClippingEnabled = shouldEnable; - - return () => { - gl.localClippingEnabled = false; - }; - }, [gl, enableSection, enableLines, meshList.length]); - - React.useImperativeHandle(ref, () => ({ update }), [update]); - - return ( - - {children} - {enableSection && meshList.length > 0 ? ( - <> - - {meshList.map((meshObject, index) => ( - - ))} - - {meshList.map((meshObject, index) => ( - - { - const map = getPlaneListMap(); - if (node) { - map.set(index, node); - } else { - map.delete(index); - } - }} - args={[planeSize, planeSize]} - renderOrder={index + 1} - material={cappingMaterial} - onAfterRender={(renderer) => { - renderer.clearStencil(); - }} - /> - - ))} - - ) : null} - - ); - }, -); - -function PlaneStencilGroup({ meshObj, plane, renderOrder }: PlaneStencilGroupProperties): React.JSX.Element { - return ( - - - - - - - - - ); -} diff --git a/apps/ui/app/components/geometry/graphics/three/react/transform-controls-drei.tsx b/apps/ui/app/components/geometry/graphics/three/react/transform-controls-drei.tsx deleted file mode 100644 index ed950be0b..000000000 --- a/apps/ui/app/components/geometry/graphics/three/react/transform-controls-drei.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import type { ThreeElement, ThreeElements } from '@react-three/fiber'; -import { useThree } from '@react-three/fiber'; -import * as React from 'react'; -import * as THREE from 'three'; -import type { ForwardRefComponent } from '@react-three/drei/helpers/ts-utils.js'; -import { TransformControls as TransformControlsImpl } from '#components/geometry/graphics/three/controls/transform-controls.js'; - -type ControlsProto = { - enabled: boolean; -}; - -export type TransformControlsProps = Omit, 'ref' | 'args'> & - Omit & { - readonly object?: THREE.Object3D | React.RefObject; - // eslint-disable-next-line react/boolean-prop-naming -- copied verbatim, keeping the same API intentionally here. - readonly enabled?: boolean; - readonly axis?: string | undefined; - readonly domElement?: HTMLElement; - readonly mode?: 'translate' | 'rotate' | 'scale'; - readonly translationSnap?: number | undefined; - readonly rotationSnap?: number | undefined; - readonly scaleSnap?: number | undefined; - readonly space?: 'world' | 'local'; - readonly size?: number; - // eslint-disable-next-line react/boolean-prop-naming -- copied verbatim, keeping the same API intentionally here. - readonly showX?: boolean; - // eslint-disable-next-line react/boolean-prop-naming -- copied verbatim, keeping the same API intentionally here. - readonly showY?: boolean; - // eslint-disable-next-line react/boolean-prop-naming -- copied verbatim, keeping the same API intentionally here. - readonly showZ?: boolean; - readonly children?: React.ReactElement; - readonly camera?: THREE.Camera; - readonly onChange?: (event?: THREE.Event) => void; - readonly onPointerDown?: (event?: THREE.Event) => void; - readonly onPointerUp?: (event?: THREE.Event) => void; - readonly onObjectChange?: (event?: THREE.Event) => void; - readonly makeDefault?: boolean; - }; - -export const TransformControls: ForwardRefComponent = - /* @__PURE__ */ React.forwardRef( - ( - { - children, - domElement, - onChange, - onPointerDown, - onPointerUp, - onObjectChange, - object, - makeDefault, - camera, - // Transform - enabled, - axis, - mode, - translationSnap, - rotationSnap, - scaleSnap, - space, - size, - showX, - showY, - showZ, - ...props - }, - ref, - ) => { - const defaultControls = useThree((state) => state.controls) as unknown as ControlsProto | undefined; - const gl = useThree((state) => state.gl); - const events = useThree((state) => state.events); - const defaultCamera = useThree((state) => state.camera); - const invalidate = useThree((state) => state.invalidate); - const get = useThree((state) => state.get); - const set = useThree((state) => state.set); - const explCamera = camera ?? defaultCamera; - const explDomElement = (domElement ?? events.connected ?? gl.domElement) as HTMLElement; - const controls = React.useMemo( - () => new TransformControlsImpl(explCamera, explDomElement), - [explCamera, explDomElement], - ); - const group = React.useRef(null!); - - React.useLayoutEffect(() => { - if (object) { - controls.attach(object instanceof THREE.Object3D ? object : object.current); - } else if (group.current instanceof THREE.Object3D) { - controls.attach(group.current); - } - - return () => { - void controls.detach(); - }; - }, [object, children, controls]); - - React.useEffect(() => { - if (defaultControls) { - const callback = (event: { value: boolean }) => { - defaultControls.enabled = !event.value; - }; - - // @ts-expect-error -- adding a new event. - controls.addEventListener('dragging-changed', callback); - return () => { - // @ts-expect-error -- adding a new event. - controls.removeEventListener('dragging-changed', callback); - }; - } - - return () => { - // No-op when makeDefault=false. - }; - }, [controls, defaultControls]); - - const onChangeRef = React.useRef<((event?: THREE.Event) => void) | undefined>(undefined); - const onPointerDownRef = React.useRef<((event?: THREE.Event) => void) | undefined>(undefined); - const onPointerUpRef = React.useRef<((event?: THREE.Event) => void) | undefined>(undefined); - const onObjectChangeRef = React.useRef<((event?: THREE.Event) => void) | undefined>(undefined); - - React.useLayoutEffect(() => { - onChangeRef.current = onChange; - }, [onChange]); - React.useLayoutEffect(() => { - onPointerDownRef.current = onPointerDown; - }, [onPointerDown]); - React.useLayoutEffect(() => { - onPointerUpRef.current = onPointerUp; - }, [onPointerUp]); - React.useLayoutEffect(() => { - onObjectChangeRef.current = onObjectChange; - }, [onObjectChange]); - - React.useEffect(() => { - const onChange = (event: THREE.Event) => { - invalidate(); - onChangeRef.current?.(event); - }; - - const onPointerDown = (event: THREE.Event) => onPointerDownRef.current?.(event); - const onPointerUp = (event: THREE.Event) => onPointerUpRef.current?.(event); - const onObjectChange = (event: THREE.Event) => onObjectChangeRef.current?.(event); - - // @ts-expect-error -- newly added events - controls.addEventListener('change', onChange); - // @ts-expect-error -- newly added events - controls.addEventListener('pointerDown', onPointerDown); - // @ts-expect-error -- newly added events - controls.addEventListener('pointerUp', onPointerUp); - // @ts-expect-error -- newly added events - controls.addEventListener('objectChange', onObjectChange); - - return () => { - // @ts-expect-error -- newly added events - controls.removeEventListener('change', onChange); - // @ts-expect-error -- newly added events - controls.removeEventListener('pointerDown', onPointerDown); - // @ts-expect-error -- newly added events - controls.removeEventListener('pointerUp', onPointerUp); - // @ts-expect-error -- newly added events - controls.removeEventListener('objectChange', onObjectChange); - }; - }, [invalidate, controls]); - - React.useEffect(() => { - if (makeDefault) { - const old = get().controls; - set({ controls }); - return () => { - set({ controls: old }); - }; - } - - return () => { - // No-op when makeDefault=false. - }; - }, [makeDefault, controls, get, set]); - - return ( - <> - - - {children} - - - ); - }, - ); diff --git a/apps/ui/app/components/geometry/graphics/three/scene-overlay.tsx b/apps/ui/app/components/geometry/graphics/three/scene-overlay.tsx deleted file mode 100644 index ca6bd44c0..000000000 --- a/apps/ui/app/components/geometry/graphics/three/scene-overlay.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import type { ReactNode } from 'react'; -import { useMemo } from 'react'; -import * as THREE from 'three'; -import { createPortal, useFrame } from '@react-three/fiber'; - -type SceneOverlayProperties = { - readonly children: ReactNode; -}; - -/** - * Renders children in a separate THREE.Scene that composites on top of the - * post-processed output. This keeps overlay elements (Grid, AxesHelper) - * outside the EffectComposer pipeline so they are not affected by N8AO - * ambient-occlusion darkening. - * - * Runs at `renderPriority = 2` (after EffectComposer at priority 1). - * - * ### Automatic adaptation - * - * The component auto-detects whether an EffectComposer (or any other - * positive-priority render owner) is active by reading R3F's internal - * subscriber count (`state.internal.priority`). - * - * - **Post-processing active** (`priority > 1`): the EffectComposer already - * wrote colour to the screen but clobbered the depth buffer with its - * fullscreen-quad output. We restore scene depth via a depth-only - * re-render (`colorMask(false …)`) before compositing the overlay. - * - * - **No post-processing** (`priority === 1`, i.e. we are the sole render - * owner): we render the full scene ourselves (colour + depth), then - * composite the overlay on top. This means the EffectComposer can be - * freely added or removed without any prop changes to SceneOverlay. - */ -export function SceneOverlay({ children }: SceneOverlayProperties): React.JSX.Element { - const overlayScene = useMemo(() => new THREE.Scene(), []); - - // Lightweight material for the depth-only pass. MeshBasicMaterial has built-in - // logarithmic depth support and skips all PBR/matcap/environment shader work. - const depthOnlyMaterial = useMemo(() => { - const mat = new THREE.MeshBasicMaterial(); - mat.colorWrite = false; - return mat; - }, []); - - useFrame((state) => { - // Skip entirely when the overlay scene has no children to render - if (overlayScene.children.length === 0) { - return; - } - - const { gl, scene, camera } = state; - const previousAutoClear = gl.autoClear; - gl.autoClear = false; - - if (state.internal.priority > 1) { - // Another render-owner (EffectComposer) already wrote colour. - // Restore scene depth only with a lightweight override material, - // preserving the post-processed image while avoiding full material shaders. - const previousOverrideMaterial = scene.overrideMaterial; - scene.overrideMaterial = depthOnlyMaterial; - gl.render(scene, camera); - scene.overrideMaterial = previousOverrideMaterial; - } else { - // We are the sole render-owner. Render the full scene ourselves. - gl.autoClear = true; - gl.render(scene, camera); - gl.autoClear = false; - } - - // Overlay pass: grid / axes depth-test correctly against the model. - gl.render(overlayScene, camera); - - gl.autoClear = previousAutoClear; - }, 2); // Priority 2: runs after EffectComposer (priority 1) - - return <>{createPortal(children, overlayScene)}; -} diff --git a/apps/ui/app/components/geometry/graphics/three/scene.tsx b/apps/ui/app/components/geometry/graphics/three/scene.tsx deleted file mode 100644 index b2161ca5b..000000000 --- a/apps/ui/app/components/geometry/graphics/three/scene.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { ReactNode } from 'react'; -import type { StageOptions } from '#components/geometry/graphics/three/stage.js'; -import { Stage } from '#components/geometry/graphics/three/stage.js'; -import { Controls } from '#components/geometry/graphics/three/controls.js'; -import { UpDirectionHandler } from '#components/geometry/graphics/three/up-direction-handler.js'; - -type SceneProperties = { - readonly children: ReactNode; - readonly enableGizmo?: boolean; - readonly enableDamping?: boolean; - readonly enableZoom?: boolean; - readonly enablePan?: boolean; - readonly upDirection?: 'x' | 'y' | 'z'; - readonly stageOptions?: StageOptions; - readonly enableCentering?: boolean; - readonly zoomSpeed: number; - readonly gizmoContainer?: HTMLElement | string; -}; - -export function Scene({ - children, - enableGizmo = false, - enableDamping = false, - enableZoom = false, - enablePan = false, - upDirection = 'z', - stageOptions, - enableCentering = false, - zoomSpeed, - gizmoContainer, -}: SceneProperties): React.JSX.Element { - return ( - <> - - - - {children} - - - ); -} diff --git a/apps/ui/app/components/geometry/graphics/three/stage.tsx b/apps/ui/app/components/geometry/graphics/three/stage.tsx deleted file mode 100644 index be158c1a4..000000000 --- a/apps/ui/app/components/geometry/graphics/three/stage.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import React from 'react'; -import type { ReactNode } from 'react'; -import type * as THREE from 'three'; -import { PerspectiveCamera } from '@react-three/drei'; -import { Lights } from '#components/geometry/graphics/three/react/lights.js'; -import { SectionView } from '#components/geometry/graphics/three/react/section-view.js'; -import { useSectionView } from '#components/geometry/graphics/three/use-section-view.js'; -import { useGeometryBounds } from '#components/geometry/graphics/three/use-geometry-bounds.js'; -import { useCameraFraming } from '#components/geometry/graphics/three/use-camera-framing.js'; -import { useGraphicsSelector } from '#hooks/use-graphics.js'; - -export type StageOptions = { - /** - * The ratio of the scene's radius to offset the camera from the center. Adjusting this value will change the applied perspective of the scene. - */ - offsetRatio?: number; - /** - * The near plane of the camera. - */ - nearPlane?: number; - /** - * The minimum far plane of the camera. - */ - minimumFarPlane?: number; - /** - * The multiplier for the camera's far plane. - */ - farPlaneRadiusMultiplier?: number; - /** - * The zoom level of the camera. - */ - zoomLevel?: number; - rotation?: { - /** - * The initial z-axis rotation of the camera in radians. - */ - side?: number; - - /** - * The initial xy-plane rotation of the camera in radians. - */ - vertical?: number; - }; -}; - -// Default configuration constants -export const defaultStageOptions = { - offsetRatio: 2, - nearPlane: 1e-3, - minimumFarPlane: 10_000_000_000, - farPlaneRadiusMultiplier: 5, - zoomLevel: 1, - rotation: { - side: -Math.PI / 4, // Default rotation is 45 degrees counter-clockwise - vertical: Math.PI / 6, // Default rotation is 30 degrees upwards - }, -} as const satisfies StageOptions; - -type StageProperties = { - readonly children: ReactNode; - readonly enableCentering?: boolean; - readonly stageOptions?: StageOptions; -} & Omit, 'id'>; - -export function Stage({ - children, - enableCentering = false, - stageOptions = defaultStageOptions, - ...properties -}: StageProperties): React.JSX.Element { - const outer = React.useRef(null); - const inner = React.useRef(null); - - const enableMatcap = useGraphicsSelector((state) => state.context.enableMatcap); - const environmentPreset = useGraphicsSelector((state) => state.context.environmentPreset); - const upDirection = useGraphicsSelector((state) => state.context.upDirection); - - // Section view (clipping plane + capping material) - const sectionView = useSectionView(); - - // Geometry bounds tracking (per-frame bounding sphere + optional centering) - const { geometryRadius, geometryCenter } = useGeometryBounds(inner, outer, { enableCentering }); - - // Camera framing policy (auto-reset on significant geometry changes) - useCameraFraming(geometryRadius, geometryCenter, stageOptions); - - return ( - - - - - {children} - - - - - ); -} diff --git a/apps/ui/app/components/geometry/graphics/three/three-context.tsx b/apps/ui/app/components/geometry/graphics/three/three-context.tsx deleted file mode 100644 index 24c8b3d2b..000000000 --- a/apps/ui/app/components/geometry/graphics/three/three-context.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { CadCanvas } from '@taucad/three/react'; -import type { StageOptions } from '@taucad/three/react'; -import { ActorBridge } from '#components/geometry/graphics/three/actor-bridge.js'; -import { cn } from '#utils/ui.utils.js'; -import { useWebglContextRef } from '#hooks/use-webgl-context-tracker.js'; -import { WebglContextLostFallback } from '#components/geometry/graphics/three/webgl-context-lost-fallback.js'; -import { WebglLimitFallback } from '#components/geometry/cad/webgl-fallback.js'; - -export type ThreeViewerProperties = { - readonly enableGizmo?: boolean; - readonly enableGrid?: boolean; - readonly enableAxes?: boolean; - readonly enableZoom?: boolean; - readonly enablePan?: boolean; - readonly enableDamping?: boolean; - readonly upDirection?: 'x' | 'y' | 'z'; - readonly className?: string; - readonly enableCentering?: boolean; - readonly stageOptions?: StageOptions; - readonly zoomSpeed?: number; - readonly gizmoContainer?: HTMLElement | string; -}; - -export type ThreeContextProperties = ThreeViewerProperties & { - readonly children?: React.ReactNode; -}; - -export function ThreeProvider({ - children, - enableGizmo = false, - enableGrid = false, - enableAxes = false, - enableZoom = false, - enablePan = false, - enableDamping = false, - upDirection = 'z', - enableCentering = false, - className, - stageOptions, - zoomSpeed = 2, - gizmoContainer, -}: ThreeContextProperties): React.JSX.Element { - const [isContextLost, setIsContextLost] = useState(false); - - // Read the actor snapshot once at mount to decide whether we can create a - // new WebGL context. This is intentionally NON-reactive -- we never - // subscribe to count changes. A reactive subscription would cause an - // infinite re-render loop because acquire/release events would - // synchronously flip `isAtLimit`. - // - // `webglRef` is `undefined` when no `` is - // mounted above this component (e.g. single-viewer pages). - const webglRef = useWebglContextRef(); - - // eslint-disable-next-line react/hook-use-state -- one-time snapshot, setter intentionally unused - const [isOverLimit] = useState(() => { - if (!webglRef) { - return false; - } - - const snap = webglRef.getSnapshot(); - return snap.context.count >= snap.context.limit; - }); - - useEffect(() => { - if (!webglRef || isOverLimit) { - return; - } - - webglRef.send({ type: 'acquire' }); - return () => { - webglRef.send({ type: 'release' }); - }; - }, [webglRef, isOverLimit]); - - const [canvasKey, setCanvasKey] = useState(0); - const handleRetry = useCallback(() => { - setIsContextLost(false); - setCanvasKey((previous) => previous + 1); - }, []); - - if (isOverLimit) { - return ; - } - - if (isContextLost) { - return ; - } - - return ( - { - setIsContextLost(true); - }} - > - {children} - - - ); -} diff --git a/apps/ui/app/components/geometry/graphics/three/up-direction-handler.tsx b/apps/ui/app/components/geometry/graphics/three/up-direction-handler.tsx deleted file mode 100644 index 8119eca37..000000000 --- a/apps/ui/app/components/geometry/graphics/three/up-direction-handler.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useEffect } from 'react'; -import { useThree } from '@react-three/fiber'; -import type { OrbitControls } from 'three/addons'; -import * as THREE from 'three'; -import { useCameraCapability } from '#hooks/use-graphics.js'; - -type UpDirectionHandlerProperties = { - readonly upDirection: 'x' | 'y' | 'z'; -}; - -/** - * Component that handles dynamic up direction changes for the camera and all scene objects. - * Must be inside the Canvas component to access the Three.js context. - */ -export function UpDirectionHandler({ upDirection }: UpDirectionHandlerProperties): undefined { - const { camera, scene, controls, invalidate } = useThree(); - const cameraCapabilityActor = useCameraCapability(); - - useEffect(() => { - // Define the new up direction based on the selected axis - // x: X-up (1, 0, 0) - Alternative coordinate system - // y: Y-up (0, 1, 0) - Standard Three.js - // z: Z-up (0, 0, 1) - CAD/engineering default - const newUp = - upDirection === 'x' - ? new THREE.Vector3(1, 0, 0) - : upDirection === 'y' - ? new THREE.Vector3(0, 1, 0) - : new THREE.Vector3(0, 0, 1); - - // Set the global default for new objects - THREE.Object3D.DEFAULT_UP.copy(newUp); - - // Update the camera's up vector - camera.up.copy(newUp); - - // Set up vectors on all objects without matrix updates during traverse, - // then call updateMatrixWorld once on the scene root. This reduces O(N²) - // work (recursive update inside traverse) to a single O(N) pass. - scene.traverse((object) => { - object.up.copy(newUp); - }); - scene.updateMatrixWorld(true); - - // Update the camera's orientation - camera.lookAt(0, 0, 0); - camera.updateProjectionMatrix(); - - // Update controls if they exist (OrbitControls type) - if (controls && 'target' in controls && 'update' in controls) { - const orbitControls = controls as OrbitControls; - orbitControls.target.set(0, 0, 0); - orbitControls.update(); - } - - // Trigger a camera reset to properly position the camera for the new up direction - cameraCapabilityActor.send({ type: 'reset' }); - - // Force a render - invalidate(); - }, [upDirection, camera, scene, controls, invalidate, cameraCapabilityActor]); - - return undefined; -} diff --git a/apps/ui/app/components/geometry/graphics/three/use-camera-framing.test.ts b/apps/ui/app/components/geometry/graphics/three/use-camera-framing.test.ts deleted file mode 100644 index 74405eb00..000000000 --- a/apps/ui/app/components/geometry/graphics/three/use-camera-framing.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { renderHook } from '@testing-library/react'; -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { Vector3 } from 'three'; -import type { StageOptions } from '#components/geometry/graphics/three/stage.js'; -import { defaultStageOptions } from '#components/geometry/graphics/three/stage.js'; -import { useCameraFraming } from '#components/geometry/graphics/three/use-camera-framing.js'; - -// ── Controllable mocks ─────────────────────────────────────────────────────── - -/** Viewport size reported by `useThree()`. Mutate before render/rerender. */ -const mockSize = { width: 800, height: 600 }; - -vi.mock('@react-three/fiber', () => ({ - useThree: () => ({ size: mockSize }), -})); - -vi.mock('#hooks/use-graphics.js', () => ({ - useGraphicsSelector: () => 50, -})); - -/** - * Spy returned by the mocked `useCameraReset`. When invoked, simulates the - * real behaviour by committing the current geometry radius via `setSceneRadius`. - */ -const mockResetCamera = vi.fn(); - -/** Shape of the params that `useCameraFraming` forwards to `useCameraReset`. */ -type CapturedResetParameters = { - geometryRadius: number; - geometryCenter: Vector3; - setSceneRadius: (radius: number) => void; - rotation: { side: number; vertical: number }; - perspective: { - offsetRatio: number; - zoomLevel: number; - nearPlane: number; - minimumFarPlane: number; - farPlaneRadiusMultiplier: number; - }; - cameraFovAngle: number; -}; - -/** Latest params forwarded to `useCameraReset` by the hook under test. */ -let latestResetParameters: CapturedResetParameters; - -vi.mock('#components/geometry/graphics/three/use-camera-reset.js', () => ({ - useCameraReset: vi.fn((parameters: CapturedResetParameters) => { - latestResetParameters = parameters; - mockResetCamera.mockImplementation(() => { - latestResetParameters.setSceneRadius(latestResetParameters.geometryRadius); - }); - return mockResetCamera; - }), -})); - -// ── Helpers ────────────────────────────────────────────────────────────────── - -const origin = new Vector3(0, 0, 0); - -// ── Setup ──────────────────────────────────────────────────────────────────── - -beforeEach(() => { - mockResetCamera.mockClear(); - mockSize.width = 800; - mockSize.height = 600; -}); - -// ── Tests ──────────────────────────────────────────────────────────────────── - -describe('useCameraFraming', () => { - // ── Initial reset behavior ────────────────────────────────────────────── - - describe('initial reset behavior', () => { - it('calls resetCamera on first render with geometryRadius > 0', () => { - renderHook(() => useCameraFraming(10, origin)); - - expect(mockResetCamera).toHaveBeenCalledTimes(1); - // Initial reset uses configured angles (called with no arguments) - expect(mockResetCamera).toHaveBeenCalledWith(); - }); - - it('calls resetCamera when geometryRadius is 0 but does not mark initial reset done', () => { - renderHook(() => useCameraFraming(0, origin)); - - // Still called because sceneRadius starts as undefined (always significant) - expect(mockResetCamera).toHaveBeenCalledTimes(1); - expect(mockResetCamera).toHaveBeenCalledWith(); - }); - - it('uses initial reset (configured angles) when transitioning from zero to positive radius', () => { - const { rerender } = renderHook(({ radius }) => useCameraFraming(radius, origin), { - initialProps: { radius: 0 }, - }); - - mockResetCamera.mockClear(); - - rerender({ radius: 10 }); - - expect(mockResetCamera).toHaveBeenCalled(); - // All calls should use the initial-reset pattern (no { enableConfiguredAngles: false }) - const hasSubsequentPattern = mockResetCamera.mock.calls.some( - (call: unknown[]) => - (call[0] as { enableConfiguredAngles?: boolean } | undefined)?.enableConfiguredAngles === false, - ); - expect(hasSubsequentPattern).toBe(false); - }); - }); - - // ── Significant geometry change detection ─────────────────────────────── - - describe('significant geometry change detection', () => { - it('triggers direction-preserving reset when radius changes by more than 10%', () => { - const { rerender } = renderHook(({ radius }) => useCameraFraming(radius, origin), { - initialProps: { radius: 10 }, - }); - - mockResetCamera.mockClear(); - - rerender({ radius: 12 }); // 20% change - - expect(mockResetCamera).toHaveBeenCalledWith({ enableConfiguredAngles: false }); - }); - - it('does not reset when radius changes by less than 10%', () => { - const { rerender } = renderHook(({ radius }) => useCameraFraming(radius, origin), { - initialProps: { radius: 10 }, - }); - - mockResetCamera.mockClear(); - - rerender({ radius: 10.5 }); // 5% change - - expect(mockResetCamera).not.toHaveBeenCalled(); - }); - - it('does not reset at the exact 10% boundary (strict > comparison)', () => { - const { rerender } = renderHook(({ radius }) => useCameraFraming(radius, origin), { - initialProps: { radius: 10 }, - }); - - mockResetCamera.mockClear(); - - rerender({ radius: 11 }); // Exactly 10% - - expect(mockResetCamera).not.toHaveBeenCalled(); - }); - - it('resets just above the 10% threshold', () => { - const { rerender } = renderHook(({ radius }) => useCameraFraming(radius, origin), { - initialProps: { radius: 10 }, - }); - - mockResetCamera.mockClear(); - - rerender({ radius: 11.01 }); // 10.1% change - - expect(mockResetCamera).toHaveBeenCalledWith({ enableConfiguredAngles: false }); - }); - - it('handles multiple successive significant changes', () => { - const { rerender } = renderHook(({ radius }) => useCameraFraming(radius, origin), { - initialProps: { radius: 10 }, - }); - - mockResetCamera.mockClear(); - - rerender({ radius: 15 }); // 50% change from 10 - expect(mockResetCamera).toHaveBeenCalledTimes(1); - - mockResetCamera.mockClear(); - - rerender({ radius: 20 }); // 33% change from 15 - expect(mockResetCamera).toHaveBeenCalledTimes(1); - expect(mockResetCamera).toHaveBeenCalledWith({ enableConfiguredAngles: false }); - }); - }); - - // ── Aspect ratio change detection ─────────────────────────────────────── - - describe('aspect ratio change detection', () => { - it('triggers reset when viewport aspect changes by more than 10%', () => { - const { rerender } = renderHook(({ radius }) => useCameraFraming(radius, origin), { - initialProps: { radius: 10 }, - }); - - mockResetCamera.mockClear(); - - mockSize.width = 400; // 400/600 = 0.667 vs 800/600 = 1.333 → 50% change - rerender({ radius: 10 }); - - expect(mockResetCamera).toHaveBeenCalledWith({ enableConfiguredAngles: false }); - }); - - it('does not reset for small aspect changes', () => { - const { rerender } = renderHook(({ radius }) => useCameraFraming(radius, origin), { - initialProps: { radius: 10 }, - }); - - mockResetCamera.mockClear(); - - mockSize.width = 790; // 790/600 ≈ 1.317 vs 1.333 → ~1.2% change - rerender({ radius: 10 }); - - expect(mockResetCamera).not.toHaveBeenCalled(); - }); - - it('ignores aspect changes before initial geometry reset is complete', () => { - const { rerender } = renderHook(({ radius }) => useCameraFraming(radius, origin), { - initialProps: { radius: 0 }, - }); - - mockResetCamera.mockClear(); - - mockSize.width = 400; // Significant aspect change - rerender({ radius: 0 }); - - // Aspect effect returns early when isInitialResetDoneRef is false or radius <= 0 - expect(mockResetCamera).not.toHaveBeenCalled(); - }); - }); - - // ── Edge cases ────────────────────────────────────────────────────────── - - describe('edge cases', () => { - it('returns the resetCamera function for manual use', () => { - const { result } = renderHook(() => useCameraFraming(10, origin)); - - expect(result.current).toBe(mockResetCamera); - }); - - it('uses defaultStageOptions when none provided', () => { - renderHook(() => useCameraFraming(10, origin)); - - expect(latestResetParameters.perspective.offsetRatio).toBe(defaultStageOptions.offsetRatio); - expect(latestResetParameters.perspective.nearPlane).toBe(defaultStageOptions.nearPlane); - expect(latestResetParameters.perspective.minimumFarPlane).toBe(defaultStageOptions.minimumFarPlane); - expect(latestResetParameters.perspective.farPlaneRadiusMultiplier).toBe( - defaultStageOptions.farPlaneRadiusMultiplier, - ); - expect(latestResetParameters.perspective.zoomLevel).toBe(defaultStageOptions.zoomLevel); - expect(latestResetParameters.rotation.side).toBe(defaultStageOptions.rotation.side); - expect(latestResetParameters.rotation.vertical).toBe(defaultStageOptions.rotation.vertical); - }); - - it('merges custom StageOptions with defaults', () => { - const customOptions: StageOptions = { - zoomLevel: 2, - rotation: { side: 0 }, - }; - - renderHook(() => useCameraFraming(10, origin, customOptions)); - - // Custom values applied - expect(latestResetParameters.perspective.zoomLevel).toBe(2); - expect(latestResetParameters.rotation.side).toBe(0); - // Defaults preserved for unset fields - expect(latestResetParameters.perspective.offsetRatio).toBe(defaultStageOptions.offsetRatio); - expect(latestResetParameters.rotation.vertical).toBe(defaultStageOptions.rotation.vertical); - }); - - it('forwards cameraFovAngle from graphics context', () => { - renderHook(() => useCameraFraming(10, origin)); - - expect(latestResetParameters.cameraFovAngle).toBe(50); - }); - - it('forwards geometryCenter to useCameraReset', () => { - const center = new Vector3(1, 2, 3); - - renderHook(() => useCameraFraming(10, center)); - - expect(latestResetParameters.geometryCenter).toBe(center); - }); - }); -}); diff --git a/apps/ui/app/components/geometry/graphics/three/use-camera-framing.ts b/apps/ui/app/components/geometry/graphics/three/use-camera-framing.ts deleted file mode 100644 index 8c5ae88d1..000000000 --- a/apps/ui/app/components/geometry/graphics/three/use-camera-framing.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { useThree } from '@react-three/fiber'; -import type * as THREE from 'three'; -import { useCameraReset } from '#components/geometry/graphics/three/use-camera-reset.js'; -import { useGraphicsSelector } from '#hooks/use-graphics.js'; -import type { StageOptions } from '#components/geometry/graphics/three/stage.js'; -import { defaultStageOptions } from '#components/geometry/graphics/three/stage.js'; - -const significantRadiusChangeRatio = 0.1; -const significantAspectChangeRatio = 0.1; - -/** - * Camera framing policy hook. - * - * Resolves `StageOptions` into concrete camera parameters, wires them into - * `useCameraReset`, and runs the auto-reset `useLayoutEffect` that decides - * whether an initial (with configured angles) or subsequent (preserving the - * current viewing direction) camera reset is needed when the geometry bounds - * change significantly. - * - * Returns `resetCamera` for manual (e.g. toolbar button) resets. - */ -export function useCameraFraming( - geometryRadius: number, - geometryCenter: THREE.Vector3, - stageOptions: StageOptions = defaultStageOptions, -): (options?: { enableConfiguredAngles?: boolean }) => void { - const cameraFovAngle = useGraphicsSelector((state) => state.context.cameraFovAngle); - - // Merge caller options with defaults - const { offsetRatio, nearPlane, minimumFarPlane, farPlaneRadiusMultiplier, zoomLevel, rotation } = useMemo( - () => ({ - ...defaultStageOptions, - ...stageOptions, - rotation: { ...defaultStageOptions.rotation, ...stageOptions.rotation }, - }), - [stageOptions], - ); - - // Internal state: the "committed" scene radius that the camera was last - // framed to. Compared against the live geometryRadius to decide whether a - // camera reset is needed. - const [sceneRadius, setSceneRadius] = useState(undefined); - - const setSceneRadiusCallback = useCallback((radius: number) => { - setSceneRadius(radius); - }, []); - - // Ref tracking the original camera distance for zoom-relative positioning - const originalDistanceReference = useRef(undefined); - - // Whether the very first camera reset (with configured angles) has fired - const isInitialResetDoneRef = useRef(false); - - // Wire everything into the lower-level camera reset hook - const resetCamera = useCameraReset({ - geometryRadius, - geometryCenter, - rotation: { - side: rotation.side, - vertical: rotation.vertical, - }, - perspective: { - offsetRatio, - zoomLevel, - nearPlane, - minimumFarPlane, - farPlaneRadiusMultiplier, - }, - setSceneRadius: setSceneRadiusCallback, - originalDistanceReference, - cameraFovAngle, - }); - - /** - * Auto-reset the camera when the geometry's bounding sphere changes - * significantly relative to the last committed scene radius. - */ - useLayoutEffect(() => { - const changeRatio = sceneRadius === undefined ? 0 : Math.abs((geometryRadius - sceneRadius) / sceneRadius); - const isSignificantChange = sceneRadius === undefined ? true : changeRatio > significantRadiusChangeRatio; - - if (isSignificantChange) { - // Only mark the initial reset as complete once we have real geometry - // (geometryRadius > 0). Before that, the camera may be replaced by - // PerspectiveCamera makeDefault, leaving it at (0,0,0). If we marked - // the flag earlier, subsequent resets would skip configured angles and - // compute the viewing direction from (0,0,0) toward geometryCenter, - // which can point the camera below the scene. - if (isInitialResetDoneRef.current && geometryRadius > 0) { - resetCamera({ enableConfiguredAngles: false }); - } else { - resetCamera(); - if (geometryRadius > 0) { - isInitialResetDoneRef.current = true; - } - } - } - }, [resetCamera, sceneRadius, geometryRadius]); - - // Track viewport aspect ratio and re-frame when it changes significantly. - // This ensures the model remains fully visible when Dockview panels are - // resized (e.g. split into narrow portrait viewports). - const { size } = useThree(); - const viewportAspect = size.width > 0 && size.height > 0 ? size.width / size.height : 1; - const lastAspectRef = useRef(viewportAspect); - - useLayoutEffect(() => { - // Skip if the initial geometry reset hasn't happened yet - if (!isInitialResetDoneRef.current || geometryRadius <= 0) { - lastAspectRef.current = viewportAspect; - return; - } - - const lastAspect = lastAspectRef.current; - const aspectChange = Math.abs(viewportAspect - lastAspect) / Math.max(lastAspect, 1e-9); - - if (aspectChange > significantAspectChangeRatio) { - lastAspectRef.current = viewportAspect; - resetCamera({ enableConfiguredAngles: false }); - } - }, [viewportAspect, resetCamera, geometryRadius]); - - return resetCamera; -} diff --git a/apps/ui/app/components/geometry/graphics/three/use-camera-reset.tsx b/apps/ui/app/components/geometry/graphics/three/use-camera-reset.tsx deleted file mode 100644 index 1401d7d86..000000000 --- a/apps/ui/app/components/geometry/graphics/three/use-camera-reset.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { useCallback, useEffect, useRef } from 'react'; -import { useThree } from '@react-three/fiber'; -import type { RefObject } from 'react'; -import type * as THREE from 'three'; -import { resetCamera as resetCameraFn } from '#components/geometry/graphics/three/utils/camera.utils.js'; -import { useCameraCapability } from '#hooks/use-graphics.js'; - -// Define the specific types needed for camera reset -type ResetRotation = { - side: number; - vertical: number; -}; - -type ResetPerspective = { - offsetRatio: number; - zoomLevel: number; - nearPlane: number; - minimumFarPlane: number; - farPlaneRadiusMultiplier: number; -}; - -type ResetCameraParameters = { - geometryRadius: number; - geometryCenter: THREE.Vector3; - rotation: ResetRotation; - perspective: ResetPerspective; - setSceneRadius: (radius: number) => void; - originalDistanceReference?: RefObject; - cameraFovAngle: number; -}; - -/** - * Hook that provides camera reset functionality and registers it with the graphics context - * - * @param parameters - The parameters for the camera reset. - * @returns The reset function. - */ -export function useCameraReset(parameters: ResetCameraParameters): (options?: { - /** - * Whether to enable configured angles. - * @default true - */ - enableConfiguredAngles?: boolean; -}) => void { - const { camera, controls, invalidate, size } = useThree(); - const viewportAspect = size.width > 0 && size.height > 0 ? size.width / size.height : 1; - const cameraCapabilityActor = useCameraCapability(); - const isRegistered = useRef(false); - - // Store viewportAspect in a ref so the resetCamera callback remains stable - // during resize. The aspect is read lazily when reset is actually called, - // preventing callback recreation on every resize pixel. - const viewportAspectRef = useRef(viewportAspect); - viewportAspectRef.current = viewportAspect; - - const { - geometryRadius, - geometryCenter, - rotation, - perspective, - setSceneRadius, - originalDistanceReference, - cameraFovAngle, - } = parameters; - - const resetCamera = useCallback( - (options?: { enableConfiguredAngles?: boolean }) => { - // Reset original distance reference if available - if (originalDistanceReference?.current !== undefined) { - originalDistanceReference.current = undefined; - } - - resetCameraFn({ - camera, - geometryRadius, - geometryCenter, - rotation, - perspective, - setSceneRadius, - invalidate, - enableConfiguredAngles: options?.enableConfiguredAngles, - cameraFovAngle, - controls: (controls ?? undefined) as { target: THREE.Vector3; update: () => void } | undefined, - viewportAspect: viewportAspectRef.current, - }); - }, - [ - originalDistanceReference, - camera, - controls, - geometryRadius, - geometryCenter, - rotation, - perspective, - setSceneRadius, - invalidate, - cameraFovAngle, - ], - ); - - // Register the reset function with the camera capability actor only once - useEffect(() => { - if (!isRegistered.current) { - cameraCapabilityActor.send({ type: 'registerReset', reset: resetCamera }); - isRegistered.current = true; - } - }, [resetCamera, cameraCapabilityActor]); - - // Return the reset function for direct use if needed - return resetCamera; -} diff --git a/apps/ui/app/components/geometry/graphics/three/use-geometry-bounds.ts b/apps/ui/app/components/geometry/graphics/three/use-geometry-bounds.ts deleted file mode 100644 index c8133a379..000000000 --- a/apps/ui/app/components/geometry/graphics/three/use-geometry-bounds.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import type { RefObject } from 'react'; -import * as THREE from 'three'; -import { useFrame } from '@react-three/fiber'; -import { useGraphics, useGraphicsSelector } from '#hooks/use-graphics.js'; - -// Reusable temporaries for per-frame bounding calculations (avoids GC pressure). -// Safe for multi-Canvas use because JavaScript is single-threaded and each -// Canvas's render loop runs sequentially. Values are snapshotted into locals -// before any state updater runs to prevent cross-contamination from batching. -const _box3 = new THREE.Box3(); -const _centerPoint = new THREE.Vector3(); -const _sphere = new THREE.Sphere(); - -type GeometryBoundsOptions = { - /** When true, the outer group is translated to center geometry at the origin. */ - enableCentering?: boolean; -}; - -type GeometryBoundsResult = { - /** The bounding sphere radius of the geometry. */ - geometryRadius: number; - /** The bounding box center of the geometry. */ - geometryCenter: THREE.Vector3; -}; - -/** - * Tracks the axis-aligned bounding box of the geometry inside `innerRef`, - * exposes the bounding sphere radius and center as React state, and syncs - * the radius to the graphics state machine. - * - * Integrates with the graphics machine's `geometryKey` to avoid expensive - * scene traversals once bounds have stabilized — they are only recomputed - * when new geometry loads (key change) and until the radius converges, then - * skipped entirely during orbit/pan/zoom. - * - * Optionally applies a centering transform to `outerRef` so the geometry's - * bounding box center sits at the world origin. - */ -export function useGeometryBounds( - // eslint-disable-next-line @typescript-eslint/no-restricted-types -- React refs use null - innerRef: RefObject, - // eslint-disable-next-line @typescript-eslint/no-restricted-types -- React refs use null - outerRef: RefObject, - options: GeometryBoundsOptions = {}, -): GeometryBoundsResult { - const { enableCentering = false } = options; - - const geometryKey = useGraphicsSelector((state) => state.context.geometryKey); - - const [{ geometryRadius, geometryCenter }, set] = useState<{ - geometryRadius: number; - geometryCenter: THREE.Vector3; - }>({ - geometryRadius: 0, - geometryCenter: new THREE.Vector3(), - }); - - // Track geometry key changes to avoid expensive per-frame scene traversal. - // When geometryKey changes, bounds are recomputed until they stabilize, - // then skipped entirely during orbit/pan/zoom. - const lastGeometryKeyRef = useRef(undefined); - const boundsStableRef = useRef(false); - - useFrame(() => { - if (!innerRef.current) { - return; - } - - // When geometryKey changes, invalidate stability - if (geometryKey !== lastGeometryKeyRef.current) { - lastGeometryKeyRef.current = geometryKey; - boundsStableRef.current = false; - } - - // Skip expensive scene traversal and matrix updates once bounds have - // stabilized. updateWorldMatrix(true, true) walks the full parent chain - // and all descendants, so gating it behind the stability check avoids - // unnecessary work during orbit/pan/zoom/resize. - if (boundsStableRef.current) { - return; - } - - if (outerRef.current) { - outerRef.current.updateWorldMatrix(true, true); - } - - _box3.setFromObject(innerRef.current); - - // Don't mark stable or update state when the bounding box is empty - // (geometry hasn't loaded yet -- GltfMesh parses GLTF asynchronously) - if (_box3.isEmpty()) { - return; - } - - // Read the bounding box center and sphere from a single traversal. - // The center is captured first for camera targeting, then the sphere - // for radius. This avoids a redundant O(n) setFromObject call. - _box3.getCenter(_centerPoint); - _box3.getBoundingSphere(_sphere); - - if (enableCentering && outerRef.current) { - // Snap to the negated center directly rather than accumulating deltas, - // so repeated frames are idempotent and don't drift from floating-point error. - outerRef.current.position.set(-_centerPoint.x, -_centerPoint.y, -_centerPoint.z); - } - - // Snapshot values from shared temporaries BEFORE the state updater runs, - // to guard against cross-contamination if React batches updates across - // multiple Canvas instances sharing the same module-level _sphere / _centerPoint. - const snapshotRadius = _sphere.radius; - const snapshotCenter = _centerPoint.clone(); - - // Only update state when the radius or center has actually changed to avoid unnecessary re-renders - set((previous) => { - const centerChanged = !previous.geometryCenter.equals(snapshotCenter); - - if (previous.geometryRadius === snapshotRadius && !centerChanged) { - // Radius and center converged -- bounds are stable, stop polling - boundsStableRef.current = true; - return previous; - } - - return { - geometryRadius: snapshotRadius, - geometryCenter: centerChanged ? snapshotCenter : previous.geometryCenter, - }; - }); - }); - - // Sync the real bounding-sphere radius to the graphics machine so other - // components (and downstream consumers of geometryRadius) get the actual value - // computed from the Three.js scene graph, not a placeholder. - const graphicsActor = useGraphics(); - useEffect(() => { - if (geometryRadius > 0) { - graphicsActor.send({ type: 'sceneRadiusUpdated', radius: geometryRadius }); - } - }, [graphicsActor, geometryRadius]); - - return { geometryRadius, geometryCenter }; -} diff --git a/apps/ui/app/components/geometry/graphics/three/use-section-view.ts b/apps/ui/app/components/geometry/graphics/three/use-section-view.ts deleted file mode 100644 index 43629089f..000000000 --- a/apps/ui/app/components/geometry/graphics/three/use-section-view.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { useEffect, useMemo, useRef } from 'react'; -import * as THREE from 'three'; -import { createStripedMaterial } from '#components/geometry/graphics/three/materials/striped-material.js'; -import { useGraphicsSelector } from '#hooks/use-graphics.js'; - -export type SectionViewState = { - /** The computed clipping plane for the active section view. */ - readonly plane: THREE.Plane; - /** The capping material used for the cross-section surface. */ - readonly cappingMaterial: THREE.ShaderMaterial; - /** Whether the section view is currently active and has a selected plane. */ - readonly isActive: boolean; - /** The ID of the selected section view plane, if any. */ - readonly selectedId: string | undefined; - /** Whether clipping lines are enabled. */ - readonly enableLines: boolean; - /** Whether the clipping mesh (capping surface) is enabled. */ - readonly enableMesh: boolean; -}; - -const defaultPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0); - -/** - * Reads section view state from the graphics context and computes the derived - * THREE.Plane and capping material. Fully self-contained -- no external refs - * or props required. - * - * The capping material is automatically disposed when its dependencies change - * and on unmount to prevent GPU resource leaks. - */ -export function useSectionView(): SectionViewState { - const isSectionViewActive = useGraphicsSelector((state) => state.context.isSectionViewActive); - const selectedSectionViewId = useGraphicsSelector((state) => state.context.selectedSectionViewId); - const sectionViewRotation = useGraphicsSelector((state) => state.context.sectionViewRotation); - const sectionViewDirection = useGraphicsSelector((state) => state.context.sectionViewDirection); - const sectionViewPivot = useGraphicsSelector((state) => state.context.sectionViewPivot); - const availableSectionViews = useGraphicsSelector((state) => state.context.availableSectionViews); - const enableClippingLines = useGraphicsSelector((state) => state.context.enableClippingLines); - const enableClippingMesh = useGraphicsSelector((state) => state.context.enableClippingMesh); - const gridSizesComputed = useGraphicsSelector((state) => state.context.gridSizesComputed); - - // Compute the clipping plane from the selected section view configuration - const plane = useMemo(() => { - if (!selectedSectionViewId) { - return defaultPlane; - } - - const selectedPlane = availableSectionViews.find((p) => p.id === selectedSectionViewId); - if (!selectedPlane) { - return defaultPlane; - } - - const normal = new THREE.Vector3(...selectedPlane.normal); - - // Apply rotation to the normal if rotation is set - const [rotX, rotY, rotZ] = sectionViewRotation; - if (rotX !== 0 || rotY !== 0 || rotZ !== 0) { - const euler = new THREE.Euler(rotX, rotY, rotZ); - normal.applyEuler(euler); - } - - // Apply direction after rotation - normal.multiplyScalar(-sectionViewDirection); - - // Compute plane constant from the world-space pivot point: n·p + c = 0 - // => c = -n·p. Using pivot as source of truth ensures the plane remains - // anchored during rotations and flips while keeping display translation stable. - const constant = -normal.dot(new THREE.Vector3(...sectionViewPivot)); - - return new THREE.Plane(normal, constant); - }, [selectedSectionViewId, sectionViewPivot, sectionViewRotation, sectionViewDirection, availableSectionViews]); - - // Create striped material for the capping surface. - // Tracked via ref so the previous material can be disposed when deps change or on unmount. - const cappingMaterialRef = useRef(undefined); - - const cappingMaterial = useMemo(() => { - cappingMaterialRef.current?.dispose(); - - const stripeSpacing = gridSizesComputed.largeSize * 0.1; - const stripeWidth = stripeSpacing * 0.2; - - const material = createStripedMaterial({ - stripeFrequency: stripeSpacing, - stripeWidth, - }); - - cappingMaterialRef.current = material; - return material; - }, [gridSizesComputed.largeSize]); - - // Dispose capping material on unmount - useEffect(() => { - return () => { - cappingMaterialRef.current?.dispose(); - }; - }, []); - - return { - plane, - cappingMaterial, - isActive: Boolean(isSectionViewActive && selectedSectionViewId), - selectedId: selectedSectionViewId, - enableLines: enableClippingLines, - enableMesh: enableClippingMesh, - }; -} diff --git a/apps/ui/app/components/geometry/graphics/three/utils/camera.utils.test.ts b/apps/ui/app/components/geometry/graphics/three/utils/camera.utils.test.ts deleted file mode 100644 index 4e3182b47..000000000 --- a/apps/ui/app/components/geometry/graphics/three/utils/camera.utils.test.ts +++ /dev/null @@ -1,646 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import * as THREE from 'three'; -import { - computeViewFittingZoom, - updateCameraFov, - resetCamera, -} from '#components/geometry/graphics/three/utils/camera.utils.js'; -import { - calculateFovFromAngle, - calculateFovDistanceCompensation, - tanEpsilon, -} from '#components/geometry/graphics/three/utils/math.utils.js'; - -// ── Helpers ───────────────────────────────────────────────────────────────── - -/** Creates a PerspectiveCamera with sensible defaults. */ -function createTestCamera(fov = 54, distance = 10): THREE.PerspectiveCamera { - const camera = new THREE.PerspectiveCamera(fov, 16 / 9, 0.1, 1000); - camera.position.set(0, 0, distance); - camera.lookAt(0, 0, 0); - camera.updateMatrixWorld(true); - return camera; -} - -/** Creates an axis-aligned bounding box centred at `center` with given half-extents. */ -// eslint-disable-next-line max-params -- test helper, simple positional args are clearer here -function makeBox(center: THREE.Vector3, hx: number, hy: number, hz: number): THREE.Box3 { - return new THREE.Box3( - new THREE.Vector3(center.x - hx, center.y - hy, center.z - hz), - new THREE.Vector3(center.x + hx, center.y + hy, center.z + hz), - ); -} - -/** Builds the default perspective config used by resetCamera tests. */ -function defaultPerspective( - overrides?: Partial<{ - offsetRatio: number; - zoomLevel: number; - nearPlane: number; - minimumFarPlane: number; - farPlaneRadiusMultiplier: number; - }>, -): { - offsetRatio: number; - zoomLevel: number; - nearPlane: number; - minimumFarPlane: number; - farPlaneRadiusMultiplier: number; -} { - return { - offsetRatio: 2, - zoomLevel: 1, - nearPlane: 1e-3, - minimumFarPlane: 10_000_000_000, - farPlaneRadiusMultiplier: 5, - ...overrides, - }; -} - -// ── computeViewFittingZoom ────────────────────────────────────────────────── - -describe('computeViewFittingZoom', () => { - const fov = 45; - const squareAspect = 1; - - describe('perspective correctness', () => { - it('should produce lower zoom for a tall box viewed from below than orthographic formula', () => { - const distance = 20; - const halfZ = 8; - const halfXy = 2; - const tallBox = makeBox(new THREE.Vector3(0, 0, 0), halfXy, halfXy, halfZ); - const tanHalf = Math.tan((fov / 2) * (Math.PI / 180)); - - const zoom = computeViewFittingZoom({ - cameraPosition: new THREE.Vector3(0, 0, -distance), - target: new THREE.Vector3(0, 0, 0), - boundingBox: tallBox, - fovDeg: fov, - aspectRatio: squareAspect, - paddingFactor: 1, - }); - - // Orthographic would give d * tan / halfXY - const orthographicZoom = (distance * tanHalf) / halfXy; - // Perspective: closest corners at z = -halfZ, forward distance = distance - halfZ = 12 - const perspectiveZoom = ((distance - halfZ) * tanHalf) / halfXy; - - expect(zoom).toBeCloseTo(perspectiveZoom, 5); - expect(zoom).toBeLessThan(orthographicZoom); - }); - - it('should match orthographic formula for a flat box with no depth along viewing axis', () => { - const distance = 10; - const halfExtent = 3; - const flatBox = makeBox(new THREE.Vector3(0, 0, 0), halfExtent, halfExtent, 0.001); - const tanHalf = Math.tan((fov / 2) * (Math.PI / 180)); - - const zoom = computeViewFittingZoom({ - cameraPosition: new THREE.Vector3(0, 0, distance), - target: new THREE.Vector3(0, 0, 0), - boundingBox: flatBox, - fovDeg: fov, - aspectRatio: squareAspect, - paddingFactor: 1, - }); - - const orthographicZoom = (distance * tanHalf) / halfExtent; - expect(zoom).toBeCloseTo(orthographicZoom, 1); - }); - }); - - describe('symmetric cube at target', () => { - it('should agree with perspective formula for cube centered at target', () => { - const distance = 10; - const halfExtent = 1; - const tanHalf = Math.tan((fov / 2) * (Math.PI / 180)); - // Closest corners at forward distance (d - halfExtent) - const expectedZoom = ((distance - halfExtent) * tanHalf) / halfExtent; - - const zoom = computeViewFittingZoom({ - cameraPosition: new THREE.Vector3(0, 0, distance), - target: new THREE.Vector3(0, 0, 0), - boundingBox: makeBox(new THREE.Vector3(0, 0, 0), halfExtent, halfExtent, halfExtent), - fovDeg: fov, - aspectRatio: squareAspect, - paddingFactor: 1, - }); - - expect(zoom).toBeCloseTo(expectedZoom, 5); - }); - }); - - describe('aspect ratio', () => { - it('should produce higher zoom for landscape than portrait', () => { - const box = makeBox(new THREE.Vector3(0, 0, 0), 2, 1, 1); - const camera = new THREE.Vector3(0, 0, 10); - const target = new THREE.Vector3(0, 0, 0); - - const landscapeZoom = computeViewFittingZoom({ - cameraPosition: camera, - target, - boundingBox: box, - fovDeg: fov, - aspectRatio: 16 / 9, - paddingFactor: 1, - }); - - const portraitZoom = computeViewFittingZoom({ - cameraPosition: camera, - target, - boundingBox: box, - fovDeg: fov, - aspectRatio: 9 / 16, - paddingFactor: 1, - }); - - expect(landscapeZoom).toBeGreaterThan(portraitZoom); - }); - - it('should be constrained horizontally for a wide object with square aspect', () => { - const wideBox = makeBox(new THREE.Vector3(0, 0, 0), 4, 1, 1); - const tanHalf = Math.tan((fov / 2) * (Math.PI / 180)); - - const zoom = computeViewFittingZoom({ - cameraPosition: new THREE.Vector3(0, 0, 10), - target: new THREE.Vector3(0, 0, 0), - boundingBox: wideBox, - fovDeg: fov, - aspectRatio: squareAspect, - paddingFactor: 1, - }); - - // Closest corners at forward distance 9, horizontal tangent = 4/9 - // zoomH = aspect * tanHalf / (4/9) = 9 * tanHalf / 4 - const closestForward = 9; - const zoomH = (squareAspect * closestForward * tanHalf) / 4; - expect(zoom).toBeCloseTo(zoomH, 5); - }); - }); - - describe('viewpoint dependence', () => { - it('should zoom in more from the top than from the side for a tall box', () => { - const tallBox = makeBox(new THREE.Vector3(0, 0, 0), 1, 1, 5); - const target = new THREE.Vector3(0, 0, 0); - - const sideZoom = computeViewFittingZoom({ - cameraPosition: new THREE.Vector3(10, 0, 0), - target, - boundingBox: tallBox, - fovDeg: fov, - aspectRatio: squareAspect, - paddingFactor: 1, - }); - - const topZoom = computeViewFittingZoom({ - cameraPosition: new THREE.Vector3(0, 0, 10), - target, - boundingBox: tallBox, - fovDeg: fov, - aspectRatio: squareAspect, - paddingFactor: 1, - }); - - // Top view sees a small X x Y face -> higher zoom - expect(topZoom).toBeGreaterThan(sideZoom); - }); - }); - - describe('off-center geometry', () => { - it('should produce the same zoom when camera + target + bbox are shifted together', () => { - const halfExtent = 2; - - const centeredZoom = computeViewFittingZoom({ - cameraPosition: new THREE.Vector3(0, 0, 10), - target: new THREE.Vector3(0, 0, 0), - boundingBox: makeBox(new THREE.Vector3(0, 0, 0), halfExtent, halfExtent, halfExtent), - fovDeg: fov, - aspectRatio: squareAspect, - paddingFactor: 1, - }); - - const offset = new THREE.Vector3(100, 50, -30); - const offCenterZoom = computeViewFittingZoom({ - cameraPosition: new THREE.Vector3(offset.x, offset.y, 10 + offset.z), - target: offset.clone(), - boundingBox: makeBox(offset.clone(), halfExtent, halfExtent, halfExtent), - fovDeg: fov, - aspectRatio: squareAspect, - paddingFactor: 1, - }); - - expect(offCenterZoom).toBeCloseTo(centeredZoom, 5); - }); - }); - - describe('padding factor', () => { - it('should scale linearly with padding factor', () => { - const baseParameters = { - cameraPosition: new THREE.Vector3(0, 0, 10), - target: new THREE.Vector3(0, 0, 0), - boundingBox: makeBox(new THREE.Vector3(0, 0, 0), 1, 1, 1), - fovDeg: fov, - aspectRatio: squareAspect, - }; - - const zoomFull = computeViewFittingZoom({ ...baseParameters, paddingFactor: 1 }); - const zoomPadded = computeViewFittingZoom({ ...baseParameters, paddingFactor: 0.8 }); - - expect(zoomPadded / zoomFull).toBeCloseTo(0.8, 5); - }); - - it('should default to 0.9 when not specified', () => { - const baseParameters = { - cameraPosition: new THREE.Vector3(0, 0, 10), - target: new THREE.Vector3(0, 0, 0), - boundingBox: makeBox(new THREE.Vector3(0, 0, 0), 1, 1, 1), - fovDeg: fov, - aspectRatio: squareAspect, - }; - - const zoomDefault = computeViewFittingZoom(baseParameters); - const zoomExplicit = computeViewFittingZoom({ ...baseParameters, paddingFactor: 0.9 }); - - expect(zoomDefault).toBeCloseTo(zoomExplicit, 10); - }); - }); - - describe('degenerate cases', () => { - it('should return 1 when camera is at the target', () => { - const zoom = computeViewFittingZoom({ - cameraPosition: new THREE.Vector3(5, 5, 5), - target: new THREE.Vector3(5, 5, 5), - boundingBox: makeBox(new THREE.Vector3(5, 5, 5), 1, 1, 1), - fovDeg: fov, - aspectRatio: squareAspect, - }); - - expect(zoom).toBe(1); - }); - - it('should return 1 for a zero-extent bounding box', () => { - const zoom = computeViewFittingZoom({ - cameraPosition: new THREE.Vector3(0, 0, 10), - target: new THREE.Vector3(0, 0, 0), - boundingBox: new THREE.Box3(new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, 0)), - fovDeg: fov, - aspectRatio: squareAspect, - }); - - expect(zoom).toBe(1); - }); - - it('should handle camera looking straight down the up axis without error', () => { - const zoom = computeViewFittingZoom({ - cameraPosition: new THREE.Vector3(0, 0, 10), - target: new THREE.Vector3(0, 0, 0), - boundingBox: makeBox(new THREE.Vector3(0, 0, 0), 2, 3, 1), - fovDeg: fov, - aspectRatio: squareAspect, - paddingFactor: 1, - }); - - expect(zoom).toBeGreaterThan(0); - expect(Number.isFinite(zoom)).toBe(true); - }); - - it('should gracefully handle bbox corners behind the camera', () => { - // Camera very close; some bbox corners behind the camera plane - const zoom = computeViewFittingZoom({ - cameraPosition: new THREE.Vector3(0, 0, 2), - target: new THREE.Vector3(0, 0, 0), - boundingBox: makeBox(new THREE.Vector3(0, 0, 0), 1, 1, 5), - fovDeg: fov, - aspectRatio: squareAspect, - paddingFactor: 1, - }); - - // Corners at z = +5 are behind camera at z = 2 looking toward z = 0. - // Should still produce a valid positive zoom based on the visible corners. - expect(zoom).toBeGreaterThan(0); - expect(Number.isFinite(zoom)).toBe(true); - }); - }); -}); - -// ── updateCameraFov ───────────────────────────────────────────────────────── - -describe('updateCameraFov', () => { - it('should set the camera FOV from the given angle', () => { - const camera = createTestCamera(54, 10); - const invalidate = vi.fn(); - - updateCameraFov({ camera, cameraFovAngle: 60, invalidate }); - - expect(camera.fov).toBeCloseTo(calculateFovFromAngle(60), 10); - }); - - it('should adjust distance to maintain perceived size', () => { - const camera = createTestCamera(54, 10); - const invalidate = vi.fn(); - const oldDistance = camera.position.length(); - const oldFov = camera.fov; - - updateCameraFov({ camera, cameraFovAngle: 30, invalidate }); - - const newFov = camera.fov; - const expectedDistance = calculateFovDistanceCompensation(oldFov, newFov, oldDistance); - expect(camera.position.length()).toBeCloseTo(expectedDistance, 5); - }); - - it('should preserve camera direction after FOV change', () => { - const camera = createTestCamera(54, 10); - camera.position.set(3, 4, 5); - camera.updateMatrixWorld(true); - const invalidate = vi.fn(); - - const directionBefore = camera.position.clone().normalize(); - - updateCameraFov({ camera, cameraFovAngle: 75, invalidate }); - - const directionAfter = camera.position.clone().normalize(); - expect(directionAfter.x).toBeCloseTo(directionBefore.x, 10); - expect(directionAfter.y).toBeCloseTo(directionBefore.y, 10); - expect(directionAfter.z).toBeCloseTo(directionBefore.z, 10); - }); - - it('should call invalidate', () => { - const camera = createTestCamera(54, 10); - const invalidate = vi.fn(); - - updateCameraFov({ camera, cameraFovAngle: 45, invalidate }); - - expect(invalidate).toHaveBeenCalledOnce(); - }); - - it('should not modify a non-PerspectiveCamera', () => { - const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 100); - camera.position.set(0, 0, 10); - const invalidate = vi.fn(); - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); - - updateCameraFov({ camera, cameraFovAngle: 45, invalidate }); - - expect(errorSpy).toHaveBeenCalledOnce(); - expect(invalidate).not.toHaveBeenCalled(); - errorSpy.mockRestore(); - }); - - it('should not adjust distance when camera is at origin', () => { - const camera = createTestCamera(54, 0); - camera.position.set(0, 0, 0); - camera.updateMatrixWorld(true); - const invalidate = vi.fn(); - - updateCameraFov({ camera, cameraFovAngle: 60, invalidate }); - - // Position should remain at origin (no direction to scale) - expect(camera.position.length()).toBeLessThan(tanEpsilon); - }); -}); - -// ── resetCamera ───────────────────────────────────────────────────────────── - -describe('resetCamera', () => { - const origin = new THREE.Vector3(0, 0, 0); - const defaultRotation = { side: -Math.PI / 4, vertical: Math.PI / 6 }; - - it('should position camera at geometryCenter + spherical offset', () => { - const camera = createTestCamera(); - const center = new THREE.Vector3(10, 5, 3); - const invalidate = vi.fn(); - const setSceneRadius = vi.fn(); - - resetCamera({ - camera, - geometryRadius: 100, - geometryCenter: center, - rotation: defaultRotation, - perspective: defaultPerspective(), - setSceneRadius, - invalidate, - cameraFovAngle: 60, - }); - - // Camera should be at some offset from the center, not at the origin - const distFromCenter = camera.position.distanceTo(center); - expect(distFromCenter).toBeGreaterThan(0); - }); - - it('should set FOV from the cameraFovAngle', () => { - const camera = createTestCamera(); - const invalidate = vi.fn(); - const setSceneRadius = vi.fn(); - - resetCamera({ - camera, - geometryRadius: 100, - geometryCenter: origin, - rotation: defaultRotation, - perspective: defaultPerspective(), - setSceneRadius, - invalidate, - cameraFovAngle: 45, - }); - - expect(camera.fov).toBeCloseTo(calculateFovFromAngle(45), 10); - }); - - it('should set zoom from perspective.zoomLevel', () => { - const camera = createTestCamera(); - const invalidate = vi.fn(); - const setSceneRadius = vi.fn(); - - resetCamera({ - camera, - geometryRadius: 100, - geometryCenter: origin, - rotation: defaultRotation, - perspective: defaultPerspective({ zoomLevel: 2.5 }), - setSceneRadius, - invalidate, - cameraFovAngle: 60, - }); - - expect(camera.zoom).toBe(2.5); - }); - - it('should look at geometry center, not origin', () => { - const camera = createTestCamera(); - const center = new THREE.Vector3(50, 50, 50); - const invalidate = vi.fn(); - const setSceneRadius = vi.fn(); - - resetCamera({ - camera, - geometryRadius: 100, - geometryCenter: center, - rotation: defaultRotation, - perspective: defaultPerspective(), - setSceneRadius, - invalidate, - cameraFovAngle: 60, - }); - - // After lookAt(center), the forward vector should point toward center - const forward = new THREE.Vector3(); - camera.getWorldDirection(forward); - const toCenter = center.clone().sub(camera.position).normalize(); - expect(forward.dot(toCenter)).toBeCloseTo(1, 3); - }); - - it('should update orbit controls target and call update', () => { - const camera = createTestCamera(); - const center = new THREE.Vector3(10, 20, 30); - const invalidate = vi.fn(); - const setSceneRadius = vi.fn(); - const controls = { - target: new THREE.Vector3(), - update: vi.fn(), - }; - - resetCamera({ - camera, - geometryRadius: 100, - geometryCenter: center, - rotation: defaultRotation, - perspective: defaultPerspective(), - setSceneRadius, - invalidate, - cameraFovAngle: 60, - controls, - }); - - expect(controls.target.x).toBeCloseTo(center.x); - expect(controls.target.y).toBeCloseTo(center.y); - expect(controls.target.z).toBeCloseTo(center.z); - expect(controls.update).toHaveBeenCalledOnce(); - }); - - it('should increase distance for portrait viewport aspect', () => { - const camera1 = createTestCamera(); - const camera2 = createTestCamera(); - const invalidate = vi.fn(); - const setSceneRadius = vi.fn(); - - // Landscape - resetCamera({ - camera: camera1, - geometryRadius: 100, - geometryCenter: origin, - rotation: defaultRotation, - perspective: defaultPerspective(), - setSceneRadius, - invalidate, - cameraFovAngle: 60, - viewportAspect: 16 / 9, - }); - - // Portrait - resetCamera({ - camera: camera2, - geometryRadius: 100, - geometryCenter: origin, - rotation: defaultRotation, - perspective: defaultPerspective(), - setSceneRadius, - invalidate, - cameraFovAngle: 60, - viewportAspect: 9 / 16, - }); - - const distLandscape = camera1.position.distanceTo(origin); - const distPortrait = camera2.position.distanceTo(origin); - expect(distPortrait).toBeGreaterThan(distLandscape); - }); - - it('should maintain current direction when enableConfiguredAngles is false', () => { - const camera = createTestCamera(54, 10); - camera.position.set(5, 5, 5); - camera.updateMatrixWorld(true); - const invalidate = vi.fn(); - const setSceneRadius = vi.fn(); - const directionBefore = camera.position.clone().normalize(); - - resetCamera({ - camera, - geometryRadius: 100, - geometryCenter: origin, - rotation: defaultRotation, - perspective: defaultPerspective(), - setSceneRadius, - invalidate, - cameraFovAngle: 60, - enableConfiguredAngles: false, - }); - - const directionAfter = camera.position.clone().normalize(); - expect(directionAfter.x).toBeCloseTo(directionBefore.x, 5); - expect(directionAfter.y).toBeCloseTo(directionBefore.y, 5); - expect(directionAfter.z).toBeCloseTo(directionBefore.z, 5); - }); - - it('should default geometry radius to 1000 when radius is 0', () => { - const camera = createTestCamera(); - const invalidate = vi.fn(); - const setSceneRadius = vi.fn(); - - resetCamera({ - camera, - geometryRadius: 0, - geometryCenter: origin, - rotation: defaultRotation, - perspective: defaultPerspective(), - setSceneRadius, - invalidate, - cameraFovAngle: 60, - }); - - // Camera should be positioned far out (distance proportional to 1000) - const dist = camera.position.distanceTo(origin); - expect(dist).toBeGreaterThan(500); - }); - - it('should not modify a non-PerspectiveCamera', () => { - const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 100); - camera.position.set(0, 0, 10); - const invalidate = vi.fn(); - const setSceneRadius = vi.fn(); - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); - - resetCamera({ - camera, - geometryRadius: 100, - geometryCenter: origin, - rotation: defaultRotation, - perspective: defaultPerspective(), - setSceneRadius, - invalidate, - cameraFovAngle: 60, - }); - - expect(errorSpy).toHaveBeenCalledOnce(); - expect(invalidate).not.toHaveBeenCalled(); - errorSpy.mockRestore(); - }); - - it('should call invalidate and setSceneRadius', () => { - const camera = createTestCamera(); - const invalidate = vi.fn(); - const setSceneRadius = vi.fn(); - - resetCamera({ - camera, - geometryRadius: 42, - geometryCenter: origin, - rotation: defaultRotation, - perspective: defaultPerspective(), - setSceneRadius, - invalidate, - cameraFovAngle: 60, - }); - - expect(invalidate).toHaveBeenCalledOnce(); - expect(setSceneRadius).toHaveBeenCalledWith(42); - }); -}); diff --git a/apps/ui/app/components/geometry/graphics/three/utils/camera.utils.ts b/apps/ui/app/components/geometry/graphics/three/utils/camera.utils.ts deleted file mode 100644 index aadadeec9..000000000 --- a/apps/ui/app/components/geometry/graphics/three/utils/camera.utils.ts +++ /dev/null @@ -1,343 +0,0 @@ -import * as THREE from 'three'; -import { - calculateFovFromAngle, - calculateFovDistanceCompensation, - tanEpsilon, -} from '#components/geometry/graphics/three/utils/math.utils.js'; - -/** - * Calculates a 3D position from spherical coordinates. - * Converts distance (radius), horizontal angle (phi), and vertical angle (theta) into x, y, z coordinates. - * Supports X-up, Y-up, and Z-up coordinate systems by checking THREE.Object3D.DEFAULT_UP. - */ -function calculatePositionFromSphericalCoordinates({ - distance, - horizontalAngle, - verticalAngle, -}: { - distance: number; - horizontalAngle: number; - verticalAngle: number; -}): THREE.Vector3 { - const cosTheta = Math.cos(verticalAngle); - const sinTheta = Math.sin(verticalAngle); - - // Determine which axis is up by checking THREE.Object3D.DEFAULT_UP - const isXaxisUp = THREE.Object3D.DEFAULT_UP.x === 1; - const isYaxisUp = THREE.Object3D.DEFAULT_UP.y === 1; - - if (isXaxisUp) { - // X-up: X is the vertical axis, Y and Z are horizontal - const x = distance * sinTheta; - const y = distance * cosTheta * Math.cos(horizontalAngle); - const z = distance * cosTheta * Math.sin(horizontalAngle); - return new THREE.Vector3(x, y, z); - } - - if (isYaxisUp) { - // Y-up: Y is the vertical axis, X and Z are horizontal (Z negated to match coordinate handedness) - const x = distance * cosTheta * Math.cos(horizontalAngle); - const y = distance * sinTheta; - const z = -distance * cosTheta * Math.sin(horizontalAngle); - return new THREE.Vector3(x, y, z); - } - - // Z-up: Z is the vertical axis, X and Y are horizontal - const x = distance * cosTheta * Math.cos(horizontalAngle); - const y = distance * cosTheta * Math.sin(horizontalAngle); - const z = distance * sinTheta; - return new THREE.Vector3(x, y, z); -} - -/** - * Computes the optimal zoom for a PerspectiveCamera so that the bounding box - * tightly fills the frame in both horizontal and vertical dimensions. - * - * Uses **perspective-correct** angular extents: each bounding-box corner is - * projected from the camera position, and the tangent of the angle from the - * optical axis is computed per-corner (rightDist / forwardDist). Corners that - * are closer to the camera subtend a larger angle and therefore limit the zoom - * more than corners further away. This prevents clipping that the simpler - * orthographic approximation (constant `distance` divisor) would miss. - * - * Frustum visibility condition for a point at camera-relative (r, u, f): - * |r/f| <= aspect * tan(fov/2) / zoom (horizontal) - * |u/f| <= tan(fov/2) / zoom (vertical) - * - * @returns The zoom value to set on the camera. Always >= a small floor to - * prevent degenerate values. - */ -export function computeViewFittingZoom({ - cameraPosition, - target, - boundingBox, - fovDeg, - aspectRatio, - paddingFactor = 0.9, -}: { - cameraPosition: THREE.Vector3; - target: THREE.Vector3; - boundingBox: THREE.Box3; - fovDeg: number; - aspectRatio: number; - paddingFactor?: number; -}): number { - const distance = cameraPosition.distanceTo(target); - if (distance < tanEpsilon) { - return 1; - } - - // Camera forward direction (from camera toward target) - const forward = new THREE.Vector3().subVectors(target, cameraPosition).normalize(); - - // Camera right = forward x worldUp (same pattern as computeHeadlampTransform) - const worldUp = THREE.Object3D.DEFAULT_UP.clone(); - const right = new THREE.Vector3().crossVectors(forward, worldUp); - - // Handle degenerate case: camera looking along up axis (e.g. TOP/BOTTOM view) - if (right.lengthSq() < 1e-6) { - // Pick an arbitrary perpendicular — use the axis with the smallest - // component of forward so the cross product is well-conditioned. - const absX = Math.abs(forward.x); - const absY = Math.abs(forward.y); - const absZ = Math.abs(forward.z); - const fallback = - absX <= absY && absX <= absZ - ? new THREE.Vector3(1, 0, 0) - : absY <= absZ - ? new THREE.Vector3(0, 1, 0) - : new THREE.Vector3(0, 0, 1); - right.crossVectors(forward, fallback); - } - - right.normalize(); - - // Camera up = right x forward - const up = new THREE.Vector3().crossVectors(right, forward).normalize(); - - const tanHalfFov = Math.tan((fovDeg / 2) * (Math.PI / 180)); - if (tanHalfFov < tanEpsilon) { - return 1; - } - - // Compute perspective-correct angular extents for each bounding-box corner. - // For each corner we measure the tangent of the angle from the optical axis - // (right/forward and up/forward ratios), which correctly accounts for corners - // at different depths from the camera. - const { min, max } = boundingBox; - let maxRightTan = 0; - let maxUpTan = 0; - let validCorners = 0; - - for (let i = 0; i < 8; i++) { - const corner = new THREE.Vector3( - // eslint-disable-next-line no-bitwise -- bit mask selects min/max per axis - i & 1 ? max.x : min.x, - // eslint-disable-next-line no-bitwise -- bit mask selects min/max per axis - i & 2 ? max.y : min.y, - // eslint-disable-next-line no-bitwise -- bit mask selects min/max per axis - i & 4 ? max.z : min.z, - ); - - // Vector from camera to this corner - const toCorner = corner.sub(cameraPosition); - const forwardDist = toCorner.dot(forward); - - // Skip corners behind or at the camera plane - if (forwardDist <= tanEpsilon) { - continue; - } - - // Tangent of horizontal and vertical angles from the optical axis - maxRightTan = Math.max(maxRightTan, Math.abs(toCorner.dot(right) / forwardDist)); - maxUpTan = Math.max(maxUpTan, Math.abs(toCorner.dot(up) / forwardDist)); - validCorners++; - } - - // Guard against no visible corners or zero-extent projected geometry - if (validCorners === 0 || maxRightTan < tanEpsilon || maxUpTan < tanEpsilon) { - return 1; - } - - // Max zoom that keeps every corner inside the frustum: - // zoom_v = tan(fov/2) / maxUpTan - // zoom_h = aspect * tan(fov/2) / maxRightTan - const zoomVertical = tanHalfFov / maxUpTan; - const zoomHorizontal = (aspectRatio * tanHalfFov) / maxRightTan; - - // Tighter constraint wins (smaller zoom = less visible area) - const tightZoom = Math.min(zoomVertical, zoomHorizontal) * paddingFactor; - - // Floor to prevent degenerate near-zero zoom - return Math.max(tightZoom, tanEpsilon); -} - -/** - * Updates only the camera FOV based on angle, adjusting distance to maintain perceived size. - * Does NOT reset camera position or viewing angle - preserves user's current view. - */ -export function updateCameraFov({ - camera, - cameraFovAngle, - invalidate, -}: { - camera: THREE.Camera; - cameraFovAngle: number; - invalidate: () => void; -}): void { - if (!(camera instanceof THREE.PerspectiveCamera)) { - console.error('updateCameraFov requires PerspectiveCamera'); - return; - } - - // Store old FOV before changing - const oldFov = camera.fov; - - // Calculate and apply the new FOV - const newFov = calculateFovFromAngle(cameraFovAngle); - camera.fov = newFov; - - // Adjust camera distance to maintain perceived size. - // This keeps objects the same apparent size when FOV changes. - if (camera.position.lengthSq() >= tanEpsilon) { - const currentDistance = camera.position.length(); - const newDistance = calculateFovDistanceCompensation(oldFov, newFov, currentDistance); - - if (newDistance !== currentDistance) { - const direction = camera.position.clone().normalize(); - camera.position.copy(direction.multiplyScalar(newDistance)); - } - } - - camera.updateProjectionMatrix(); - invalidate(); -} - -/** - * Resets the camera to a standard position and orientation based on geometry dimensions - * Adjusts for FOV to maintain consistent framing regardless of perspective setting - */ -export function resetCamera({ - camera, - geometryRadius, - geometryCenter, - rotation, - perspective, - setSceneRadius, - invalidate, - enableConfiguredAngles, - cameraFovAngle, - controls, - viewportAspect, -}: { - camera: THREE.Camera; - geometryRadius: number; - /** - * The center of the geometry's bounding box. The camera will orbit around - * and look at this point rather than the world origin, allowing geometry - * to remain at its absolute coordinates. - */ - geometryCenter: THREE.Vector3; - rotation: { side: number; vertical: number }; - perspective: { - offsetRatio: number; - zoomLevel: number; - nearPlane: number; - minimumFarPlane: number; - farPlaneRadiusMultiplier: number; - }; - setSceneRadius: (radius: number) => void; - invalidate: () => void; - enableConfiguredAngles?: boolean; - cameraFovAngle: number; - controls?: { target: THREE.Vector3; update: () => void } | undefined; - /** - * The viewport width / height ratio. When the viewport is in portrait - * orientation (aspect < 1), the camera distance is increased so the model - * is not clipped horizontally. - */ - viewportAspect?: number; -}): void { - if (!(camera instanceof THREE.PerspectiveCamera)) { - console.error('resetCamera requires PerspectiveCamera'); - return; - } - - // If the geometry radius is less than or requal to 0, we didn't get an object to render. - // Leaving it at 0 or less results in undefined camera behavior, so we set it to 1000. - const adjustedGeometryRadius = geometryRadius <= 0 ? 1000 : geometryRadius; - - const useConfiguredAngles = enableConfiguredAngles ?? true; - - // Calculate and apply the FOV - const calculatedFov = calculateFovFromAngle(cameraFovAngle); - camera.fov = calculatedFov; - - // Calculate the effective FOV that will be active after this reset, due to perspective.zoomLevel - let effectiveFovForAdjustment = calculatedFov; - if (useConfiguredAngles) { - effectiveFovForAdjustment = - THREE.MathUtils.RAD2DEG * - 2 * - Math.atan(Math.tan((THREE.MathUtils.DEG2RAD * calculatedFov) / 2) / perspective.zoomLevel); - } - - const standardFov = 60; - // Distance compensation ratio: pass distance=1 to get the pure tan(std/2)/tan(eff/2) ratio - const adjustedOffsetRatio = - perspective.offsetRatio * calculateFovDistanceCompensation(standardFov, effectiveFovForAdjustment, 1); - let newDistance = adjustedGeometryRadius * adjustedOffsetRatio; - - // Compensate for narrow (portrait) viewports so the model isn't clipped horizontally. - // The base distance ensures the model fits vertically. When the viewport is narrower - // than it is tall, the horizontal FOV shrinks and the model may exceed the horizontal - // frustum. Scale the distance by the ratio of vertical-to-horizontal half-FOV tangents - // so both dimensions are covered. - if (viewportAspect !== undefined && viewportAspect > 0 && viewportAspect < 1) { - const vFovRad = (effectiveFovForAdjustment / 2) * (Math.PI / 180); - const hFovHalf = Math.atan(viewportAspect * Math.tan(vFovRad)); - newDistance *= Math.tan(vFovRad) / Math.tan(hFovHalf); - } - - if (useConfiguredAngles) { - // Use configured rotation angles (side and vertical) for positioning - // Offset from the geometry center so the camera orbits around it - const offset = calculatePositionFromSphericalCoordinates({ - distance: newDistance, - horizontalAngle: rotation.side, - verticalAngle: rotation.vertical, - }); - camera.position.copy(geometryCenter).add(offset); - } else if (camera.position.distanceToSquared(geometryCenter) >= 1e-9) { - // Maintain current viewing direction if not at center, only adjust distance - const currentDirection = camera.position.clone().sub(geometryCenter).normalize(); - camera.position.copy(geometryCenter).add(currentDirection.multiplyScalar(newDistance)); - } else { - // Fallback for non-configured angle mode: If at center or too close, use configured angles to set an initial safe direction. - const offset = calculatePositionFromSphericalCoordinates({ - distance: newDistance, - horizontalAngle: rotation.side, - verticalAngle: rotation.vertical, - }); - camera.position.copy(geometryCenter).add(offset); - } - - camera.zoom = perspective.zoomLevel; - camera.near = perspective.nearPlane; - camera.far = Math.max(perspective.minimumFarPlane, adjustedGeometryRadius * perspective.farPlaneRadiusMultiplier); - - // Aim the camera at the geometry center - camera.lookAt(geometryCenter); - - // Update orbit controls target so the user orbits around the geometry center - if (controls) { - controls.target.copy(geometryCenter); - controls.update(); - } - - // Update the scene radius - setSceneRadius(geometryRadius); - - camera.updateProjectionMatrix(); - invalidate(); -} diff --git a/apps/ui/app/components/geometry/graphics/three/utils/gizmo.utils.ts b/apps/ui/app/components/geometry/graphics/three/utils/gizmo.utils.ts deleted file mode 100644 index 973128d30..000000000 --- a/apps/ui/app/components/geometry/graphics/three/utils/gizmo.utils.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Shared utilities for viewport gizmo components. - * - * Extracts common logic (canvas/renderer creation, FOV synchronization, - * container resolution, resource cleanup) so that the individual gizmo - * components only need to declare their configuration differences. - */ - -import type { ViewportGizmo } from 'three-viewport-gizmo'; -import * as THREE from 'three'; -import { - calculateGizmoFovFromAngle, - calculateFovDistanceCompensation, - gizmoBaseFov, - gizmoBaseDistance, - gizmoDepthMargin, - gizmoFocusOffset, -} from '#components/geometry/graphics/three/utils/math.utils.js'; - -// ── FOV synchronization ───────────────────────────────────────────────────── - -/** - * Synchronize the gizmo's internal camera FOV with the viewport camera FOV. - * - * The `three-viewport-gizmo` library creates its own internal PerspectiveCamera - * (accessed via the private `_camera` property) with hardcoded defaults (FOV=26, - * distance=7). This function updates that internal camera so the gizmo shows the - * same perspective as the main viewport, while compensating the camera distance to - * keep the gizmo cube at a consistent apparent size. - * - * NOTE: `_camera` is a non-public property. If the library ever exposes a public - * FOV API, this should be migrated. The runtime `instanceof` guard ensures a - * silent no-op if the internal structure changes. - */ -export function syncGizmoFov(gizmo: ViewportGizmo, cameraFovAngle: number): void { - const internalCamera = (gizmo as unknown as { _camera: THREE.PerspectiveCamera })._camera; - if (!(internalCamera instanceof THREE.PerspectiveCamera)) { - return; - } - - const gizmoFov = calculateGizmoFovFromAngle(cameraFovAngle); - const newDistance = calculateFovDistanceCompensation(gizmoBaseFov, gizmoFov, gizmoBaseDistance, gizmoFocusOffset); - - internalCamera.fov = gizmoFov; - internalCamera.position.set(0, 0, newDistance); - internalCamera.near = Math.max(0.01, newDistance - gizmoDepthMargin); - internalCamera.far = newDistance + gizmoDepthMargin; - internalCamera.updateProjectionMatrix(); -} - -// ── Container resolution ──────────────────────────────────────────────────── - -/** - * Resolve the gizmo container element from a string selector, an element - * reference, or fall back to the renderer's parent element. - * - * @returns The resolved container, or `undefined` if none could be found. - */ -export function resolveGizmoContainer( - container: HTMLElement | string | undefined, - glDomElement: HTMLCanvasElement, -): HTMLElement | undefined { - if (typeof container === 'string') { - return document.querySelector(container) ?? undefined; - } - - return container ?? glDomElement.parentElement ?? undefined; -} - -// ── Canvas & renderer creation ────────────────────────────────────────────── - -/** - * Create and configure a canvas element for a viewport gizmo overlay. - * - * The canvas is absolutely positioned at bottom-right with a z-index of 10, - * matching the convention used by all gizmo variants. - */ -export function createGizmoCanvas(className: string): HTMLCanvasElement { - const canvas = document.createElement('canvas'); - canvas.className = className; - canvas.style.position = 'absolute'; - canvas.style.bottom = '0'; - canvas.style.right = '0'; - canvas.style.zIndex = '10'; - return canvas; -} - -/** - * Create and configure a WebGL renderer for a viewport gizmo. - * - * Uses alpha + antialias, caps pixel ratio at 2, and clears to transparent. - */ -export function createGizmoRenderer(canvas: HTMLCanvasElement, size: number): THREE.WebGLRenderer { - const renderer = new THREE.WebGLRenderer({ - canvas, - alpha: true, - antialias: true, - }); - renderer.setSize(size, size); - const dpr = Math.min(globalThis.devicePixelRatio, 2); - renderer.setPixelRatio(dpr); - renderer.setClearColor(0x00_00_00, 0); - return renderer; -} - -// ── Resource cleanup ──────────────────────────────────────────────────────── - -/** - * Dispose all resources created for a viewport gizmo. - * - * Removes event listeners, disposes the gizmo and renderer, removes the - * canvas from the DOM, and forces WebGL context loss to prevent GPU context - * exhaustion. - */ -export function disposeGizmoResources({ - gizmo, - renderer, - canvas, - handleChange, -}: { - gizmo: ViewportGizmo; - renderer: THREE.WebGLRenderer; - canvas: HTMLCanvasElement; - handleChange: () => void; -}): void { - gizmo.removeEventListener('change', handleChange); - gizmo.dispose(); - - if (canvas.parentElement) { - canvas.remove(); - } - - renderer.forceContextLoss(); - renderer.dispose(); -} diff --git a/apps/ui/app/components/geometry/graphics/three/utils/lights.utils.test.ts b/apps/ui/app/components/geometry/graphics/three/utils/lights.utils.test.ts deleted file mode 100644 index a6416116c..000000000 --- a/apps/ui/app/components/geometry/graphics/three/utils/lights.utils.test.ts +++ /dev/null @@ -1,762 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import * as THREE from 'three'; -import { - computeEnvironmentRotation, - computeHeadlampTransform, - applyLightingForCamera, - findTaggedLights, - defaultHeadlampConfig, - ambientBaseIntensity, - headlampBaseIntensity, - environmentBaseIntensity, - lightingUserDataKeys, - poleFadeAngleDeg, -} from '#components/geometry/graphics/three/utils/lights.utils.js'; -import type { HeadlampConfig, LightingConfig } from '#components/geometry/graphics/three/utils/lights.utils.js'; - -// ── Helpers ───────────────────────────────────────────────────────────────── - -/** Creates a PerspectiveCamera positioned along +Z looking at origin. */ -function createTestCamera(fov = 54, distance = 10): THREE.PerspectiveCamera { - const camera = new THREE.PerspectiveCamera(fov, 16 / 9, 0.1, 1000); - camera.position.set(0, 0, distance); - camera.lookAt(0, 0, 0); - camera.updateMatrixWorld(true); - return camera; -} - -/** Creates a minimal Scene with environment rotation support. */ -function createTestScene(): THREE.Scene { - const scene = new THREE.Scene(); - return scene; -} - -/** Creates the default lighting config used in most tests. */ -function createDefaultLightingConfig(overrides?: Partial): LightingConfig { - return { - sceneRadius: 5, - upDirection: 'z', - headlampIntensity: headlampBaseIntensity, - ambientIntensity: ambientBaseIntensity, - environmentIntensity: environmentBaseIntensity, - headlampConfig: defaultHeadlampConfig, - ...overrides, - }; -} - -// ── computeEnvironmentRotation ────────────────────────────────────────────── - -/** - * Builds an orbit-camera quaternion: Q_yaw(azimuth) * Q_pitch(polar). - * - * For Z-up the orbit is: rotate around Z by `azimuth`, then tilt around - * the resulting local X by `polar`. - */ -function orbitQuaternion(azimuth: number, polar: number, up: 'x' | 'y' | 'z' = 'z'): THREE.Quaternion { - const upAxis = - up === 'z' ? new THREE.Vector3(0, 0, 1) : up === 'y' ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3(1, 0, 0); - - const pitchAxis = - up === 'z' ? new THREE.Vector3(1, 0, 0) : up === 'y' ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 0, 1); - - const qYaw = new THREE.Quaternion().setFromAxisAngle(upAxis, azimuth); - const qPitch = new THREE.Quaternion().setFromAxisAngle(pitchAxis, polar); - return qYaw.multiply(qPitch); -} - -/** Polar angle (from top) within which the pole-fade is fully active (yaw → 0). */ -const poleFadeRad = (poleFadeAngleDeg * Math.PI) / 180; - -describe('computeEnvironmentRotation', () => { - // ── Basic identity and order ──────────────────────────────────────────── - - describe('identity camera', () => { - it('should produce a near-zero Euler for an identity quaternion', () => { - // Identity quaternion sits at the top pole — pole fade drives yaw to 0. - const identityQuat = new THREE.Quaternion(); // (0, 0, 0, 1) - const euler = computeEnvironmentRotation(identityQuat, 'z'); - - expect(euler.x).toBeCloseTo(0, 6); - expect(euler.y).toBeCloseTo(0, 6); - expect(euler.z).toBeCloseTo(0, 6); - }); - }); - - describe('euler order selection', () => { - it('should use ZXY order for z-up', () => { - const quat = new THREE.Quaternion(); - const euler = computeEnvironmentRotation(quat, 'z'); - expect(euler.order).toBe('ZXY'); - }); - - it('should use YXZ order for y-up', () => { - const quat = new THREE.Quaternion(); - const euler = computeEnvironmentRotation(quat, 'y'); - expect(euler.order).toBe('YXZ'); - }); - - it('should use XZY order for x-up', () => { - const quat = new THREE.Quaternion(); - const euler = computeEnvironmentRotation(quat, 'x'); - expect(euler.order).toBe('XZY'); - }); - }); - - // ── Azimuth extraction (non-pole cameras) ─────────────────────────────── - - describe('azimuth-only extraction', () => { - it('should extract correct yaw for each up direction at equatorial polar', () => { - // Use polar = π/2 (equator) where blend = 1 → full yaw - const angle = Math.PI / 3; - - const eulerZ = computeEnvironmentRotation(orbitQuaternion(angle, Math.PI / 2, 'z'), 'z'); - expect(eulerZ.z).toBeCloseTo(angle, 4); - expect(eulerZ.x).toBeCloseTo(0, 6); - expect(eulerZ.y).toBeCloseTo(0, 6); - - const eulerY = computeEnvironmentRotation(orbitQuaternion(angle, Math.PI / 2, 'y'), 'y'); - expect(eulerY.y).toBeCloseTo(angle, 4); - expect(eulerY.x).toBeCloseTo(0, 6); - expect(eulerY.z).toBeCloseTo(0, 6); - - const eulerX = computeEnvironmentRotation(orbitQuaternion(angle, Math.PI / 2, 'x'), 'x'); - expect(eulerX.x).toBeCloseTo(angle, 4); - expect(eulerX.y).toBeCloseTo(0, 6); - expect(eulerX.z).toBeCloseTo(0, 6); - }); - - it('should only populate the up-axis Euler component (pitch/roll zeroed)', () => { - // Polar π/3 (60°) — well away from both poles, blend ≈ 1 - const quat = orbitQuaternion(Math.PI / 4, Math.PI / 3, 'z'); - const euler = computeEnvironmentRotation(quat, 'z'); - - expect(euler.z).toBeCloseTo(Math.PI / 4, 4); - expect(euler.x).toBeCloseTo(0, 6); - expect(euler.y).toBeCloseTo(0, 6); - }); - }); - - // ── Polar-angle independence (mid-latitude band only) ─────────────────── - - describe('polar-angle independence (outside pole caps)', () => { - it('should return the same rotation for polar angles in the mid-latitude band (z-up)', () => { - const azimuth = Math.PI / 4; - // Stay well outside the pole-fade caps (poleFadeAngleDeg from each pole) - const safeMargin = poleFadeRad + 0.05; - const polarAngles = [ - safeMargin, - Math.PI / 4, - Math.PI / 3, - Math.PI / 2, - (2 * Math.PI) / 3, - (3 * Math.PI) / 4, - Math.PI - safeMargin, - ]; - - const results = polarAngles.map((polar) => { - const quat = orbitQuaternion(azimuth, polar, 'z'); - return computeEnvironmentRotation(quat, 'z'); - }); - - for (const euler of results) { - expect(euler.z).toBeCloseTo(azimuth, 3); - expect(euler.x).toBeCloseTo(0, 6); - expect(euler.y).toBeCloseTo(0, 6); - } - }); - - it('should return the same rotation for polar angles in the mid-latitude band (y-up)', () => { - const azimuth = -Math.PI / 3; - const safeMargin = poleFadeRad + 0.05; - const polarAngles = [safeMargin, Math.PI / 4, Math.PI / 2, Math.PI - safeMargin]; - - const results = polarAngles.map((polar) => { - const quat = orbitQuaternion(azimuth, polar, 'y'); - return computeEnvironmentRotation(quat, 'y'); - }); - - for (const euler of results) { - expect(euler.y).toBeCloseTo(azimuth, 3); - expect(euler.x).toBeCloseTo(0, 6); - expect(euler.z).toBeCloseTo(0, 6); - } - }); - }); - - // ── Continuity ────────────────────────────────────────────────────────── - - describe('continuity across equatorial plane', () => { - it('should be continuous sweeping polar through 90° (z-up)', () => { - const azimuth = Math.PI / 3; - const steps = 100; - const polarStart = Math.PI / 4; - const polarEnd = (3 * Math.PI) / 4; - - let previousZ: number | undefined; - for (let index = 0; index <= steps; index++) { - const polar = polarStart + (index / steps) * (polarEnd - polarStart); - const quat = orbitQuaternion(azimuth, polar, 'z'); - const euler = computeEnvironmentRotation(quat, 'z'); - - if (previousZ !== undefined) { - const delta = Math.abs(euler.z - previousZ); - expect(delta).toBeLessThan(0.1); - } - - previousZ = euler.z; - } - }); - - it('should be continuous sweeping polar through 90° (y-up)', () => { - const azimuth = -Math.PI / 6; - const steps = 100; - const polarStart = Math.PI / 4; - const polarEnd = (3 * Math.PI) / 4; - - let previousY: number | undefined; - for (let index = 0; index <= steps; index++) { - const polar = polarStart + (index / steps) * (polarEnd - polarStart); - const quat = orbitQuaternion(azimuth, polar, 'y'); - const euler = computeEnvironmentRotation(quat, 'y'); - - if (previousY !== undefined) { - const delta = Math.abs(euler.y - previousY); - expect(delta).toBeLessThan(0.1); - } - - previousY = euler.y; - } - }); - }); - - describe('full azimuth sweep is continuous', () => { - it('should have no jumps across the full −π→+π azimuth range', () => { - const polar = Math.PI / 3; // 60° — well away from poles - const steps = 360; - - let previousZ: number | undefined; - for (let index = 0; index <= steps; index++) { - const azimuth = (index / steps) * 2 * Math.PI - Math.PI; - const quat = orbitQuaternion(azimuth, polar, 'z'); - const euler = computeEnvironmentRotation(quat, 'z'); - - if (previousZ !== undefined) { - let delta = Math.abs(euler.z - previousZ); - if (delta > Math.PI) { - delta = 2 * Math.PI - delta; - } - - expect(delta).toBeLessThan(0.1); - } - - previousZ = euler.z; - } - }); - }); - - // ── Pole-proximity fade ───────────────────────────────────────────────── - - describe('pole-proximity fade', () => { - it('should return near-zero yaw at the top pole (polar ≈ 0)', () => { - // Top pole: polar = 0° → camera looking straight down along up axis - const quat = orbitQuaternion(Math.PI / 2, 0, 'z'); - const euler = computeEnvironmentRotation(quat, 'z'); - expect(Math.abs(euler.z)).toBeLessThan(0.01); - }); - - it('should return near-zero yaw at the bottom pole (polar ≈ π)', () => { - // Bottom pole: polar = π → camera looking straight up from below - const quat = orbitQuaternion(Math.PI / 2, Math.PI - 0.001, 'z'); - const euler = computeEnvironmentRotation(quat, 'z'); - expect(Math.abs(euler.z)).toBeLessThan(0.01); - }); - - it('should return full yaw at the equator (polar = π/2)', () => { - const azimuth = Math.PI / 3; - const quat = orbitQuaternion(azimuth, Math.PI / 2, 'z'); - const euler = computeEnvironmentRotation(quat, 'z'); - expect(euler.z).toBeCloseTo(azimuth, 4); - }); - - it('should return full yaw well outside the pole cap', () => { - const azimuth = Math.PI / 4; - // 30° from top pole — outside the 15° cap - const quat = orbitQuaternion(azimuth, Math.PI / 6, 'z'); - const euler = computeEnvironmentRotation(quat, 'z'); - expect(euler.z).toBeCloseTo(azimuth, 2); - }); - - it('should be symmetric between top and bottom poles', () => { - const azimuth = Math.PI / 4; - // 5° from top pole - const topQuat = orbitQuaternion(azimuth, 0.087, 'z'); - const topEuler = computeEnvironmentRotation(topQuat, 'z'); - - // 5° from bottom pole - const bottomQuat = orbitQuaternion(azimuth, Math.PI - 0.087, 'z'); - const bottomEuler = computeEnvironmentRotation(bottomQuat, 'z'); - - // Both should have heavily attenuated yaw (blend close to 0) - expect(Math.abs(topEuler.z)).toBeLessThan(Math.abs(azimuth) * 0.3); - expect(Math.abs(bottomEuler.z)).toBeLessThan(Math.abs(azimuth) * 0.3); - }); - - it('should produce a monotonically increasing blend from pole to equator', () => { - const azimuth = Math.PI / 3; - const steps = 50; - // Sweep from top pole (polar = 0) to equator (polar = π/2) - let previousAbsZ = -1; - for (let index = 1; index <= steps; index++) { - const polar = (index / steps) * (Math.PI / 2); - const quat = orbitQuaternion(azimuth, polar, 'z'); - const euler = computeEnvironmentRotation(quat, 'z'); - const absZ = Math.abs(euler.z); - expect(absZ).toBeGreaterThanOrEqual(previousAbsZ - 1e-6); - previousAbsZ = absZ; - } - }); - }); - - describe('continuity through pole-fade transition (no lighting hop)', () => { - it('should be continuous sweeping from equator through bottom pole cap (z-up)', () => { - const azimuth = Math.PI / 3; - const steps = 500; - // Sweep from π/3 (60°) all the way to near-bottom pole - const polarStart = Math.PI / 3; - const polarEnd = Math.PI - 0.01; - - let previousZ: number | undefined; - for (let index = 0; index <= steps; index++) { - const polar = polarStart + (index / steps) * (polarEnd - polarStart); - const quat = orbitQuaternion(azimuth, polar, 'z'); - const euler = computeEnvironmentRotation(quat, 'z'); - - if (previousZ !== undefined) { - // A genuine hop (the original bug) would produce a delta of ~π. - // The smoothstep fade may produce up to ~0.04 rad per step at - // 500 steps, well within the 0.1 rad threshold. - const delta = Math.abs(euler.z - previousZ); - expect(delta).toBeLessThan(0.1); - } - - previousZ = euler.z; - } - }); - - it('should be continuous sweeping from equator through top pole cap (z-up)', () => { - const azimuth = -Math.PI / 4; - const steps = 500; - const polarStart = (2 * Math.PI) / 3; - const polarEnd = 0.01; - - let previousZ: number | undefined; - for (let index = 0; index <= steps; index++) { - const polar = polarStart + (index / steps) * (polarEnd - polarStart); - const quat = orbitQuaternion(azimuth, polar, 'z'); - const euler = computeEnvironmentRotation(quat, 'z'); - - if (previousZ !== undefined) { - const delta = Math.abs(euler.z - previousZ); - expect(delta).toBeLessThan(0.1); - } - - previousZ = euler.z; - } - }); - }); - - describe('near-pole perturbation stability', () => { - it('should produce small yaw changes for small camera perturbations near bottom pole', () => { - // At 3° from the bottom pole, perturb the azimuth by 90° (worst case). - // Without pole-fade this would be a massive lighting change; with it - // the effective yaw should be heavily attenuated. - const polar = Math.PI - 0.05; // ~3° from bottom pole - const euler1 = computeEnvironmentRotation(orbitQuaternion(0, polar, 'z'), 'z'); - const euler2 = computeEnvironmentRotation(orbitQuaternion(Math.PI / 2, polar, 'z'), 'z'); - - // The effective yaw difference should be much smaller than π/2 (the raw difference) - const effectiveDelta = Math.abs(euler2.z - euler1.z); - expect(effectiveDelta).toBeLessThan(0.15); // < ~9° - }); - - it('should produce small yaw changes for small camera perturbations near top pole', () => { - const polar = 0.05; // ~3° from top pole - const euler1 = computeEnvironmentRotation(orbitQuaternion(0, polar, 'z'), 'z'); - const euler2 = computeEnvironmentRotation(orbitQuaternion(Math.PI / 2, polar, 'z'), 'z'); - - const effectiveDelta = Math.abs(euler2.z - euler1.z); - expect(effectiveDelta).toBeLessThan(0.15); - }); - }); - - // ── Degenerate / edge cases ───────────────────────────────────────────── - - describe('degenerate cases', () => { - it('should return identity for exact bottom-pole quaternion (q.z = q.w = 0)', () => { - const degenerate = new THREE.Quaternion(0, 1, 0, 0); // 180° around Y - const euler = computeEnvironmentRotation(degenerate, 'z'); - - expect(euler.x).toBeCloseTo(0, 6); - expect(euler.y).toBeCloseTo(0, 6); - expect(euler.z).toBeCloseTo(0, 6); - }); - - it('should return identity for exact top-pole quaternion (q.x = q.y = 0)', () => { - // Pure yaw with no pitch → top pole. Pole-fade drives effective yaw to 0. - const topPole = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI / 2); - const euler = computeEnvironmentRotation(topPole, 'z'); - - expect(euler.x).toBeCloseTo(0, 6); - expect(euler.y).toBeCloseTo(0, 6); - // At top pole, blend = 0 so effective yaw = 0 - expect(euler.z).toBeCloseTo(0, 6); - }); - - it('should handle near-zero-length quaternion gracefully', () => { - // Edge case: quaternion is nearly zero (shouldn't happen but protect against it) - const nearZero = new THREE.Quaternion(1e-8, 1e-8, 1e-8, 1e-8); - expect(() => computeEnvironmentRotation(nearZero, 'z')).not.toThrow(); - }); - }); - - // ── Input immutability ────────────────────────────────────────────────── - - describe('does not mutate input', () => { - it('should not modify the input quaternion', () => { - const quat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 3); - const originalW = quat.w; - const originalX = quat.x; - const originalY = quat.y; - const originalZ = quat.z; - - computeEnvironmentRotation(quat, 'z'); - - expect(quat.x).toBe(originalX); - expect(quat.y).toBe(originalY); - expect(quat.z).toBe(originalZ); - expect(quat.w).toBe(originalW); - }); - }); -}); - -// ── computeHeadlampTransform ──────────────────────────────────────────────── - -describe('computeHeadlampTransform', () => { - describe('identity camera matrix', () => { - it('should offset position in camera-up (+Y) and camera-right (+X) directions', () => { - const cameraPosition = new THREE.Vector3(0, 0, 10); - const cameraMatrix = new THREE.Matrix4().identity(); - const radius = 5; - - const { position } = computeHeadlampTransform({ - cameraPosition, - cameraMatrixWorld: cameraMatrix, - sceneRadius: radius, - config: defaultHeadlampConfig, - }); - - // With identity matrix: - // camera-right = column 0 = (1,0,0) - // camera-up = column 1 = (0,1,0) - // Expected position: (0,0,10) + (0,1,0) * 5 * 2.1 + (1,0,0) * 5 * -0.1 - const expectedX = 0 + radius * defaultHeadlampConfig.rightOffset; - const expectedY = 0 + radius * defaultHeadlampConfig.upOffset; - const expectedZ = 10; - - expect(position.x).toBeCloseTo(expectedX, 6); - expect(position.y).toBeCloseTo(expectedY, 6); - expect(position.z).toBeCloseTo(expectedZ, 6); - }); - - it('should place the target forward of camera with skew offsets', () => { - const cameraPosition = new THREE.Vector3(0, 0, 10); - const cameraMatrix = new THREE.Matrix4().identity(); - const radius = 5; - - const { targetPosition } = computeHeadlampTransform({ - cameraPosition, - cameraMatrixWorld: cameraMatrix, - sceneRadius: radius, - config: defaultHeadlampConfig, - }); - - // With identity matrix: - // camera-forward = -column2 = (0,0,-1) negated = (0,0,1)... actually - // column 2 of identity = (0,0,1), negated = (0,0,-1) - // forward direction = -column2 = (0,0,-1) - // target = camera_pos + forward * radius * 2 + right * (-radius * skew) + up * (-radius * skew) - const expectedZ = 10 + -1 * radius * 2; - const expectedX = 0 + -radius * defaultHeadlampConfig.targetRightSkew; - const expectedY = 0 + -radius * defaultHeadlampConfig.targetUpSkew; - - expect(targetPosition.x).toBeCloseTo(expectedX, 6); - expect(targetPosition.y).toBeCloseTo(expectedY, 6); - expect(targetPosition.z).toBeCloseTo(expectedZ, 6); - }); - }); - - describe('scaling with sceneRadius', () => { - it('should produce proportionally larger offsets with larger radius', () => { - const cameraPosition = new THREE.Vector3(0, 0, 10); - const cameraMatrix = new THREE.Matrix4().identity(); - - const small = computeHeadlampTransform({ - cameraPosition, - cameraMatrixWorld: cameraMatrix, - sceneRadius: 1, - config: defaultHeadlampConfig, - }); - const large = computeHeadlampTransform({ - cameraPosition, - cameraMatrixWorld: cameraMatrix, - sceneRadius: 10, - config: defaultHeadlampConfig, - }); - - // The offset from camera position should be 10x larger - const smallOffset = small.position.clone().sub(cameraPosition); - const largeOffset = large.position.clone().sub(cameraPosition); - - expect(largeOffset.length()).toBeCloseTo(smallOffset.length() * 10, 4); - }); - }); - - describe('custom config', () => { - it('should respect custom offset values', () => { - const cameraPosition = new THREE.Vector3(0, 0, 0); - const cameraMatrix = new THREE.Matrix4().identity(); - const radius = 1; - const config: HeadlampConfig = { - rightOffset: 1, - upOffset: 1, - targetRightSkew: 0, - targetUpSkew: 0, - }; - - const { position } = computeHeadlampTransform({ - cameraPosition, - cameraMatrixWorld: cameraMatrix, - sceneRadius: radius, - config, - }); - - // Camera-right = (1,0,0), camera-up = (0,1,0) - // position = (0,0,0) + (0,1,0)*1*1 + (1,0,0)*1*1 = (1, 1, 0) - expect(position.x).toBeCloseTo(1, 6); - expect(position.y).toBeCloseTo(1, 6); - expect(position.z).toBeCloseTo(0, 6); - }); - }); - - describe('does not mutate input', () => { - it('should not modify the input camera position', () => { - const cameraPosition = new THREE.Vector3(1, 2, 3); - const originalX = cameraPosition.x; - const originalY = cameraPosition.y; - const originalZ = cameraPosition.z; - const cameraMatrix = new THREE.Matrix4().identity(); - - computeHeadlampTransform({ - cameraPosition, - cameraMatrixWorld: cameraMatrix, - sceneRadius: 5, - config: defaultHeadlampConfig, - }); - - expect(cameraPosition.x).toBe(originalX); - expect(cameraPosition.y).toBe(originalY); - expect(cameraPosition.z).toBe(originalZ); - }); - }); -}); - -// ── applyLightingForCamera ────────────────────────────────────────────────── - -describe('applyLightingForCamera', () => { - describe('environment rotation', () => { - it('should set scene.environmentRotation based on camera orientation', () => { - const scene = createTestScene(); - const camera = createTestCamera(); - const config = createDefaultLightingConfig(); - - applyLightingForCamera({ scene, camera, headlamp: undefined, ambient: undefined, config }); - - // EnvironmentRotation should have been set (not identity if camera is looking at origin from +Z) - const euler = scene.environmentRotation; - expect(euler).toBeDefined(); - // The Euler order should match z-up - expect(euler.order).toBe('ZXY'); - }); - }); - - describe('environment intensity', () => { - it('should set scene.environmentIntensity using FOV compensation', () => { - const scene = createTestScene(); - const camera = createTestCamera(54); // Reference FOV - const config = createDefaultLightingConfig(); - - applyLightingForCamera({ scene, camera, headlamp: undefined, ambient: undefined, config }); - - // At reference FOV (54), envFactor ≈ 1.0, so intensity ≈ base - expect(scene.environmentIntensity).toBeCloseTo(environmentBaseIntensity, 2); - }); - - it('should reduce environment intensity at low FOV', () => { - const scene = createTestScene(); - const camera = createTestCamera(10); // Low FOV - const config = createDefaultLightingConfig(); - - applyLightingForCamera({ scene, camera, headlamp: undefined, ambient: undefined, config }); - - expect(scene.environmentIntensity).toBeLessThan(environmentBaseIntensity); - }); - }); - - describe('headlamp positioning', () => { - it('should update headlamp position and intensity when provided', () => { - const scene = createTestScene(); - const camera = createTestCamera(); - const headlamp = new THREE.DirectionalLight('white', 1); - scene.add(headlamp); - scene.add(headlamp.target); - const config = createDefaultLightingConfig(); - - const originalPosition = headlamp.position.clone(); - - applyLightingForCamera({ scene, camera, headlamp, ambient: undefined, config }); - - // Position should have changed - expect(headlamp.position.equals(originalPosition)).toBe(false); - // Intensity should be set (at reference FOV, headlampFactor ≈ 1.0) - expect(headlamp.intensity).toBeCloseTo(headlampBaseIntensity, 2); - }); - - it('should not throw when headlamp is undefined', () => { - const scene = createTestScene(); - const camera = createTestCamera(); - const config = createDefaultLightingConfig(); - - expect(() => { - applyLightingForCamera({ scene, camera, headlamp: undefined, ambient: undefined, config }); - }).not.toThrow(); - }); - }); - - describe('ambient light', () => { - it('should update ambient intensity with FOV compensation when provided', () => { - const scene = createTestScene(); - const camera = createTestCamera(54); // Reference FOV - const ambient = new THREE.AmbientLight('white', 1); - scene.add(ambient); - const config = createDefaultLightingConfig(); - - applyLightingForCamera({ scene, camera, headlamp: undefined, ambient, config }); - - // At reference FOV, ambientFactor ≈ 1.0 - expect(ambient.intensity).toBeCloseTo(ambientBaseIntensity, 2); - }); - - it('should boost ambient intensity at low FOV', () => { - const scene = createTestScene(); - const camera = createTestCamera(10); // Low FOV - const ambient = new THREE.AmbientLight('white', 1); - scene.add(ambient); - const config = createDefaultLightingConfig(); - - applyLightingForCamera({ scene, camera, headlamp: undefined, ambient, config }); - - // At low FOV, ambientFactor > 1.0 - expect(ambient.intensity).toBeGreaterThan(ambientBaseIntensity); - }); - - it('should not throw when ambient is undefined', () => { - const scene = createTestScene(); - const camera = createTestCamera(); - const config = createDefaultLightingConfig(); - - expect(() => { - applyLightingForCamera({ scene, camera, headlamp: undefined, ambient: undefined, config }); - }).not.toThrow(); - }); - }); - - describe('consistency across camera angles', () => { - it('should produce different environment rotations for different azimuthal positions', () => { - const scene1 = createTestScene(); - const scene2 = createTestScene(); - const config = createDefaultLightingConfig(); - - // Camera 1: azimuth 0°, polar 45° - const camera1 = createTestCamera(); - camera1.quaternion.copy(orbitQuaternion(0, Math.PI / 4, 'z')); - camera1.updateMatrixWorld(true); - - // Camera 2: azimuth 90°, polar 45° - const camera2 = createTestCamera(); - camera2.quaternion.copy(orbitQuaternion(Math.PI / 2, Math.PI / 4, 'z')); - camera2.updateMatrixWorld(true); - - applyLightingForCamera({ scene: scene1, camera: camera1, headlamp: undefined, ambient: undefined, config }); - applyLightingForCamera({ scene: scene2, camera: camera2, headlamp: undefined, ambient: undefined, config }); - - // Different azimuths should produce different environment rotations - const rot1 = scene1.environmentRotation; - const rot2 = scene2.environmentRotation; - const isIdentical = - Math.abs(rot1.x - rot2.x) < 1e-6 && Math.abs(rot1.y - rot2.y) < 1e-6 && Math.abs(rot1.z - rot2.z) < 1e-6; - expect(isIdentical).toBe(false); - }); - }); -}); - -// ── findTaggedLights ──────────────────────────────────────────────────────── - -describe('findTaggedLights', () => { - it('should find tagged headlamp and ambient light in a scene', () => { - const scene = createTestScene(); - const headlamp = new THREE.DirectionalLight('white', 1); - headlamp.userData[lightingUserDataKeys.headlamp] = true; - const ambient = new THREE.AmbientLight('white', 1); - ambient.userData[lightingUserDataKeys.ambient] = true; - scene.add(headlamp); - scene.add(ambient); - - const result = findTaggedLights(scene); - - expect(result.headlamp).toBe(headlamp); - expect(result.ambient).toBe(ambient); - }); - - it('should return undefined for missing lights', () => { - const scene = createTestScene(); - - const result = findTaggedLights(scene); - - expect(result.headlamp).toBeUndefined(); - expect(result.ambient).toBeUndefined(); - }); - - it('should find lights nested in groups', () => { - const scene = createTestScene(); - const group = new THREE.Group(); - const headlamp = new THREE.DirectionalLight('white', 1); - headlamp.userData[lightingUserDataKeys.headlamp] = true; - group.add(headlamp); - scene.add(group); - - const result = findTaggedLights(scene); - - expect(result.headlamp).toBe(headlamp); - }); - - it('should not return untagged lights', () => { - const scene = createTestScene(); - const untaggedLight = new THREE.DirectionalLight('white', 1); - scene.add(untaggedLight); - - const result = findTaggedLights(scene); - - expect(result.headlamp).toBeUndefined(); - expect(result.ambient).toBeUndefined(); - }); -}); diff --git a/apps/ui/app/components/geometry/graphics/three/utils/lights.utils.ts b/apps/ui/app/components/geometry/graphics/three/utils/lights.utils.ts deleted file mode 100644 index 21c32af72..000000000 --- a/apps/ui/app/components/geometry/graphics/three/utils/lights.utils.ts +++ /dev/null @@ -1,314 +0,0 @@ -/** - * Pure lighting utilities extracted from the Lights component. - * - * These functions encapsulate the per-frame camera-relative lighting logic so - * that both the live renderer (via `useFrame`) and the offline screenshot - * renderer can apply identical lighting for any camera orientation. - */ - -import * as THREE from 'three'; -import { calculateFovLightingCompensation } from '#components/geometry/graphics/three/utils/math.utils.js'; - -// ── Lighting constants ───────────────────────────────────────────────────── -// Exported so that both the Lights component and the screenshot capture -// system reference the same tuning values. - -/** Ambient fill -- provides base illumination floor so no surface is fully dark. */ -export const ambientBaseIntensity = 0.05; - -/** - * Camera-relative headlamp base intensity. - * - * Reduced from 0.5 since the camera-relative environment now provides - * directional variation. The headlamp is retained primarily as part of - * the FOV diffuse compensation system (its intensity is boosted at low FOV). - */ -export const headlampBaseIntensity = 0.8; - -/** - * Scene-level environment intensity (base value) -- the primary illumination - * source. This base value is scaled per-frame by the FOV compensation factor. - */ -export const environmentBaseIntensity = 0.9; - -// ── Headlamp configuration ───────────────────────────────────────────────── - -export type HeadlampConfig = { - /** Camera-right offset (multiplier of sceneRadius). */ - rightOffset: number; - /** Camera-up offset (multiplier of sceneRadius). */ - upOffset: number; - /** Target camera-right skew (multiplier of sceneRadius). */ - targetRightSkew: number; - /** Target camera-up skew (multiplier of sceneRadius). */ - targetUpSkew: number; -}; - -/** Default headlamp offset configuration matching the studio lighting rig. */ -export const defaultHeadlampConfig: HeadlampConfig = { - rightOffset: -0.1, - upOffset: 2.1, - targetRightSkew: 0.2, - targetUpSkew: 0.1, -}; - -// ── userData tag constants ───────────────────────────────────────────────── -// Used to identify lights in a cloned scene for screenshot rendering. - -export const lightingUserDataKeys = { - headlamp: 'isScreenshotHeadlamp', - ambient: 'isScreenshotAmbient', - config: 'lightingConfig', -} as const; - -// ── Lighting config stored on scene.userData ─────────────────────────────── - -export type SceneLightingConfig = { - sceneRadius: number; - upDirection: 'x' | 'y' | 'z'; -}; - -// ── Combined config for applyLightingForCamera ───────────────────────────── - -export type LightingConfig = { - sceneRadius: number; - upDirection: 'x' | 'y' | 'z'; - headlampIntensity: number; - ambientIntensity: number; - environmentIntensity: number; - headlampConfig: HeadlampConfig; -}; - -// ── Pure functions ───────────────────────────────────────────────────────── - -/** - * Angle (in radians) from either pole within which the yaw compensation - * fades to zero. At the equator the blend is 1 (full compensation); inside - * this cap the blend smoothsteps to 0 so the environment becomes world- - * fixed and small camera perturbations don't swing the lighting wildly. - * - * 15° keeps standard top/bottom views stable while leaving the vast - * majority of the viewing sphere (150° out of 180°) fully compensated. - */ -export const poleFadeAngleDeg = 15; - -/** `sin²(poleFadeAngle)` — precomputed threshold for the smoothstep ramp. */ -const poleFadeThreshold = Math.sin((poleFadeAngleDeg * Math.PI) / 180) ** 2; - -/** - * Computes the environment rotation Euler that cancels only the azimuthal - * (yaw) component of the camera's world orientation around the up axis. - * - * By not compensating for polar (pitch) or roll changes, the Lightformers - * remain stable during horizontal orbit but shift relative to the camera - * when tilting up/down — producing natural lighting variation at different - * viewing elevations instead of a uniformly locked rig. - * - * **Swing-twist decomposition** extracts yaw in quaternion space, avoiding - * the gimbal-lock discontinuity of Euler decomposition that caused 180° - * lighting hops at the equatorial plane. - * - * **Pole-proximity fade** attenuates the yaw compensation smoothly to zero - * within {@link poleFadeAngleDeg}° of either pole (top/bottom view). Near - * the poles azimuth is geometrically ill-defined and tiny camera movements - * would otherwise cause wild lighting swings. The fade uses a smoothstep - * over `sin²(polar_angle)` which is symmetric around both poles. - * - * Three.js internally negates all Euler components of `environmentRotation` - * (WebGLMaterials.js "accommodate left-handed frame"), so the returned yaw - * angle is provided with the sign that compensates for this negation. - * - * @param cameraWorldQuaternion - The camera's world quaternion. - * @param upDirection - The configured up axis ('x', 'y', or 'z'). - * @returns An Euler suitable for assigning to `scene.environmentRotation`. - */ -export function computeEnvironmentRotation( - cameraWorldQuaternion: THREE.Quaternion, - upDirection: 'x' | 'y' | 'z', -): THREE.Euler { - const order: THREE.EulerOrder = upDirection === 'y' ? 'YXZ' : upDirection === 'z' ? 'ZXY' : 'XZY'; - - // ── Swing-twist decomposition ─────────────────────────────────────────── - // For quaternion Q = (x, y, z, w) the twist around the up axis keeps only - // the scalar (w) and the component aligned with the up axis. The yaw angle - // is 2·atan2(axisComponent, w). - const { x, y, z, w } = cameraWorldQuaternion; - const axisComponent = upDirection === 'z' ? z : upDirection === 'y' ? y : x; - - const twistLengthSq = axisComponent * axisComponent + w * w; - - // Hard safety net: both twist components ≈ 0 → azimuth truly undefined. - if (twistLengthSq < 1e-10) { - return new THREE.Euler(0, 0, 0, order); - } - - const yaw = 2 * Math.atan2(axisComponent, w); - - // ── Pole-proximity fade ───────────────────────────────────────────────── - // sin²(polar_angle) = 4 · twistLen² · swingLen². This equals 0 at both - // poles and 1 at the equator. We smoothstep from 0 → 1 over the - // [0, poleFadeThreshold] range so the yaw compensation fades out - // gracefully within ~15° of either pole. - const swingLengthSq = Math.max(0, 1 - twistLengthSq); // Clamp for float drift - const sinPolarSq = 4 * twistLengthSq * swingLengthSq; - - const t = Math.min(1, sinPolarSq / poleFadeThreshold); - const blend = t * t * (3 - 2 * t); // Smoothstep - - const effectiveYaw = yaw * blend; - - if (upDirection === 'z') { - return new THREE.Euler(0, 0, effectiveYaw, order); - } - - if (upDirection === 'y') { - return new THREE.Euler(0, effectiveYaw, 0, order); - } - - // UpDirection === 'x' - return new THREE.Euler(effectiveYaw, 0, 0, order); -} - -/** - * Computes the world-space position and target for a camera-relative - * directional headlamp. - * - * The headlamp is offset in camera-up and camera-right directions so the - * highlight remains biased toward screen upper-right. The target is placed - * forward of the camera with slight lower-left skew. - * - * @param cameraPosition - The camera's world position. - * @param cameraMatrixWorld - The camera's world matrix (used for basis vectors). - * @param sceneRadius - The bounding sphere radius of the scene. - * @param config - Offset multipliers for headlamp placement. - * @returns The world-space position and target position for the headlamp. - */ -export function computeHeadlampTransform({ - cameraPosition, - cameraMatrixWorld, - sceneRadius, - config, -}: { - cameraPosition: THREE.Vector3; - cameraMatrixWorld: THREE.Matrix4; - sceneRadius: number; - config: HeadlampConfig; -}): { position: THREE.Vector3; targetPosition: THREE.Vector3 } { - // Camera basis vectors in world space: - // - column 0: camera-right (+X local) - // - column 1: camera-up (+Y local) - const cameraRight = new THREE.Vector3().setFromMatrixColumn(cameraMatrixWorld, 0).normalize(); - const cameraUp = new THREE.Vector3().setFromMatrixColumn(cameraMatrixWorld, 1).normalize(); - - // Position: camera + up offset + right offset - const position = cameraPosition.clone(); - position.addScaledVector(cameraUp, sceneRadius * config.upOffset); - position.addScaledVector(cameraRight, sceneRadius * config.rightOffset); - - // Camera forward direction - const cameraForward = new THREE.Vector3(); - // Extract forward from the matrix column instead of calling getWorldDirection - // so this function remains standalone (no camera method dependency). - cameraForward.setFromMatrixColumn(cameraMatrixWorld, 2).normalize().negate(); - - // Target: forward of camera with skew offsets - const targetPosition = cameraPosition.clone(); - targetPosition.addScaledVector(cameraForward, sceneRadius * 2); - targetPosition.addScaledVector(cameraRight, -sceneRadius * config.targetRightSkew); - targetPosition.addScaledVector(cameraUp, -sceneRadius * config.targetUpSkew); - - return { position, targetPosition }; -} - -// ── applyLightingForCamera options ────────────────────────────────────────── - -export type ApplyLightingOptions = { - /** The THREE.Scene to update. */ - scene: THREE.Scene; - /** The camera whose orientation drives lighting. */ - camera: THREE.Camera; - /** Optional directional light to position as headlamp. */ - headlamp: THREE.DirectionalLight | undefined; - /** Optional ambient light whose intensity to compensate. */ - ambient: THREE.AmbientLight | undefined; - /** Lighting configuration with base intensities and offsets. */ - config: LightingConfig; -}; - -/** - * Applies camera-relative lighting to a scene for the given camera. - * - * This function is the single source of truth for per-camera lighting setup. - * It is called by: - * - The `Lights` component's `useFrame` callback (live rendering) - * - The `captureScreenshots` function (offline screenshot rendering) - * - * It performs: - * 1. FOV-dependent intensity compensation - * 2. Camera-locked environment rotation - * 3. Headlamp positioning (if headlamp provided) - * 4. Ambient intensity update (if ambient light provided) - */ -export function applyLightingForCamera({ scene, camera, headlamp, ambient, config }: ApplyLightingOptions): void { - // FOV compensation - const currentFov = (camera as THREE.PerspectiveCamera).fov; - const compensation = calculateFovLightingCompensation(currentFov); - - // Environment intensity - scene.environmentIntensity = config.environmentIntensity * compensation.envFactor; - - // Camera-locked environment rotation - const quaternion = new THREE.Quaternion(); - camera.getWorldQuaternion(quaternion); - const rotation = computeEnvironmentRotation(quaternion, config.upDirection); - scene.environmentRotation.copy(rotation); - - // Ambient intensity with FOV compensation - if (ambient) { - ambient.intensity = config.ambientIntensity * compensation.ambientFactor; - } - - // Headlamp positioning with FOV compensation - if (headlamp) { - headlamp.intensity = config.headlampIntensity * compensation.headlampFactor; - - const transform = computeHeadlampTransform({ - cameraPosition: camera.position, - cameraMatrixWorld: camera.matrixWorld, - sceneRadius: config.sceneRadius, - config: config.headlampConfig, - }); - - headlamp.position.copy(transform.position); - headlamp.target.position.copy(transform.targetPosition); - headlamp.target.updateMatrixWorld(); - } -} - -/** - * Discovers tagged lights in a scene graph by traversing and checking - * `userData` markers. Used by the screenshot capture system to find the - * headlamp and ambient light in a cloned scene. - * - * @param scene - The scene to search. - * @returns The tagged headlamp and ambient light, or undefined if not found. - */ -export function findTaggedLights(scene: THREE.Scene): { - headlamp: THREE.DirectionalLight | undefined; - ambient: THREE.AmbientLight | undefined; -} { - let headlamp: THREE.DirectionalLight | undefined; - let ambient: THREE.AmbientLight | undefined; - - scene.traverse((object) => { - if (object.userData[lightingUserDataKeys.headlamp]) { - headlamp = object as THREE.DirectionalLight; - } - - if (object.userData[lightingUserDataKeys.ambient]) { - ambient = object as THREE.AmbientLight; - } - }); - - return { headlamp, ambient }; -} diff --git a/apps/ui/app/components/geometry/graphics/three/utils/math.utils.test.ts b/apps/ui/app/components/geometry/graphics/three/utils/math.utils.test.ts deleted file mode 100644 index aca909cc1..000000000 --- a/apps/ui/app/components/geometry/graphics/three/utils/math.utils.test.ts +++ /dev/null @@ -1,383 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - calculateFovFromAngle, - calculateGizmoFovFromAngle, - calculateFovDistanceCompensation, - calculateFovLightingCompensation, - gizmoFovScale, - fovCompensationReferenceFov, - fovCompensationEnvMin, - fovCompensationEnvMax, -} from '#components/geometry/graphics/three/utils/math.utils.js'; - -describe('calculateFovFromAngle', () => { - describe('boundary values', () => { - it('should return 0.1 (near-orthographic minimum) when angle is 0', () => { - expect(calculateFovFromAngle(0)).toBeCloseTo(0.1, 10); - }); - - it('should return 90 (maximum perspective) when angle is 90', () => { - expect(calculateFovFromAngle(90)).toBeCloseTo(90, 10); - }); - }); - - describe('known intermediate values', () => { - it('should return ~60.03 for default angle of 60', () => { - const expected = 0.1 + 89.9 * (60 / 90); - expect(calculateFovFromAngle(60)).toBeCloseTo(expected, 10); - }); - - it('should return ~45.05 for midpoint angle of 45', () => { - const expected = 0.1 + 89.9 * (45 / 90); - expect(calculateFovFromAngle(45)).toBeCloseTo(expected, 10); - }); - }); - - describe('linearity', () => { - it('should follow the formula 0.1 + 89.9 * (angle / 90) for several values', () => { - for (const angle of [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]) { - const expected = 0.1 + 89.9 * (angle / 90); - expect(calculateFovFromAngle(angle)).toBeCloseTo(expected, 10); - } - }); - }); - - describe('monotonicity', () => { - it('should strictly increase over the range [0, 90]', () => { - let previous = calculateFovFromAngle(0); - for (let angle = 1; angle <= 90; angle++) { - const current = calculateFovFromAngle(angle); - expect(current).toBeGreaterThan(previous); - previous = current; - } - }); - }); - - describe('clamping', () => { - it('should clamp negative input to the minimum FOV (0.1)', () => { - expect(calculateFovFromAngle(-10)).toBeCloseTo(0.1, 10); - expect(calculateFovFromAngle(-100)).toBeCloseTo(0.1, 10); - }); - - it('should clamp input above 90 to the maximum FOV (90)', () => { - expect(calculateFovFromAngle(100)).toBeCloseTo(90, 10); - expect(calculateFovFromAngle(180)).toBeCloseTo(90, 10); - }); - }); -}); - -describe('calculateGizmoFovFromAngle', () => { - it('should return the same minimum FOV as calculateFovFromAngle when angle is 0', () => { - expect(calculateGizmoFovFromAngle(0)).toBeCloseTo(calculateFovFromAngle(0), 10); - }); - - it('should return half the maximum FOV when angle is 90', () => { - // With GIZMO_FOV_SCALE = 0.5, slider 90 maps to calculateFovFromAngle(45) ≈ 45.05 - const expected = calculateFovFromAngle(90 * gizmoFovScale); - expect(calculateGizmoFovFromAngle(90)).toBeCloseTo(expected, 10); - }); - - it('should equal calculateFovFromAngle(angle * GIZMO_FOV_SCALE) for several values', () => { - for (const angle of [0, 15, 30, 45, 60, 75, 90]) { - const expected = calculateFovFromAngle(angle * gizmoFovScale); - expect(calculateGizmoFovFromAngle(angle)).toBeCloseTo(expected, 10); - } - }); - - it('should always be less than or equal to the viewport FOV for the same slider value', () => { - for (let angle = 0; angle <= 90; angle += 5) { - const viewportFov = calculateFovFromAngle(angle); - const gizmoFov = calculateGizmoFovFromAngle(angle); - expect(gizmoFov).toBeLessThanOrEqual(viewportFov); - } - }); - - it('should be strictly monotonically increasing over [0, 90]', () => { - let previous = calculateGizmoFovFromAngle(0); - for (let angle = 1; angle <= 90; angle++) { - const current = calculateGizmoFovFromAngle(angle); - expect(current).toBeGreaterThan(previous); - previous = current; - } - }); -}); - -describe('calculateFovDistanceCompensation', () => { - describe('identity', () => { - it('should return the same distance when old and new FOV are equal', () => { - expect(calculateFovDistanceCompensation(60, 60, 100)).toBeCloseTo(100, 6); - expect(calculateFovDistanceCompensation(26, 26, 7)).toBeCloseTo(7, 6); - expect(calculateFovDistanceCompensation(0.1, 0.1, 1000)).toBeCloseTo(1000, 6); - }); - }); - - describe('direction', () => { - it('should return a larger distance when narrowing FOV (camera moves back)', () => { - const result = calculateFovDistanceCompensation(60, 30, 100); - expect(result).toBeGreaterThan(100); - }); - - it('should return a smaller distance when widening FOV (camera moves closer)', () => { - const result = calculateFovDistanceCompensation(30, 60, 100); - expect(result).toBeLessThan(100); - }); - }); - - describe('known values', () => { - it('should produce the correct result for base FOV=26, new FOV=60, distance=7', () => { - const degToRad = Math.PI / 180; - const expected = 7 * (Math.tan((26 / 2) * degToRad) / Math.tan((60 / 2) * degToRad)); - expect(calculateFovDistanceCompensation(26, 60, 7)).toBeCloseTo(expected, 6); - }); - - it('should produce the correct result for FOV=60, new FOV=26, distance=7', () => { - const degToRad = Math.PI / 180; - const expected = 7 * (Math.tan((60 / 2) * degToRad) / Math.tan((26 / 2) * degToRad)); - expect(calculateFovDistanceCompensation(60, 26, 7)).toBeCloseTo(expected, 6); - }); - }); - - describe('round-trip symmetry', () => { - it('should return to the original distance after compensating there and back', () => { - const original = 100; - const intermediate = calculateFovDistanceCompensation(60, 30, original); - const restored = calculateFovDistanceCompensation(30, 60, intermediate); - expect(restored).toBeCloseTo(original, 6); - }); - - it('should be symmetric for arbitrary FOV pairs', () => { - const original = 42; - const intermediate = calculateFovDistanceCompensation(15, 75, original); - const restored = calculateFovDistanceCompensation(75, 15, intermediate); - expect(restored).toBeCloseTo(original, 6); - }); - }); - - describe('zero distance', () => { - it('should return 0 regardless of FOV values', () => { - expect(calculateFovDistanceCompensation(60, 30, 0)).toBe(0); - expect(calculateFovDistanceCompensation(30, 60, 0)).toBe(0); - }); - }); - - describe('epsilon guards', () => { - it('should return a finite value for small but valid FOV (0.001)', () => { - const result = calculateFovDistanceCompensation(60, 0.001, 100); - expect(Number.isFinite(result)).toBe(true); - expect(result).toBeGreaterThan(100); - }); - - it('should return currentDistance unchanged for truly degenerate FOV (1e-8)', () => { - // Tan(1e-8 / 2 * π/180) ≈ 8.7e-11, below the epsilon threshold - expect(calculateFovDistanceCompensation(60, 1e-8, 100)).toBe(100); - expect(calculateFovDistanceCompensation(1e-8, 60, 100)).toBe(100); - }); - - it('should return currentDistance unchanged for zero FOV', () => { - expect(calculateFovDistanceCompensation(60, 0, 100)).toBe(100); - expect(calculateFovDistanceCompensation(0, 60, 100)).toBe(100); - }); - }); - - describe('NaN guards', () => { - it('should return currentDistance unchanged when oldFov is NaN', () => { - expect(calculateFovDistanceCompensation(Number.NaN, 60, 100)).toBe(100); - }); - - it('should return currentDistance unchanged when newFov is NaN', () => { - expect(calculateFovDistanceCompensation(60, Number.NaN, 100)).toBe(100); - }); - - it('should return currentDistance unchanged when distance is NaN', () => { - const result = calculateFovDistanceCompensation(60, 30, Number.NaN); - expect(Number.isNaN(result)).toBe(true); - }); - }); - - describe('focusOffset parameter', () => { - it('should return the same distance when old and new FOV are equal, regardless of offset', () => { - expect(calculateFovDistanceCompensation(26, 26, 7, 0.7)).toBeCloseTo(7, 6); - expect(calculateFovDistanceCompensation(60, 60, 100, 5)).toBeCloseTo(100, 6); - }); - - it('should match the original formula when focusOffset is 0', () => { - const withDefault = calculateFovDistanceCompensation(26, 60, 7); - const withExplicitZero = calculateFovDistanceCompensation(26, 60, 7, 0); - expect(withExplicitZero).toBeCloseTo(withDefault, 10); - }); - - it('should produce the correct result for gizmo constants (FOV=26->90, dist=7, offset=0.7)', () => { - const degToRad = Math.PI / 180; - // NewDist = 0.7 + (7 - 0.7) * tan(13°) / tan(45°) - const expected = 0.7 + 6.3 * (Math.tan(13 * degToRad) / Math.tan(45 * degToRad)); - const result = calculateFovDistanceCompensation(26, 90, 7, 0.7); - expect(result).toBeCloseTo(expected, 6); - }); - - it('should keep the focus plane at constant projected size across FOV changes', () => { - const offset = 0.7; - const baseDistance = 7; - const baseFov = 26; - const degToRad = Math.PI / 180; - - // At any FOV, (newDist - offset) * tan(newFov/2) should equal (baseDist - offset) * tan(baseFov/2) - const baseProduct = (baseDistance - offset) * Math.tan((baseFov / 2) * degToRad); - - for (const newFov of [0.1, 10, 26, 45, 60, 90]) { - const newDist = calculateFovDistanceCompensation(baseFov, newFov, baseDistance, offset); - const newProduct = (newDist - offset) * Math.tan((newFov / 2) * degToRad); - expect(newProduct).toBeCloseTo(baseProduct, 6); - } - }); - - it('should return to the original distance after a round-trip with focusOffset', () => { - const original = 7; - const offset = 0.7; - const intermediate = calculateFovDistanceCompensation(26, 60, original, offset); - const restored = calculateFovDistanceCompensation(60, 26, intermediate, offset); - expect(restored).toBeCloseTo(original, 6); - }); - }); -}); - -describe('calculateFovLightingCompensation', () => { - describe('reference FOV identity', () => { - it('should return { envFactor: 1, headlampFactor: 1, ambientFactor: 1 } at reference FOV', () => { - const result = calculateFovLightingCompensation(fovCompensationReferenceFov); - expect(result.envFactor).toBeCloseTo(1, 6); - expect(result.headlampFactor).toBeCloseTo(1, 6); - expect(result.ambientFactor).toBeCloseTo(1, 6); - }); - }); - - describe('envFactor direction', () => { - it('should have envFactor < 1 for FOV below reference', () => { - const result = calculateFovLightingCompensation(10); - expect(result.envFactor).toBeLessThan(1); - }); - - it('should have envFactor > 1 for FOV above reference', () => { - const result = calculateFovLightingCompensation(90); - expect(result.envFactor).toBeGreaterThan(1); - }); - }); - - describe('diffuse compensation at low FOV', () => { - it('should have headlampFactor > 1 when envFactor < 1', () => { - const result = calculateFovLightingCompensation(10); - expect(result.envFactor).toBeLessThan(1); - expect(result.headlampFactor).toBeGreaterThan(1); - }); - - it('should have ambientFactor > 1 when envFactor < 1', () => { - const result = calculateFovLightingCompensation(10); - expect(result.envFactor).toBeLessThan(1); - expect(result.ambientFactor).toBeGreaterThan(1); - }); - }); - - describe('no compensation at high FOV', () => { - it('should have headlampFactor === 1 when envFactor >= 1', () => { - const result = calculateFovLightingCompensation(90); - expect(result.envFactor).toBeGreaterThanOrEqual(1); - expect(result.headlampFactor).toBeCloseTo(1, 6); - }); - - it('should have ambientFactor === 1 when envFactor >= 1', () => { - const result = calculateFovLightingCompensation(90); - expect(result.envFactor).toBeGreaterThanOrEqual(1); - expect(result.ambientFactor).toBeCloseTo(1, 6); - }); - }); - - describe('clamping', () => { - it('should clamp envFactor at minimum for very low FOV (0.1 deg)', () => { - const result = calculateFovLightingCompensation(0.1); - expect(result.envFactor).toBeCloseTo(fovCompensationEnvMin, 6); - }); - - it('should clamp envFactor at maximum for very high FOV', () => { - // At extremely high FOV, the tan ratio would exceed max clamp - const result = calculateFovLightingCompensation(179); - expect(result.envFactor).toBeCloseTo(fovCompensationEnvMax, 6); - }); - - it('should enforce envFactor >= envMin', () => { - for (const fov of [0.1, 0.5, 1, 2, 5]) { - const result = calculateFovLightingCompensation(fov); - expect(result.envFactor).toBeGreaterThanOrEqual(fovCompensationEnvMin); - } - }); - - it('should enforce envFactor <= envMax', () => { - for (const fov of [90, 120, 150, 170, 179]) { - const result = calculateFovLightingCompensation(fov); - expect(result.envFactor).toBeLessThanOrEqual(fovCompensationEnvMax); - } - }); - }); - - describe('NaN and degenerate input', () => { - it('should return neutral factors for NaN input', () => { - const result = calculateFovLightingCompensation(Number.NaN); - expect(result.envFactor).toBe(1); - expect(result.headlampFactor).toBe(1); - expect(result.ambientFactor).toBe(1); - }); - - it('should return finite factors for valid edge-case inputs', () => { - for (const fov of [0.001, 0.1, 1, 89, 90, 179]) { - const result = calculateFovLightingCompensation(fov); - expect(Number.isFinite(result.envFactor)).toBe(true); - expect(Number.isFinite(result.headlampFactor)).toBe(true); - expect(Number.isFinite(result.ambientFactor)).toBe(true); - } - }); - }); - - describe('monotonicity', () => { - it('should have envFactor monotonically increasing as FOV increases', () => { - let previousEnv = calculateFovLightingCompensation(0.1).envFactor; - for (let fov = 1; fov <= 90; fov++) { - const env = calculateFovLightingCompensation(fov).envFactor; - expect(env).toBeGreaterThanOrEqual(previousEnv); - previousEnv = env; - } - }); - - it('should have headlampFactor monotonically decreasing as FOV increases', () => { - let previousHeadlamp = calculateFovLightingCompensation(0.1).headlampFactor; - for (let fov = 1; fov <= 90; fov++) { - const headlamp = calculateFovLightingCompensation(fov).headlampFactor; - expect(headlamp).toBeLessThanOrEqual(previousHeadlamp); - previousHeadlamp = headlamp; - } - }); - - it('should have ambientFactor monotonically decreasing as FOV increases', () => { - let previousAmbient = calculateFovLightingCompensation(0.1).ambientFactor; - for (let fov = 1; fov <= 90; fov++) { - const ambient = calculateFovLightingCompensation(fov).ambientFactor; - expect(ambient).toBeLessThanOrEqual(previousAmbient); - previousAmbient = ambient; - } - }); - }); - - describe('custom parameters', () => { - it('should accept custom reference FOV', () => { - const result = calculateFovLightingCompensation(30, 30); - expect(result.envFactor).toBeCloseTo(1, 6); - expect(result.headlampFactor).toBeCloseTo(1, 6); - expect(result.ambientFactor).toBeCloseTo(1, 6); - }); - - it('should accept custom exponent', () => { - // Use FOV=50 (close to reference) so values stay above envMin clamp - const mildResult = calculateFovLightingCompensation(50, 54, 0.2); - const strongResult = calculateFovLightingCompensation(50, 54, 0.8); - // Stronger exponent = more aggressive compensation (lower envFactor at low FOV) - expect(strongResult.envFactor).toBeLessThan(mildResult.envFactor); - }); - }); -}); diff --git a/apps/ui/app/components/geometry/graphics/three/utils/math.utils.ts b/apps/ui/app/components/geometry/graphics/three/utils/math.utils.ts deleted file mode 100644 index 7c2a08acb..000000000 --- a/apps/ui/app/components/geometry/graphics/three/utils/math.utils.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * Pure math utilities for camera FOV calculations. - */ - -const degToRad = Math.PI / 180; - -// FOV mapping constants -const minFov = 0.1; // Very narrow FOV (nearly orthographic) -const maxFov = 90; // Very wide FOV (extreme perspective) -const fovRange = maxFov - minFov; // 89.9 - -// Epsilon below which a half-FOV tangent is considered degenerate (avoids Infinity/NaN) -export const tanEpsilon = 1e-9; - -// --------------------------------------------------------------------------- -// Gizmo base constants (sourced from the three-viewport-gizmo library internals) -// See: node_modules/three-viewport-gizmo/dist/three-viewport-gizmo.js -// -> `new PerspectiveCamera(26, 1, 5, 10)` with `position.set(0, 0, 7)` -// --------------------------------------------------------------------------- - -/** Default FOV of the three-viewport-gizmo internal PerspectiveCamera (degrees). */ -export const gizmoBaseFov = 26; -/** Default Z-distance of the three-viewport-gizmo internal camera from origin. */ -export const gizmoBaseDistance = 7; -/** Depth margin around the gizmo origin for near/far plane calculation. */ -export const gizmoDepthMargin = 3; - -/** - * Focus offset for gizmo distance compensation. - * - * Derived from `GIZMO_CUBE_LOCAL_HALF_SIZE (1.0) * GIZMO_SCALE (0.7)`. - * The cube face centers sit at ±1.0 in the gizmo's local coordinate space; - * after `gizmo.scale.multiplyScalar(0.7)` the front face is at z = 0.7 in - * world space. Focusing the distance compensation on this plane keeps the - * front-facing cube face at a constant projected size regardless of FOV, - * so only the perspective look/feel changes. - */ -export const gizmoFocusOffset = 0.7; - -/** - * Scale factor applied to the viewport FOV slider value before mapping it to - * the gizmo's camera FOV. A value of 0.5 halves the effective FOV so that at - * the maximum slider position (90) the gizmo shows ~45° instead of 90°, - * significantly reducing perspective warping on the small cube. - */ -export const gizmoFovScale = 0.5; - -/** - * Maps a slider angle (0-90) to an actual camera FOV in degrees (0.1-90). - * Input is clamped to [0, 90]. - * - * The mapping is linear: `0.1 + 89.9 * (angle / 90)`. - * - * @param cameraFovAngle - The slider value in the range [0, 90]. - * @returns The camera FOV in degrees in the range [0.1, 90]. - */ -export function calculateFovFromAngle(cameraFovAngle: number): number { - const clamped = Math.max(0, Math.min(maxFov, cameraFovAngle)); - return minFov + fovRange * (clamped / maxFov); -} - -/** - * Maps a slider angle (0-90) to a **dampened** FOV for the viewport gizmo. - * - * Applies {@link gizmoFovScale} to the slider value before the standard - * linear mapping so that the gizmo never reaches extreme perspective angles. - * At `GIZMO_FOV_SCALE = 0.5`: - * - Slider 0 → 0.1° (same as viewport) - * - Slider 90 → ~45° (half of viewport's 90°) - * - * @param cameraFovAngle - The slider value in the range [0, 90]. - * @returns The dampened gizmo FOV in degrees. - */ -export function calculateGizmoFovFromAngle(cameraFovAngle: number): number { - return calculateFovFromAngle(cameraFovAngle * gizmoFovScale); -} - -// ── FOV lighting compensation ───────────────────────────────────────────────── -// At low FOV, parallel view rays cause specular highlights to wash out across -// entire flat faces. The compensation reduces scene.environmentIntensity -// (dimming specular wash) while boosting headlamp + ambient (compensating -// diffuse loss). All constants are tunable during visual iteration. - -/** FOV at which lighting looks "correct" — no compensation applied. */ -export const fovCompensationReferenceFov = 54; -/** Damping exponent (< 1.0 for partial compensation; 1.0 = full tan ratio). */ -export const fovCompensationExponent = 0.4; -/** Minimum envFactor clamp — prevents total darkness at near-orthographic FOV. */ -export const fovCompensationEnvMin = 0.9; -/** Maximum envFactor clamp — limits brightening at high FOV. */ -export const fovCompensationEnvMax = 1.2; -/** Fraction of diffuse loss redirected to headlamp boost. */ -export const fovCompensationHeadlampBoost = 2.5; -/** Fraction of diffuse loss redirected to ambient boost. */ -export const fovCompensationAmbientBoost = 3; - -export type FovLightingCompensation = { - /** Multiply scene.environmentIntensity by this factor. */ - envFactor: number; - /** Multiply headlamp intensity by this factor. */ - headlampFactor: number; - /** Multiply ambient intensity by this factor. */ - ambientFactor: number; -}; - -/** - * Computes FOV-dependent lighting compensation factors. - * - * At the reference FOV all factors are 1.0. Below reference FOV, `envFactor` - * decreases (dims specular wash) while `headlampFactor` and `ambientFactor` - * increase (compensates diffuse loss). Above reference FOV, `envFactor` - * increases (up to max clamp) and diffuse compensators stay at 1.0. - * - * @param currentFovDeg - The current camera FOV in degrees. - * @param referenceFovDeg - The FOV at which no compensation is applied. - * @param exponent - Damping exponent for the tan ratio. - * @returns Three coordinated compensation factors. - */ -export function calculateFovLightingCompensation( - currentFovDeg: number, - referenceFovDeg: number = fovCompensationReferenceFov, - exponent: number = fovCompensationExponent, -): FovLightingCompensation { - const currentHalfRad = (currentFovDeg / 2) * degToRad; - const refHalfRad = (referenceFovDeg / 2) * degToRad; - const tanCurrent = Math.tan(currentHalfRad); - const tanRef = Math.tan(refHalfRad); - - if (tanRef < tanEpsilon || Number.isNaN(currentFovDeg)) { - return { envFactor: 1, headlampFactor: 1, ambientFactor: 1 }; - } - - const rawEnv = Math.max(tanCurrent / tanRef, 1e-9) ** exponent; - const envFactor = Math.max(fovCompensationEnvMin, Math.min(fovCompensationEnvMax, rawEnv)); - - // Diffuse loss from reduced environment intensity - const diffuseLoss = Math.max(0, 1 - envFactor); - - return { - envFactor, - headlampFactor: 1 + diffuseLoss * fovCompensationHeadlampBoost, - ambientFactor: 1 + diffuseLoss * fovCompensationAmbientBoost, - }; -} - -/** - * Computes the new camera distance required to maintain perceived object size - * when the FOV changes. - * - * **Without `focusOffset` (default 0):** - * `newDistance = currentDistance * tan(oldFov/2) / tan(newFov/2)` - * Keeps objects at the origin at constant projected size. - * - * **With `focusOffset > 0`:** - * `newDistance = focusOffset + (currentDistance - focusOffset) * tan(oldFov/2) / tan(newFov/2)` - * Keeps the plane at `z = focusOffset` (toward the camera) at constant - * projected size. This is used for the viewport gizmo so that the cube's - * front face stays the same apparent size and only the perspective look/feel - * changes with FOV. - * - * Guards against degenerate inputs: - * - If either half-FOV tangent is below epsilon, returns `currentDistance` unchanged. - * - If any input is NaN, returns `currentDistance` unchanged. - * - * @param oldFovDeg - The previous FOV in degrees. - * @param newFovDeg - The new FOV in degrees. - * @param currentDistance - The current camera distance from the target. - * @param focusOffset - Z-offset from the origin toward the camera whose projected - * size should remain constant. Defaults to 0 (origin). - * @returns The compensated distance that keeps the focus plane the same apparent size. - */ -// eslint-disable-next-line max-params -- heavily used utility (30+ call sites); wrapping would add noise for simple numeric args -export function calculateFovDistanceCompensation( - oldFovDeg: number, - newFovDeg: number, - currentDistance: number, - focusOffset = 0, -): number { - if (Number.isNaN(oldFovDeg) || Number.isNaN(newFovDeg) || Number.isNaN(currentDistance)) { - return currentDistance; - } - - const oldHalfRad = (oldFovDeg / 2) * degToRad; - const newHalfRad = (newFovDeg / 2) * degToRad; - - const tanOld = Math.tan(oldHalfRad); - const tanNew = Math.tan(newHalfRad); - - if (Math.abs(tanOld) < tanEpsilon || Math.abs(tanNew) < tanEpsilon) { - return currentDistance; - } - - const effectiveDistance = currentDistance - focusOffset; - return focusOffset + effectiveDistance * (tanOld / tanNew); -} diff --git a/apps/ui/app/components/geometry/graphics/three/utils/rotation.utils.ts b/apps/ui/app/components/geometry/graphics/three/utils/rotation.utils.ts deleted file mode 100644 index a04902860..000000000 --- a/apps/ui/app/components/geometry/graphics/three/utils/rotation.utils.ts +++ /dev/null @@ -1,69 +0,0 @@ -import * as THREE from 'three'; - -/** - * Computes a quaternion that rotates an object around a given axis to face the camera. - * This creates a billboard effect constrained to rotation around a single axis. - * - * @param axis - The axis to rotate around (should be normalized) - * @param position - The position of the object in world space - * @param camera - The camera to face towards - * @param referenceUp - Optional reference "up" vector perpendicular to the axis (default: best perpendicular to axis) - * @returns Quaternion representing the rotation around the axis - */ -export function computeAxisRotationForCamera({ - axis, - position, - camera, - referenceUp, -}: { - axis: THREE.Vector3; - position: THREE.Vector3; - camera: THREE.Camera; - referenceUp?: THREE.Vector3; -}): THREE.Quaternion { - // Calculate direction from object to camera - const eyeDirection = new THREE.Vector3(); - eyeDirection.subVectors(camera.position, position).normalize(); - - // Project the camera direction onto the plane perpendicular to the axis - // This gives us the direction towards the camera, constrained to rotation around the axis - const eyeProjected = new THREE.Vector3() - .copy(eyeDirection) - .addScaledVector(axis, -eyeDirection.dot(axis)) - .normalize(); - - // Get a reference "up" vector perpendicular to the axis - // If not provided, choose the best perpendicular vector - let upVector: THREE.Vector3; - if (referenceUp) { - upVector = referenceUp.clone(); - } else { - // Choose a reference vector that's not parallel to the axis - const absX = Math.abs(axis.x); - const absY = Math.abs(axis.y); - const absZ = Math.abs(axis.z); - - if (absX < absY && absX < absZ) { - upVector = new THREE.Vector3(1, 0, 0); - } else if (absY < absZ) { - upVector = new THREE.Vector3(0, 1, 0); - } else { - upVector = new THREE.Vector3(0, 0, 1); - } - } - - // Project the reference up vector onto the same plane perpendicular to the axis - const upProjected = new THREE.Vector3().copy(upVector).addScaledVector(axis, -upVector.dot(axis)).normalize(); - - // Calculate the signed angle between the projected vectors - const cosAngle = upProjected.dot(eyeProjected); - const crossProduct = new THREE.Vector3().crossVectors(upProjected, eyeProjected); - const sinAngle = crossProduct.dot(axis); - const rotationAngle = Math.atan2(sinAngle, cosAngle); - - // Create quaternion from axis-angle - const quaternion = new THREE.Quaternion(); - quaternion.setFromAxisAngle(axis, rotationAngle); - - return quaternion; -} diff --git a/apps/ui/app/components/geometry/graphics/three/utils/snap-detection.utils.ts b/apps/ui/app/components/geometry/graphics/three/utils/snap-detection.utils.ts deleted file mode 100644 index 5eebcbdd8..000000000 --- a/apps/ui/app/components/geometry/graphics/three/utils/snap-detection.utils.ts +++ /dev/null @@ -1,697 +0,0 @@ -import * as THREE from 'three'; - -export type SnapPoint = { - position: THREE.Vector3; - type: 'vertex' | 'edge-midpoint'; -}; - -// Epsilon constants for coplanar face detection -const normalEpsilonCos = 0.9995; // Cos(theta) where theta ~ 1.8° -const planeDistanceEpsilon = 1e-4; // World units - -type Triangle = { - a: number; - b: number; - c: number; -}; - -function getTriangleIndexArray(geometry: THREE.BufferGeometry): Triangle[] { - const triangles: Triangle[] = []; - const index = geometry.getIndex(); - if (index) { - const array = index.array as ArrayLike; - for (let i = 0; i < array.length; i += 3) { - triangles.push({ a: array[i]!, b: array[i + 1]!, c: array[i + 2]! }); - } - } else { - const positionCount = geometry.getAttribute('position').count; - for (let i = 0; i < positionCount; i += 3) { - triangles.push({ a: i, b: i + 1, c: i + 2 }); - } - } - - return triangles; -} - -function computeWorldPositions(mesh: THREE.Mesh, geometry: THREE.BufferGeometry): THREE.Vector3[] { - const position = geometry.getAttribute('position'); - const worldPositions: THREE.Vector3[] = Array.from({ length: position.count }); - for (let i = 0; i < position.count; i++) { - const v = new THREE.Vector3().fromBufferAttribute(position, i); - v.applyMatrix4(mesh.matrixWorld); - worldPositions[i] = v; - } - - return worldPositions; -} - -function getTriangleVertices( - tri: Triangle, - worldPositions: THREE.Vector3[], -): [THREE.Vector3, THREE.Vector3, THREE.Vector3] { - return [worldPositions[tri.a]!, worldPositions[tri.b]!, worldPositions[tri.c]!]; -} - -function triangleNormalWorld(a: THREE.Vector3, b: THREE.Vector3, c: THREE.Vector3): THREE.Vector3 { - const ab = new THREE.Vector3().subVectors(b, a); - const ac = new THREE.Vector3().subVectors(c, a); - return new THREE.Vector3().crossVectors(ab, ac).normalize(); -} - -function pointPlaneDistance(normal: THREE.Vector3, constant: number, p: THREE.Vector3): number { - return normal.dot(p) - constant; -} - -function edgeKey(i: number, j: number): string { - return i < j ? `${i}|${j}` : `${j}|${i}`; -} - -type CoplanarFaceParameters = { - hitTriIndex: number; - triangles: Triangle[]; - worldPositions: THREE.Vector3[]; - refNormal: THREE.Vector3; - refConstant: number; - canonicalIndex: number[]; -}; - -function collectCoplanarContiguousFace(parameters: CoplanarFaceParameters): number[] { - const { hitTriIndex, triangles, worldPositions, refNormal, refConstant, canonicalIndex } = parameters; - const candidateFlags = Array.from({ length: triangles.length }).fill(false); - // Pre-filter triangles by plane distance and normal similarity - for (const [i, triangle] of triangles.entries()) { - const t = triangle; - const [a, b, c] = getTriangleVertices(t, worldPositions); - const n = triangleNormalWorld(a, b, c); - if (Math.abs(n.dot(refNormal)) < normalEpsilonCos) { - continue; - } - - const d1 = Math.abs(pointPlaneDistance(refNormal, refConstant, a)); - const d2 = Math.abs(pointPlaneDistance(refNormal, refConstant, b)); - const d3 = Math.abs(pointPlaneDistance(refNormal, refConstant, c)); - if (d1 < planeDistanceEpsilon && d2 < planeDistanceEpsilon && d3 < planeDistanceEpsilon) { - candidateFlags[i] = true; - } - } - - // Build adjacency for candidate triangles via shared edges - const edgeToTriangles = new Map(); - for (const [i, triangle] of triangles.entries()) { - if (!candidateFlags[i]) { - continue; - } - - const t = triangle; - const ca = canonicalIndex[t.a]!; - const cb = canonicalIndex[t.b]!; - const cc = canonicalIndex[t.c]!; - const edges: Array<[string, number, number]> = [ - [edgeKey(ca, cb), ca, cb], - [edgeKey(cb, cc), cb, cc], - [edgeKey(cc, ca), cc, ca], - ]; - for (const [k] of edges) { - const list = edgeToTriangles.get(k) ?? []; - list.push(i); - edgeToTriangles.set(k, list); - } - } - - // BFS from hitTriIndex to collect contiguous region - const visited = new Set(); - const queue: number[] = []; - if (candidateFlags[hitTriIndex]) { - queue.push(hitTriIndex); - visited.add(hitTriIndex); - } - - while (queue.length > 0) { - const idx = queue.shift()!; - const t = triangles[idx]!; - const ca = canonicalIndex[t.a]!; - const cb = canonicalIndex[t.b]!; - const cc = canonicalIndex[t.c]!; - const keys = [edgeKey(ca, cb), edgeKey(cb, cc), edgeKey(cc, ca)]; - for (const k of keys) { - const neighbors = edgeToTriangles.get(k) ?? []; - for (const nIdx of neighbors) { - if (candidateFlags[nIdx] && !visited.has(nIdx)) { - visited.add(nIdx); - queue.push(nIdx); - } - } - } - } - - return [...visited]; -} - -type ReferencePlane = { - normal: THREE.Vector3; - constant: number; - point: THREE.Vector3; -}; - -type BoundaryEdgeResult = { - boundaryEdges: Array<[number, number]>; - interiorEdges: Array<[number, number]>; -}; - -function getRaycastIntersection( - mesh: THREE.Mesh, - raycaster: THREE.Raycaster, -): THREE.Intersection | undefined { - const intersects = raycaster.intersectObject(mesh, true); - if (intersects.length === 0) { - return undefined; - } - - const intersection = intersects[0]; - if (!intersection?.face) { - return undefined; - } - - return intersection as THREE.Intersection; -} - -function buildCanonicalVertexIndices(worldPositions: THREE.Vector3[]): number[] { - const positionKey = (v: THREE.Vector3): string => `${v.x.toFixed(5)},${v.y.toFixed(5)},${v.z.toFixed(5)}`; - const keyToCanonical = new Map(); - const canonicalIndex: number[] = Array.from({ length: worldPositions.length }); - - let idx = 0; - for (const wp of worldPositions) { - const key = positionKey(wp); - if (!keyToCanonical.has(key)) { - keyToCanonical.set(key, idx); - } - - canonicalIndex[idx] = keyToCanonical.get(key)!; - idx++; - } - - return canonicalIndex; -} - -function findHitTriangleIndex(face: THREE.Face, triangles: Triangle[]): number { - const { a, b, c } = face; - for (const [i, triangle] of triangles.entries()) { - const t = triangle; - if ( - (t.a === a && t.b === b && t.c === c) || - (t.a === b && t.b === c && t.c === a) || - (t.a === c && t.b === a && t.c === b) - ) { - return i; - } - } - - return 0; // Fallback -} - -function computeReferencePlane(triangle: Triangle, worldPositions: THREE.Vector3[]): ReferencePlane { - const [pa, pb, pc] = getTriangleVertices(triangle, worldPositions); - const normal = triangleNormalWorld(pa, pb, pc).normalize(); - const constant = normal.dot(pa); - - return { normal, constant, point: pa }; -} - -function gatherBoundaryEdges( - faceTriangleIndices: number[], - triangles: Triangle[], - canonicalIndex: number[], -): BoundaryEdgeResult { - const edgeCount = new Map(); - const edgeCounter = new Map(); - - for (const idx of faceTriangleIndices) { - const t = triangles[idx]!; - const ca = canonicalIndex[t.a]!; - const cb = canonicalIndex[t.b]!; - const cc = canonicalIndex[t.c]!; - const edges: Array<[number, number]> = [ - [ca, cb], - [cb, cc], - [cc, ca], - ]; - - for (const [i, j] of edges) { - const k = edgeKey(i, j); - if (!edgeCount.has(k)) { - edgeCount.set(k, [i, j]); - } - - edgeCounter.set(k, (edgeCounter.get(k) ?? 0) + 1); - } - } - - const boundaryEdges: Array<[number, number]> = []; - const interiorEdges: Array<[number, number]> = []; - - for (const [k, count] of edgeCounter) { - const pair = edgeCount.get(k)!; - if (count === 1) { - boundaryEdges.push(pair); - } else if (count === 2) { - interiorEdges.push(pair); - } - } - - return { boundaryEdges, interiorEdges }; -} - -function tryDetectCircularFace({ - boundaryEdges, - worldPositions, - faceNormal, - planePoint, -}: { - boundaryEdges: Array<[number, number]>; - worldPositions: THREE.Vector3[]; - faceNormal: THREE.Vector3; - planePoint: THREE.Vector3; -}): SnapPoint[] | undefined { - const boundaryVertexIndices = new Set(); - for (const [i, j] of boundaryEdges) { - boundaryVertexIndices.add(i); - boundaryVertexIndices.add(j); - } - - const boundaryVerticesWorld: THREE.Vector3[] = [...boundaryVertexIndices].map((idx) => worldPositions[idx]!); - return detectCircleOnFace(boundaryVerticesWorld, faceNormal, planePoint); -} - -function collectBoundarySnapPoints( - boundaryEdges: Array<[number, number]>, - worldPositions: THREE.Vector3[], -): { snapPoints: SnapPoint[]; addPoint: (v: THREE.Vector3, type: SnapPoint['type']) => void } { - const snapPoints: SnapPoint[] = []; - const seen = new Set(); - - const addPoint = (v: THREE.Vector3, type: SnapPoint['type']): void => { - const key = `${v.x.toFixed(6)},${v.y.toFixed(6)},${v.z.toFixed(6)}`; - if (!seen.has(key)) { - snapPoints.push({ position: v.clone(), type }); - seen.add(key); - } - }; - - for (const [i, j] of boundaryEdges) { - const vi = worldPositions[i]!; - const vj = worldPositions[j]!; - addPoint(vi, 'vertex'); - addPoint(vj, 'vertex'); - addPoint(new THREE.Vector3().addVectors(vi, vj).multiplyScalar(0.5), 'edge-midpoint'); - } - - return { snapPoints, addPoint }; -} - -function orderBoundaryVertices(boundaryEdges: Array<[number, number]>): { - ordered: number[]; - boundaryVertexIndexSet: Set; -} { - const boundaryVertexIndexSet = new Set(); - const boundaryAdj = new Map(); - - for (const [i, j] of boundaryEdges) { - boundaryVertexIndexSet.add(i); - boundaryVertexIndexSet.add(j); - - const ai = boundaryAdj.get(i) ?? []; - ai.push(j); - boundaryAdj.set(i, ai); - - const aj = boundaryAdj.get(j) ?? []; - aj.push(i); - boundaryAdj.set(j, aj); - } - - const boundaryIndexList = [...boundaryVertexIndexSet]; - const ordered: number[] = []; - - if (boundaryIndexList.length >= 3) { - const start = boundaryIndexList[0]!; - let previous = -1; - let curr = start; - const maxSteps = boundaryIndexList.length + 5; - - for (let step = 0; step < maxSteps; step++) { - ordered.push(curr); - // eslint-disable-next-line @typescript-eslint/no-loop-func -- `previous` is always defined in the loop - const neighbors = (boundaryAdj.get(curr) ?? []).filter((n) => n !== previous); - if (neighbors.length === 0) { - break; - } - - const next = neighbors[0]!; - previous = curr; - curr = next; - if (curr === start) { - break; - } - } - } - - return { ordered, boundaryVertexIndexSet }; -} - -type FaceCenterParameters = { - ordered: number[]; - boundaryVertexIndexSet: Set; - worldPositions: THREE.Vector3[]; - refNormal: THREE.Vector3; - refPoint: THREE.Vector3; -}; - -function computeFaceCenter(parameters: FaceCenterParameters): THREE.Vector3 { - const { ordered, boundaryVertexIndexSet, worldPositions, refNormal, refPoint } = parameters; - const { u: planeU, v: planeV } = constructPlaneAxes(refNormal); - let center: THREE.Vector3 | undefined; - - if (ordered.length >= 3) { - // Area-weighted centroid in 2D then map back to 3D - let area = 0; - let cx = 0; - let cy = 0; - - const to2D = (idx: number): { x: number; y: number } => { - const p = worldPositions[idx]!; - const rel = new THREE.Vector3().subVectors(p, refPoint); - return { x: rel.dot(planeU), y: rel.dot(planeV) }; - }; - - for (let i = 0; i < ordered.length; i++) { - const a = to2D(ordered[i]!); - const b = to2D(ordered[(i + 1) % ordered.length]!); - const cross = a.x * b.y - a.y * b.x; - area += cross; - cx += (a.x + b.x) * cross; - cy += (a.y + b.y) * cross; - } - - area *= 0.5; - if (Math.abs(area) > 1e-9) { - cx /= 6 * area; - cy /= 6 * area; - center = new THREE.Vector3() - .copy(refPoint) - .add(new THREE.Vector3().copy(planeU).multiplyScalar(cx)) - .add(new THREE.Vector3().copy(planeV).multiplyScalar(cy)); - } - } - - if (!center) { - // Fallback: average of boundary vertices - center = new THREE.Vector3(); - for (const idx of boundaryVertexIndexSet) { - center.add(worldPositions[idx]!); - } - - const boundaryIndexList = [...boundaryVertexIndexSet]; - center.multiplyScalar(1 / Math.max(1, boundaryIndexList.length)); - } - - return center; -} - -export function detectSnapPoints(mesh: THREE.Mesh, raycaster: THREE.Raycaster): SnapPoint[] { - // 1. Get raycast intersection - const intersection = getRaycastIntersection(mesh, raycaster); - if (!intersection) { - return []; - } - - // 2. Extract geometry data - const { object } = intersection; - const { geometry } = object; - const triangles = getTriangleIndexArray(geometry); - const worldPositions = computeWorldPositions(object, geometry); - - // 3. Build canonical vertex indices to merge coincident vertices - const canonicalIndex = buildCanonicalVertexIndices(worldPositions); - - // 4. Find the hit triangle - const triIndex = findHitTriangleIndex(intersection.face!, triangles); - - // 5. Compute reference plane from hit triangle - const { - normal: refNormal, - constant: refConstant, - point: refPoint, - } = computeReferencePlane(triangles[triIndex]!, worldPositions); - - // 6. Collect contiguous coplanar face region - const faceTriangleIndices = collectCoplanarContiguousFace({ - hitTriIndex: triIndex, - triangles, - worldPositions, - refNormal, - refConstant, - canonicalIndex, - }); - - // 7. Gather boundary edges - const { boundaryEdges } = gatherBoundaryEdges(faceTriangleIndices, triangles, canonicalIndex); - - // 8. Try circular face detection first - const maybeCircle = tryDetectCircularFace({ - boundaryEdges, - worldPositions, - faceNormal: refNormal, - planePoint: refPoint, - }); - if (maybeCircle) { - return maybeCircle; - } - - // 9. Collect boundary snap points - const { snapPoints, addPoint } = collectBoundarySnapPoints(boundaryEdges, worldPositions); - - // 10. Compute and add face center - const { ordered, boundaryVertexIndexSet } = orderBoundaryVertices(boundaryEdges); - const center = computeFaceCenter({ - ordered, - boundaryVertexIndexSet, - worldPositions, - refNormal, - refPoint, - }); - addPoint(center, 'vertex'); - - return snapPoints; -} - -// ---------------------- Circle detection helpers ---------------------- - -function constructPlaneAxes(normal: THREE.Vector3): { u: THREE.Vector3; v: THREE.Vector3 } { - const tryAxis = (axis: THREE.Vector3): THREE.Vector3 => { - const proj = axis.clone().addScaledVector(normal, -axis.dot(normal)); - if (proj.lengthSq() < 1e-10) { - return proj; - } - - return proj.normalize(); - }; - - let u = tryAxis(new THREE.Vector3(1, 0, 0)); - if (u.lengthSq() < 1e-10) { - u = tryAxis(new THREE.Vector3(0, 1, 0)); - } - - if (u.lengthSq() < 1e-10) { - const temporary = Math.abs(normal.x) < 0.9 ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0); - u = tryAxis(temporary); - } - - const v = new THREE.Vector3().crossVectors(normal, u).normalize(); - return { u, v }; -} - -function fitCircle2D(points: Array<{ x: number; y: number }>): { cx: number; cy: number; r: number } | undefined { - let sxx = 0; - let syy = 0; - let sxy = 0; - let sx = 0; - let sy = 0; - let szz = 0; - let sxz = 0; - let syz = 0; - - for (const p of points) { - const z = p.x * p.x + p.y * p.y; - sxx += p.x * p.x; - syy += p.y * p.y; - sxy += p.x * p.y; - sx += p.x; - sy += p.y; - szz += z; - sxz += p.x * z; - syz += p.y * z; - } - - const n = points.length; - - const aMatrix = [ - [sxx, sxy, sx], - [sxy, syy, sy], - [sx, sy, n], - ]; - - const bVector = [sxz * 0.5, syz * 0.5, szz * 0.5]; - const sol = solveSymmetric3(aMatrix, bVector); - if (!sol) { - return undefined; - } - - const [cx, cy, c] = sol; - const r = Math.sqrt(Math.max(0, cx * cx + cy * cy + c)); - if (!Number.isFinite(r)) { - return undefined; - } - - return { cx, cy, r }; -} - -function solveSymmetric3(aMatrix: number[][], bVector: number[]): [number, number, number] | undefined { - const m: number[][] = [[...aMatrix[0]!], [...aMatrix[1]!], [...aMatrix[2]!]]; - const b = [...bVector]; - - for (let i = 0; i < 3; i++) { - let pivot = i; - for (let r = i + 1; r < 3; r++) { - if (Math.abs(m[r]![i]!) > Math.abs(m[pivot]![i]!)) { - pivot = r; - } - } - - if (Math.abs(m[pivot]![i]!) < 1e-12) { - return undefined; - } - - if (pivot !== i) { - [m[i], m[pivot]] = [m[pivot]!, m[i]!]; - [b[i], b[pivot]] = [b[pivot]!, b[i]!]; - } - - for (let r = i + 1; r < 3; r++) { - const factor = m[r]![i]! / m[i]![i]!; - for (let c = i; c < 3; c++) { - m[r]![c]! -= factor * m[i]![c]!; - } - - b[r]! -= factor * b[i]!; - } - } - - const x: [number, number, number] = [0, 0, 0]; - for (let i = 2; i >= 0; i--) { - let sum = b[i]!; - for (let c = i + 1; c < 3; c++) { - sum -= m[i]![c]! * x[c]!; - } - - x[i] = sum / m[i]![i]!; - } - - return [x[0], x[1], x[2]]; -} - -function detectCircleOnFace( - boundaryVerticesWorld: THREE.Vector3[], - faceNormal: THREE.Vector3, - planePoint: THREE.Vector3, -): SnapPoint[] | undefined { - const minSamples = 12; - if (boundaryVerticesWorld.length < minSamples) { - return undefined; - } - - const { u, v } = constructPlaneAxes(faceNormal.clone().normalize()); - - const pts2D = boundaryVerticesWorld.map((p) => { - const rel = new THREE.Vector3().subVectors(p, planePoint); - return { x: rel.dot(u), y: rel.dot(v) }; - }); - - const fit = fitCircle2D(pts2D); - if (!fit) { - return undefined; - } - - const { cx, cy, r } = fit; - if (!Number.isFinite(r) || r <= 0) { - return undefined; - } - - let r2sum = 0; - let minX = Infinity; - let maxX = -Infinity; - let minY = Infinity; - let maxY = -Infinity; - for (const p of pts2D) { - const dx = p.x - cx; - const dy = p.y - cy; - const di = Math.hypot(dx, dy); - r2sum += (di - r) * (di - r); - minX = Math.min(minX, p.x); - maxX = Math.max(maxX, p.x); - minY = Math.min(minY, p.y); - maxY = Math.max(maxY, p.y); - } - - const rms = Math.sqrt(r2sum / pts2D.length); - const relRms = rms / r; - const width = maxX - minX; - const height = maxY - minY; - const aspect = Math.max(width, height) / Math.max(1e-9, Math.min(width, height)); - if (!(relRms <= 0.03 && aspect <= 1.05)) { - return undefined; - } - - const centerWorld = planePoint.clone().addScaledVector(u, cx).addScaledVector(v, cy); - - const result: SnapPoint[] = [ - { position: centerWorld.clone().addScaledVector(u, r), type: 'edge-midpoint' }, - { position: centerWorld.clone().addScaledVector(u, -r), type: 'edge-midpoint' }, - { position: centerWorld.clone().addScaledVector(v, r), type: 'edge-midpoint' }, - { position: centerWorld.clone().addScaledVector(v, -r), type: 'edge-midpoint' }, - { position: centerWorld, type: 'vertex' }, - ]; - return result; -} - -export function findClosestSnapPoint( - snapPoints: SnapPoint[], - options: { - mousePos: THREE.Vector2; - camera: THREE.Camera; - canvas: HTMLCanvasElement; - snapDistancePx: number; - snapPointBufferPx?: number; - }, -): SnapPoint | undefined { - const { mousePos, camera, canvas, snapDistancePx, snapPointBufferPx = 15 } = options; - let closest: SnapPoint | undefined; - let minDistance = snapDistancePx + snapPointBufferPx; - - for (const snapPoint of snapPoints) { - const screenPos = snapPoint.position.clone().project(camera); - const canvasX = (screenPos.x + 1) * 0.5 * canvas.width; - const canvasY = (-screenPos.y + 1) * 0.5 * canvas.height; - - const mouseCanvasX = (mousePos.x + 1) * 0.5 * canvas.width; - const mouseCanvasY = (-mousePos.y + 1) * 0.5 * canvas.height; - - const distance = Math.hypot(canvasX - mouseCanvasX, canvasY - mouseCanvasY); - - if (distance < minDistance) { - minDistance = distance; - closest = snapPoint; - } - } - - return closest; -} diff --git a/apps/ui/app/components/geometry/graphics/three/utils/spatial.utils.ts b/apps/ui/app/components/geometry/graphics/three/utils/spatial.utils.ts deleted file mode 100644 index 215a2df8b..000000000 --- a/apps/ui/app/components/geometry/graphics/three/utils/spatial.utils.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type * as THREE from 'three'; - -export type PixelsToWorldUnitsInput = { - // Accepts a loosely-typed viewport to avoid version-specific type coupling. - viewport: unknown; - camera: THREE.Camera; - size: { width: number; height: number }; - at: THREE.Vector3; - pixels: number; -}; - -// Convert a pixel measure to world units at a given world-space point. -export function pixelsToWorldUnits({ viewport, camera, size, at, pixels }: PixelsToWorldUnitsInput): number { - const { getCurrentViewport } = viewport as { getCurrentViewport: (...args: unknown[]) => unknown }; - const vp = getCurrentViewport(camera, at) as { width: number; height: number }; - const worldPerPixel = vp.height / size.height; - return pixels * worldPerPixel; -}