Add interactive 3D model viewer with STEP to STL conversion
- Install and integrate CadQuery for STEP to STL conversion on server - Add convert_step_to_stl() function using CadQuery OCP bindings - Implement Three.js-based interactive 3D viewer with OrbitControls - Add STLLoader for loading converted models - Auto-center and scale models to fit viewer - Add lighting (ambient + 2 directional lights) for better visualization - Enable mouse controls: rotate (left-click drag), zoom (scroll), pan (right-click drag) - Add debug logging throughout conversion pipeline - Display converted STL models in real-time when footprint is selected Dependencies added: cadquery, cadquery-ocp, zstandard 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
99
app.py
99
app.py
@@ -173,6 +173,64 @@ def get_footprints_in_library(library_path):
|
|||||||
|
|
||||||
return sorted(footprints)
|
return sorted(footprints)
|
||||||
|
|
||||||
|
def convert_step_to_stl(step_data_bytes):
|
||||||
|
"""Convert STEP file data to STL format using CadQuery.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
step_data_bytes: Raw STEP file data as bytes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
STL file data as bytes, or None if conversion fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import cadquery as cq
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
# Write STEP data to a temporary file
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.step', delete=False, mode='wb') as temp_step:
|
||||||
|
temp_step.write(step_data_bytes)
|
||||||
|
temp_step_path = temp_step.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Import the STEP file
|
||||||
|
print(f"[3D Model] Importing STEP file...")
|
||||||
|
result = cq.importers.importStep(temp_step_path)
|
||||||
|
|
||||||
|
# Export to STL
|
||||||
|
print(f"[3D Model] Converting to STL...")
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.stl', delete=False, mode='wb') as temp_stl:
|
||||||
|
temp_stl_path = temp_stl.name
|
||||||
|
|
||||||
|
# Export the shape to STL
|
||||||
|
cq.exporters.export(result, temp_stl_path, exportType='STL')
|
||||||
|
|
||||||
|
# Read the STL file
|
||||||
|
with open(temp_stl_path, 'rb') as f:
|
||||||
|
stl_data = f.read()
|
||||||
|
|
||||||
|
print(f"[3D Model] Conversion successful, STL size: {len(stl_data)} bytes")
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
os.unlink(temp_step_path)
|
||||||
|
os.unlink(temp_stl_path)
|
||||||
|
|
||||||
|
return stl_data
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[3D Model] Error during conversion: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
# Cleanup on error
|
||||||
|
if os.path.exists(temp_step_path):
|
||||||
|
os.unlink(temp_step_path)
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[3D Model] Error setting up conversion: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return None
|
||||||
|
|
||||||
def extract_embedded_model(footprint_path, footprint_name):
|
def extract_embedded_model(footprint_path, footprint_name):
|
||||||
"""Extract embedded 3D model from a KiCad footprint file.
|
"""Extract embedded 3D model from a KiCad footprint file.
|
||||||
|
|
||||||
@@ -2140,18 +2198,25 @@ def handle_get_footprint_model(data):
|
|||||||
library_path = data.get('library_path', '')
|
library_path = data.get('library_path', '')
|
||||||
footprint_name = data.get('footprint_name', '')
|
footprint_name = data.get('footprint_name', '')
|
||||||
|
|
||||||
|
print(f"[3D Model] Request for footprint: {footprint_name}")
|
||||||
|
print(f"[3D Model] Library path: {library_path}")
|
||||||
|
|
||||||
if not library_path or not footprint_name:
|
if not library_path or not footprint_name:
|
||||||
|
print(f"[3D Model] Missing library_path or footprint_name")
|
||||||
emit('model_result', {'has_model': False})
|
emit('model_result', {'has_model': False})
|
||||||
return
|
return
|
||||||
|
|
||||||
if not os.path.exists(library_path):
|
if not os.path.exists(library_path):
|
||||||
|
print(f"[3D Model] Library path does not exist: {library_path}")
|
||||||
emit('model_result', {'has_model': False})
|
emit('model_result', {'has_model': False})
|
||||||
return
|
return
|
||||||
|
|
||||||
# Extract embedded model
|
# Extract embedded model
|
||||||
|
print(f"[3D Model] Extracting model from {library_path}/{footprint_name}")
|
||||||
model_data = extract_embedded_model(library_path, footprint_name)
|
model_data = extract_embedded_model(library_path, footprint_name)
|
||||||
|
|
||||||
if model_data:
|
if model_data:
|
||||||
|
print(f"[3D Model] Found model: {model_data['name']}")
|
||||||
# Decode base64 to get the actual file data
|
# Decode base64 to get the actual file data
|
||||||
import base64
|
import base64
|
||||||
import zstandard as zstd
|
import zstandard as zstd
|
||||||
@@ -2159,25 +2224,37 @@ def handle_get_footprint_model(data):
|
|||||||
try:
|
try:
|
||||||
# Decode base64
|
# Decode base64
|
||||||
compressed_data = base64.b64decode(model_data['data'])
|
compressed_data = base64.b64decode(model_data['data'])
|
||||||
|
print(f"[3D Model] Decoded base64, compressed size: {len(compressed_data)}")
|
||||||
# Decompress (KiCad uses zstd compression)
|
# Decompress (KiCad uses zstd compression)
|
||||||
dctx = zstd.ZstdDecompressor()
|
dctx = zstd.ZstdDecompressor()
|
||||||
decompressed_data = dctx.decompress(compressed_data)
|
step_data = dctx.decompress(compressed_data)
|
||||||
# Re-encode as base64 for transmission
|
print(f"[3D Model] Decompressed STEP, size: {len(step_data)}")
|
||||||
model_base64 = base64.b64encode(decompressed_data).decode('ascii')
|
|
||||||
|
|
||||||
emit('model_result', {
|
# Convert STEP to STL
|
||||||
'has_model': True,
|
stl_data = convert_step_to_stl(step_data)
|
||||||
'name': model_data['name'],
|
|
||||||
'type': model_data['type'],
|
if stl_data:
|
||||||
'data': model_base64,
|
# Encode STL as base64 for transmission
|
||||||
'format': 'step' # Assuming STEP format
|
stl_base64 = base64.b64encode(stl_data).decode('ascii')
|
||||||
})
|
print(f"[3D Model] Success! Sending STL model to client")
|
||||||
|
|
||||||
|
emit('model_result', {
|
||||||
|
'has_model': True,
|
||||||
|
'name': model_data['name'].replace('.stp', '.stl').replace('.step', '.stl'),
|
||||||
|
'type': model_data['type'],
|
||||||
|
'data': stl_base64,
|
||||||
|
'format': 'stl'
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
print(f"[3D Model] Failed to convert STEP to STL")
|
||||||
|
emit('model_result', {'has_model': False})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error decoding model data: {e}")
|
print(f"[3D Model] Error processing model data: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
emit('model_result', {'has_model': False})
|
emit('model_result', {'has_model': False})
|
||||||
else:
|
else:
|
||||||
|
print(f"[3D Model] No embedded model found")
|
||||||
emit('model_result', {'has_model': False})
|
emit('model_result', {'has_model': False})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
|
<script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/three@0.159.0/build/three.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/three@0.159.0/build/three.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/three@0.159.0/examples/js/controls/OrbitControls.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/three@0.159.0/examples/js/controls/OrbitControls.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/three@0.159.0/examples/js/loaders/STLLoader.js"></script>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
@@ -1302,6 +1303,101 @@
|
|||||||
selectedFootprint = `${libName}:${data.footprint_name}`;
|
selectedFootprint = `${libName}:${data.footprint_name}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let modelScene = null;
|
||||||
|
let modelRenderer = null;
|
||||||
|
let modelCamera = null;
|
||||||
|
let modelControls = null;
|
||||||
|
let modelMesh = null;
|
||||||
|
|
||||||
|
function init3DViewer() {
|
||||||
|
const canvasContainer = document.getElementById('modelViewerCanvas');
|
||||||
|
const width = canvasContainer.clientWidth || 800;
|
||||||
|
const height = 300;
|
||||||
|
|
||||||
|
// Scene
|
||||||
|
modelScene = new THREE.Scene();
|
||||||
|
modelScene.background = new THREE.Color(0x1a1a1a);
|
||||||
|
|
||||||
|
// Camera
|
||||||
|
modelCamera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
|
||||||
|
modelCamera.position.set(0, 0, 50);
|
||||||
|
|
||||||
|
// Renderer
|
||||||
|
modelRenderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
|
modelRenderer.setSize(width, height);
|
||||||
|
canvasContainer.innerHTML = '';
|
||||||
|
canvasContainer.appendChild(modelRenderer.domElement);
|
||||||
|
|
||||||
|
// Lights
|
||||||
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
|
||||||
|
modelScene.add(ambientLight);
|
||||||
|
|
||||||
|
const directionalLight1 = new THREE.DirectionalLight(0xffffff, 0.5);
|
||||||
|
directionalLight1.position.set(1, 1, 1);
|
||||||
|
modelScene.add(directionalLight1);
|
||||||
|
|
||||||
|
const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.3);
|
||||||
|
directionalLight2.position.set(-1, -1, -1);
|
||||||
|
modelScene.add(directionalLight2);
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
modelControls = new THREE.OrbitControls(modelCamera, modelRenderer.domElement);
|
||||||
|
modelControls.enableDamping = true;
|
||||||
|
modelControls.dampingFactor = 0.05;
|
||||||
|
|
||||||
|
// Animation loop
|
||||||
|
function animate() {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
modelControls.update();
|
||||||
|
modelRenderer.render(modelScene, modelCamera);
|
||||||
|
}
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function load3DModel(stlData) {
|
||||||
|
if (!modelScene) {
|
||||||
|
init3DViewer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove old mesh if exists
|
||||||
|
if (modelMesh) {
|
||||||
|
modelScene.remove(modelMesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode base64 STL data
|
||||||
|
const binaryString = atob(stlData);
|
||||||
|
const bytes = new Uint8Array(binaryString.length);
|
||||||
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load STL
|
||||||
|
const loader = new THREE.STLLoader();
|
||||||
|
const geometry = loader.parse(bytes.buffer);
|
||||||
|
|
||||||
|
// Center and scale the geometry
|
||||||
|
geometry.center();
|
||||||
|
geometry.computeBoundingBox();
|
||||||
|
const size = new THREE.Vector3();
|
||||||
|
geometry.boundingBox.getSize(size);
|
||||||
|
const maxDim = Math.max(size.x, size.y, size.z);
|
||||||
|
const scale = 30 / maxDim;
|
||||||
|
geometry.scale(scale, scale, scale);
|
||||||
|
|
||||||
|
// Create mesh
|
||||||
|
const material = new THREE.MeshPhongMaterial({
|
||||||
|
color: 0x00aa00,
|
||||||
|
specular: 0x111111,
|
||||||
|
shininess: 50
|
||||||
|
});
|
||||||
|
modelMesh = new THREE.Mesh(geometry, material);
|
||||||
|
modelScene.add(modelMesh);
|
||||||
|
|
||||||
|
// Adjust camera
|
||||||
|
modelCamera.position.set(0, 0, 50);
|
||||||
|
modelControls.reset();
|
||||||
|
}
|
||||||
|
|
||||||
socket.on('model_result', (data) => {
|
socket.on('model_result', (data) => {
|
||||||
console.log('Received model_result:', data);
|
console.log('Received model_result:', data);
|
||||||
const container = document.getElementById('modelViewerContainer');
|
const container = document.getElementById('modelViewerContainer');
|
||||||
@@ -1310,11 +1406,16 @@
|
|||||||
if (data.has_model) {
|
if (data.has_model) {
|
||||||
console.log('Model found, displaying viewer');
|
console.log('Model found, displaying viewer');
|
||||||
container.style.display = 'block';
|
container.style.display = 'block';
|
||||||
messageEl.innerHTML = `<strong>3D Model Found:</strong> ${data.name}<br><br>` +
|
messageEl.style.display = 'none';
|
||||||
`<em>Note: STEP file viewing in browser requires conversion to STL/OBJ format.<br>` +
|
|
||||||
`This feature is under development.</em><br><br>` +
|
// Load and display the 3D model
|
||||||
`<a href="data:application/stp;base64,${data.data}" download="${data.name}" ` +
|
try {
|
||||||
`style="color: #007bff; text-decoration: underline; cursor: pointer;">Download ${data.name}</a>`;
|
load3DModel(data.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading 3D model:', error);
|
||||||
|
messageEl.style.display = 'block';
|
||||||
|
messageEl.innerHTML = `<span style="color: #dc3545;">Error loading 3D model: ${error.message}</span>`;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('No model found');
|
console.log('No model found');
|
||||||
container.style.display = 'none';
|
container.style.display = 'none';
|
||||||
|
|||||||
Reference in New Issue
Block a user