import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { LineSegments2 } from 'three/addons/lines/LineSegments2.js'; import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js'; import { LineMaterial } from 'three/addons/lines/LineMaterial.js'; // Pre-allocated temp objects for hot-path event handlers (avoid GC pressure) const _tmpQ1 = new THREE.Quaternion(); const _tmpQ2 = new THREE.Quaternion(); const _tmpV1 = new THREE.Vector3(); const _tmpV2 = new THREE.Vector3(); const _tmpV3 = new THREE.Vector3(); const _tmpV4 = new THREE.Vector3(); let renderer, camera, scene, controls, meshGroup, ambientLight, dirLight1, dirLight2, grid; let currentMesh = null; let axesGroup = null; let dimensionGroup = null; let wireframeLines = null; // LineSegments overlay, or null when hidden let wireframeVisible = false; let exclusionMesh = null; // flat orange overlay for user-excluded faces let hoverMesh = null; // semi-transparent yellow bucket-fill preview let _exclMaterial = null; let _hoverMaterial = null; let _needsRender = true; // Build a labelled coordinate axes indicator scaled to `size`. // X = red, Y = green, Z = blue (up). function buildAxesIndicator(size) { const group = new THREE.Group(); const addAxis = (dir, hex, label) => { const r = size; // Shaft const pts = [new THREE.Vector3(0, 0, 0), dir.clone().multiplyScalar(r * 0.78)]; const line = new THREE.Line( new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color: hex, transparent: true, opacity: 0.9 }), ); group.add(line); // Cone arrowhead const cone = new THREE.Mesh( new THREE.ConeGeometry(r * 0.07, r * 0.22, 8), new THREE.MeshBasicMaterial({ color: hex }), ); cone.position.copy(dir.clone().multiplyScalar(r * 0.89)); cone.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir); group.add(cone); // Text sprite label const c = document.createElement('canvas'); c.width = c.height = 64; const ctx = c.getContext('2d'); ctx.fillStyle = `#${hex.toString(16).padStart(6, '0')}`; ctx.font = 'bold 48px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(label, 32, 32); const sprite = new THREE.Sprite( new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(c) }), ); sprite.position.copy(dir.clone().multiplyScalar(r * 1.18)); sprite.scale.set(r * 0.32, r * 0.32, 1); group.add(sprite); }; addAxis(new THREE.Vector3(1, 0, 0), 0xff3333, 'X'); addAxis(new THREE.Vector3(0, 1, 0), 0x33dd55, 'Y'); addAxis(new THREE.Vector3(0, 0, 1), 0x4488ff, 'Z'); return group; } // Create a canvas-texture sprite label for a dimension annotation. // Flat ground-plane label — no billboard, no background, lies directly on the bed. function buildDimensionLabel(text, hex, worldW, worldH) { const c = document.createElement('canvas'); c.width = 256; c.height = 64; const ctx = c.getContext('2d'); ctx.clearRect(0, 0, 256, 64); ctx.fillStyle = `#${hex.toString(16).padStart(6, '0')}`; ctx.font = 'bold 36px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(text, 128, 32); const mesh = new THREE.Mesh( new THREE.PlaneGeometry(worldW, worldH), new THREE.MeshBasicMaterial({ map: new THREE.CanvasTexture(c), transparent: true, side: THREE.DoubleSide }), ); return mesh; } // Build X/Y dimension-line annotations lying flat on the ground plane. function buildDimensions(box, groundZ, scale) { const group = new THREE.Group(); const fmt = v => v.toFixed(2); const pad = scale * 0.18; const tick = scale * 0.08; const lblW = scale * 0.50; const lblH = scale * 0.12; const zOff = 0.02; // tiny lift to avoid z-fighting with the grid const addLine = (pts, hex) => { const line = new THREE.Line( new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color: hex, transparent: true, opacity: 0.75 }), ); group.add(line); }; const addTick = (centre, dir, hex) => { addLine([ centre.clone().addScaledVector(dir, -tick * 0.5), centre.clone().addScaledVector(dir, tick * 0.5), ], hex); }; // X dimension — line along the front edge of the model { const hex = 0xff3333; const y = box.min.y - pad; addLine([new THREE.Vector3(box.min.x, y, groundZ), new THREE.Vector3(box.max.x, y, groundZ)], hex); addTick(new THREE.Vector3(box.min.x, y, groundZ), new THREE.Vector3(0, 1, 0), hex); addTick(new THREE.Vector3(box.max.x, y, groundZ), new THREE.Vector3(0, 1, 0), hex); const lbl = buildDimensionLabel(`X: ${fmt(box.max.x - box.min.x)}`, hex, lblW, lblH); lbl.position.set((box.min.x + box.max.x) / 2, y - lblH * 0.7, groundZ + zOff); group.add(lbl); } // Y dimension — line along the right edge of the model { const hex = 0x33dd55; const x = box.max.x + pad; addLine([new THREE.Vector3(x, box.min.y, groundZ), new THREE.Vector3(x, box.max.y, groundZ)], hex); addTick(new THREE.Vector3(x, box.min.y, groundZ), new THREE.Vector3(1, 0, 0), hex); addTick(new THREE.Vector3(x, box.max.y, groundZ), new THREE.Vector3(1, 0, 0), hex); const lbl = buildDimensionLabel(`Y: ${fmt(box.max.y - box.min.y)}`, hex, lblW, lblH); lbl.position.set(x + lblH * 0.7, (box.min.y + box.max.y) / 2, groundZ + zOff); lbl.rotation.z = Math.PI / 2; group.add(lbl); } return group; } export function initViewer(canvas) { // Renderer renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.1; renderer.shadowMap.enabled = false; // Scene scene = new THREE.Scene(); scene.background = new THREE.Color(0x111114); // Grid helper — in XY plane (Z-up) grid = new THREE.GridHelper(200, 40, 0x333340, 0x2a2a34); grid.rotation.x = Math.PI / 2; // rotate to XY plane for Z-up grid.position.z = 0; scene.add(grid); // Camera — orthographic (parallel projection), Z-up camera = new THREE.OrthographicCamera(-150, 150, 150, -150, -10000, 10000); camera.up.set(0, 0, 1); camera.position.set(120, -200, 100); camera.lookAt(0, 0, 0); // Lights ambientLight = new THREE.AmbientLight(0xffffff, 0.4); scene.add(ambientLight); dirLight1 = new THREE.DirectionalLight(0xffffff, 1.2); dirLight1.position.set(80, 120, 60); dirLight1.castShadow = false; scene.add(dirLight1); dirLight2 = new THREE.DirectionalLight(0x8899ff, 0.4); dirLight2.position.set(-60, -20, -80); scene.add(dirLight2); // Group to hold the mesh meshGroup = new THREE.Group(); scene.add(meshGroup); // Controls controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.screenSpacePanning = true; controls.enableZoom = false; // we handle zoom ourselves for cursor-centric behaviour // Raycast-based orbit pivot: when left-drag starts on the model, orbit // around the surface point under the cursor instead of the default target. // We disable OrbitControls' own rotation and handle it manually so that // neither the camera view nor the target "snaps" to the clicked point. const _orbitRaycaster = new THREE.Raycaster(); let _customPivot = null; // active pivot for the current drag let _lastKnownPivot = null; // persists between drags as fallback let _lastPointer = null; // Small red sphere shown at the orbit centre during a drag const _pivotMarker = new THREE.Mesh( new THREE.SphereGeometry(1, 16, 10), new THREE.MeshBasicMaterial({ color: 0xff2222, depthTest: false }), ); _pivotMarker.renderOrder = 10; _pivotMarker.visible = false; scene.add(_pivotMarker); renderer.domElement.addEventListener('pointerdown', (e) => { if (e.button !== 0 || !controls.enabled) return; if (!currentMesh) return; const rect = renderer.domElement.getBoundingClientRect(); const ndc = new THREE.Vector2( ((e.clientX - rect.left) / rect.width) * 2 - 1, ((e.clientY - rect.top) / rect.height) * -2 + 1, ); _orbitRaycaster.setFromCamera(ndc, camera); const hits = _orbitRaycaster.intersectObject(currentMesh); if (hits.length) { _customPivot = hits[0].point.clone(); _lastKnownPivot = _customPivot.clone(); } else if (_lastKnownPivot) { _customPivot = _lastKnownPivot.clone(); } else { return; // no pivot available yet, fall back to OrbitControls default } _lastPointer = { x: e.clientX, y: e.clientY }; controls.enableRotate = false; // we'll rotate manually // Show marker, sized as ~1.5 % of the visible frustum height _pivotMarker.position.copy(_customPivot); const markerScale = (camera.top / camera.zoom) * 0.015; _pivotMarker.scale.setScalar(markerScale); _pivotMarker.visible = true; _needsRender = true; }); document.addEventListener('pointermove', (e) => { if (!_customPivot || !_lastPointer || !controls.enabled) return; const dx = e.clientX - _lastPointer.x; const dy = e.clientY - _lastPointer.y; _lastPointer = { x: e.clientX, y: e.clientY }; if (dx === 0 && dy === 0) return; const rotSpeed = 0.005; // Horizontal: rotate around world Z (up) _tmpQ1.setFromAxisAngle(_tmpV1.set(0, 0, 1), -dx * rotSpeed); // Vertical: rotate around camera's local X (right vector) _tmpV2.setFromMatrixColumn(camera.matrixWorld, 0).normalize(); _tmpQ2.setFromAxisAngle(_tmpV2, -dy * rotSpeed); _tmpQ1.premultiply(_tmpQ2); // _tmpQ1 = qV * qH (total rotation) // Rotate camera position around the pivot _tmpV3.copy(camera.position).sub(_customPivot); _tmpV3.applyQuaternion(_tmpQ1); camera.position.copy(_customPivot).add(_tmpV3); // Rotate orbit target around the same pivot so OrbitControls stays in sync _tmpV4.copy(controls.target).sub(_customPivot); _tmpV4.applyQuaternion(_tmpQ1); controls.target.copy(_customPivot).add(_tmpV4); camera.lookAt(controls.target); _needsRender = true; }); document.addEventListener('pointerup', () => { if (_customPivot) { _customPivot = null; _lastPointer = null; controls.enableRotate = true; _pivotMarker.visible = false; _needsRender = true; } }); // Pinch-to-zoom + two-finger pan for touch devices let _pinchDist = null; let _pinchMid = null; // { x, y } client coords of two-finger midpoint renderer.domElement.addEventListener('touchstart', (e) => { if (e.touches.length === 2) { const t0 = e.touches[0], t1 = e.touches[1]; _pinchDist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY); _pinchMid = { x: (t0.clientX + t1.clientX) / 2, y: (t0.clientY + t1.clientY) / 2 }; controls.enabled = false; // suppress OrbitControls during two-finger gesture e.preventDefault(); } }, { passive: false }); renderer.domElement.addEventListener('touchmove', (e) => { if (e.touches.length !== 2 || _pinchDist === null) return; e.preventDefault(); const t0 = e.touches[0], t1 = e.touches[1]; const rect = renderer.domElement.getBoundingClientRect(); const newDist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY); const midX = (t0.clientX + t1.clientX) / 2; const midY = (t0.clientY + t1.clientY) / 2; // ── Pan: shift camera so the world point under the old midpoint // is now under the new midpoint ────────────────────────── const prevNdcX = ((_pinchMid.x - rect.left) / rect.width) * 2 - 1; const prevNdcY = -((_pinchMid.y - rect.top) / rect.height) * 2 + 1; const curNdcX = ((midX - rect.left) / rect.width) * 2 - 1; const curNdcY = -((midY - rect.top) / rect.height) * 2 + 1; _tmpV1.set(prevNdcX, prevNdcY, 0).unproject(camera); _tmpV2.set(curNdcX, curNdcY, 0).unproject(camera); _tmpV1.sub(_tmpV2); // panDelta camera.position.add(_tmpV1); controls.target.add(_tmpV1); // ── Zoom: zoom toward the current midpoint ──────────────────────── const factor = newDist / _pinchDist; _tmpV3.set(curNdcX, curNdcY, 0).unproject(camera); camera.zoom = Math.max(0.05, Math.min(200, camera.zoom * factor)); camera.updateProjectionMatrix(); _tmpV4.set(curNdcX, curNdcY, 0).unproject(camera); _tmpV3.sub(_tmpV4); // zoomDelta camera.position.add(_tmpV3); controls.target.add(_tmpV3); _pinchDist = newDist; _pinchMid = { x: midX, y: midY }; controls.update(); _needsRender = true; }, { passive: false }); renderer.domElement.addEventListener('touchend', (e) => { if (e.touches.length < 2) { _pinchDist = null; _pinchMid = null; controls.enabled = true; } }); // Cursor-centric zoom: zoom toward the mouse pointer instead of screen centre renderer.domElement.addEventListener('wheel', (e) => { e.preventDefault(); const rect = renderer.domElement.getBoundingClientRect(); const ndcX = ((e.clientX - rect.left) / rect.width) * 2 - 1; const ndcY = -((e.clientY - rect.top) / rect.height) * 2 + 1; // World position under cursor before zoom _tmpV1.set(ndcX, ndcY, 0).unproject(camera); // Apply zoom const factor = e.deltaY > 0 ? 1 / 1.1 : 1.1; camera.zoom = Math.max(0.05, Math.min(200, camera.zoom * factor)); camera.updateProjectionMatrix(); // World position under cursor after zoom _tmpV2.set(ndcX, ndcY, 0).unproject(camera); // Shift camera + target so the world point stays under the cursor _tmpV1.sub(_tmpV2); // delta = before - after camera.position.add(_tmpV1); controls.target.add(_tmpV1); controls.update(); }, { passive: false }); // Resize observer const resizeObserver = new ResizeObserver(() => onResize()); resizeObserver.observe(canvas.parentElement); onResize(); // Damping needs controls.update() every frame; re-render only when needed controls.addEventListener('change', () => { _needsRender = true; }); // Render loop (function animate() { requestAnimationFrame(animate); controls.update(); if (_needsRender) { _needsRender = false; renderer.render(scene, camera); } })(); } function onResize() { const el = renderer.domElement.parentElement; const w = el.clientWidth; const h = el.clientHeight; renderer.setSize(w, h, false); // Orthographic: keep the frustum half-height, update left/right for new aspect const aspect = w / h; const halfH = camera.top; camera.left = -halfH * aspect; camera.right = halfH * aspect; camera.updateProjectionMatrix(); // LineMaterial needs the actual pixel resolution to compute linewidth correctly if (wireframeLines) { wireframeLines.material.resolution.set( w * renderer.getPixelRatio(), h * renderer.getPixelRatio(), ); } requestRender(); } function disposeGroup(group) { group.traverse(obj => { if (obj.geometry) obj.geometry.dispose(); if (obj.material) { if (Array.isArray(obj.material)) { obj.material.forEach(m => { if (m.map) m.map.dispose(); m.dispose(); }); } else { if (obj.material.map) obj.material.map.dispose(); obj.material.dispose(); } } }); } /** * Replace the mesh in the scene with new geometry. * @param {THREE.BufferGeometry} geometry * @param {THREE.Material} [material] – if omitted, a default material is used */ export function loadGeometry(geometry, material) { // Clear previous mesh while (meshGroup.children.length) { const old = meshGroup.children[0]; old.geometry.dispose(); if (old.material && old.material.dispose) old.material.dispose(); meshGroup.remove(old); } const mat = material || new THREE.MeshStandardMaterial({ color: 0xaaaacc, roughness: 0.6, metalness: 0.1, side: THREE.DoubleSide, }); if (!geometry.attributes.normal) geometry.computeVertexNormals(); currentMesh = new THREE.Mesh(geometry, mat); currentMesh.castShadow = true; currentMesh.receiveShadow = true; meshGroup.add(currentMesh); // Rebuild wireframe overlay to match the new geometry // (old overlay is already gone because meshGroup was cleared above) wireframeLines = null; if (wireframeVisible) _buildWireframe(geometry); // Position grid at mesh bottom (Z-up: move grid along Z) geometry.computeBoundingBox(); const box = geometry.boundingBox; const groundZ = box.min.z - 0.01; grid.position.z = groundZ; // Fit camera const sphere = new THREE.Sphere(); geometry.computeBoundingSphere(); sphere.copy(geometry.boundingSphere); fitCamera(sphere); // Place coordinate axes away from the part corner if (axesGroup) { disposeGroup(axesGroup); scene.remove(axesGroup); } const axisSize = sphere.radius * 0.30; axesGroup = buildAxesIndicator(axisSize); // Offset from the bounding box corner by ~1 axis-length so it doesn't overlap the mesh const axisPad = axisSize * 1.8; axesGroup.position.set(box.min.x - axisPad, box.min.y - axisPad, groundZ); scene.add(axesGroup); // Bounding-box dimension annotations on the ground plane if (dimensionGroup) { disposeGroup(dimensionGroup); scene.remove(dimensionGroup); } dimensionGroup = buildDimensions(box, groundZ, sphere.radius); scene.add(dimensionGroup); requestRender(); } /** * Update only the material on the current mesh. * @param {THREE.Material} material */ export function setMeshMaterial(material) { if (!currentMesh) return; if (currentMesh.material && currentMesh.material.dispose) { currentMesh.material.dispose(); } currentMesh.material = material || new THREE.MeshStandardMaterial({ color: 0xaaaacc, roughness: 0.6, metalness: 0.1, side: THREE.DoubleSide, }); requestRender(); } /** * Swap only the geometry on the current mesh, keeping material and camera. * Rebuilds wireframe if visible. Does NOT reset camera or grid. * The caller is responsible for disposing old geometry if needed. * @param {THREE.BufferGeometry} geometry */ export function setMeshGeometry(geometry) { if (!currentMesh) return; if (!geometry.attributes.normal) geometry.computeVertexNormals(); currentMesh.geometry = geometry; // Rebuild wireframe overlay to match the new geometry if (wireframeLines) { meshGroup.remove(wireframeLines); wireframeLines.geometry.dispose(); wireframeLines.material.dispose(); wireframeLines = null; } if (wireframeVisible) _buildWireframe(geometry); requestRender(); } /** * Get the grid object so callers can adjust position. */ export function getGrid() { return grid; } function fitCamera(sphere) { const sz = renderer.getSize(new THREE.Vector2()); const aspect = sz.x / sz.y; const halfH = sphere.radius * 1.4; camera.left = -halfH * aspect; camera.right = halfH * aspect; camera.top = halfH; camera.bottom = -halfH; camera.near = -sphere.radius * 200; camera.far = sphere.radius * 200; camera.zoom = 1; camera.updateProjectionMatrix(); // Isometric-ish view from front-right-above in Z-up space const dir = new THREE.Vector3(0.6, -1.2, 0.8).normalize(); controls.target.copy(sphere.center); camera.position.copy(sphere.center).addScaledVector(dir, halfH * 4); camera.up.set(0, 0, 1); camera.lookAt(sphere.center); controls.update(); } export function requestRender() { _needsRender = true; } export function getRenderer() { return renderer; } export function getCamera() { return camera; } export function getScene() { return scene; } export function getControls() { return controls; } export function getCurrentMesh() { return currentMesh; } export function setSceneBackground(hexColor) { if (scene) scene.background = new THREE.Color(hexColor); requestRender(); } export function setViewerTheme(isLight) { if (!scene) return; scene.background = new THREE.Color(isLight ? 0xf0f0f5 : 0x111114); const savedZ = grid ? grid.position.z : 0; if (grid) { scene.remove(grid); grid.geometry.dispose(); grid.material.dispose(); } grid = new THREE.GridHelper( 200, 40, isLight ? 0xb0b0c8 : 0x333340, isLight ? 0xd0d0e0 : 0x2a2a34 ); grid.rotation.x = Math.PI / 2; grid.position.z = savedZ; scene.add(grid); requestRender(); } /** * Replace (or clear) the flat orange exclusion overlay mesh. * overlayGeo must be a non-indexed BufferGeometry with a 'position' attribute, * or null / an empty geometry to clear the overlay. * The mesh lives directly in the scene so loadGeometry() (which clears * meshGroup) never accidentally removes it. * * @param {THREE.BufferGeometry|null} overlayGeo */ export function setExclusionOverlay(overlayGeo, color = 0xff6600, opacity = 1.0) { if (exclusionMesh) { scene.remove(exclusionMesh); exclusionMesh.geometry.dispose(); exclusionMesh = null; } if (!overlayGeo || overlayGeo.attributes.position.count === 0) { requestRender(); return; } if (!_exclMaterial) { _exclMaterial = new THREE.MeshLambertMaterial({ color, side: THREE.DoubleSide, transparent: opacity < 1.0, opacity, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1, }); } else { _exclMaterial.color.set(color); _exclMaterial.opacity = opacity; _exclMaterial.transparent = opacity < 1.0; } exclusionMesh = new THREE.Mesh(overlayGeo, _exclMaterial); exclusionMesh.renderOrder = 1; scene.add(exclusionMesh); requestRender(); } /** * Replace (or clear) the yellow hover-preview overlay shown before a bucket-fill * click is confirmed. Pass null or an empty geometry to clear it. * * @param {THREE.BufferGeometry|null} overlayGeo */ export function setHoverPreview(overlayGeo, color = 0xffee00) { if (hoverMesh) { scene.remove(hoverMesh); hoverMesh.geometry.dispose(); hoverMesh = null; } if (!overlayGeo || overlayGeo.attributes.position.count === 0) { requestRender(); return; } if (!_hoverMaterial) { _hoverMaterial = new THREE.MeshBasicMaterial({ color, side: THREE.DoubleSide, transparent: true, opacity: 0.45, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2, }); } else { _hoverMaterial.color.set(color); } hoverMesh = new THREE.Mesh(overlayGeo, _hoverMaterial); hoverMesh.renderOrder = 2; scene.add(hoverMesh); requestRender(); } /** * Show or hide the triangle-edge wireframe overlay. * @param {boolean} enabled */ export function setWireframe(enabled) { wireframeVisible = enabled; if (enabled) { if (!wireframeLines && currentMesh) _buildWireframe(currentMesh.geometry); if (wireframeLines) wireframeLines.visible = true; } else { if (wireframeLines) wireframeLines.visible = false; } requestRender(); } function _buildWireframe(geometry) { // Dispose any stale overlay if (wireframeLines) { if (wireframeLines.parent) wireframeLines.parent.remove(wireframeLines); wireframeLines.geometry.dispose(); wireframeLines.material.dispose(); wireframeLines = null; } // WireframeGeometry gives every triangle edge; EdgesGeometry skips edges // between near-coplanar faces so large flat STL regions lose their grid lines. const wireGeo = new THREE.WireframeGeometry(geometry); const lsGeo = new LineSegmentsGeometry(); lsGeo.setPositions(wireGeo.attributes.position.array); wireGeo.dispose(); const lsMat = new LineMaterial({ color: 0xffffff, opacity: 0.65, transparent: true, linewidth: 1.2, depthTest: true, // Pull lines slightly in front so they beat the base mesh AND the // exclusion overlay (polygonOffsetFactor -1,-1) in the depth test. polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2, resolution: new THREE.Vector2( renderer.domElement.width * renderer.getPixelRatio(), renderer.domElement.height * renderer.getPixelRatio(), ), }); wireframeLines = new LineSegments2(lsGeo, lsMat); wireframeLines.renderOrder = 3; // draw after base mesh (0), overlays (1-2) // Add to meshGroup so it's automatically removed when a new model is loaded meshGroup.add(wireframeLines); }