This commit is contained in:
gsinghpal
2026-04-13 02:35:35 -04:00
parent 1176ba68ae
commit 0ff8c0b93f
116 changed files with 14227 additions and 2406 deletions

View File

@@ -46,15 +46,13 @@ Provides:
'views/sale_order_views.xml',
'views/fp_configurator_menu.xml',
],
# 3D viewer assets temporarily disabled — causes 'registry already declared'
# error in Odoo 19 asset bundler. Needs investigation.
# 'assets': {
# 'web.assets_backend': [
# 'fusion_plating_configurator/static/src/scss/fp_3d_viewer.scss',
# 'fusion_plating_configurator/static/src/xml/fp_3d_viewer.xml',
# 'fusion_plating_configurator/static/src/js/fp_3d_viewer.js',
# ],
# },
'assets': {
'web.assets_backend': [
'fusion_plating_configurator/static/src/scss/fp_3d_viewer.scss',
'fusion_plating_configurator/static/src/xml/fp_3d_viewer.xml',
'fusion_plating_configurator/static/src/js/fp_3d_viewer.js',
],
},
'installable': True,
'application': False,
'auto_install': False,

View File

@@ -15,6 +15,55 @@ _logger = logging.getLogger(__name__)
class FpConfiguratorController(http.Controller):
@http.route('/fp/3d-viewer', type='http', auth='user', website=False)
def viewer_3d(self, **kw):
"""Serve the standalone 3D viewer HTML page.
Query params: id (attachment ID), name (filename for format detection).
The HTML page loads Online3DViewer and renders the model.
"""
from odoo.modules.module import get_module_path
import os
mod_path = get_module_path('fusion_plating_configurator')
html_path = os.path.join(
mod_path, 'static', 'src', 'html', '3d_viewer.html',
)
with open(html_path, 'r', encoding='utf-8') as f:
content = f.read()
return request.make_response(content, headers=[
('Content-Type', 'text/html; charset=utf-8'),
])
@http.route('/fp/3d-model/<int:attachment_id>/<string:filename>',
type='http', auth='user', website=False)
def serve_3d_model(self, attachment_id, filename, **kw):
"""Serve a 3D model file from ir.attachment.
This bypasses the /web/content auth issues when loading inside
an iframe. The filename in the URL ensures Online3DViewer can
detect the format from the extension.
"""
attachment = request.env['ir.attachment'].browse(attachment_id)
if not attachment.exists():
return request.not_found()
raw = base64.b64decode(attachment.datas)
# Map common CAD extensions to MIME types
mime_map = {
'.step': 'application/step', '.stp': 'application/step',
'.iges': 'application/iges', '.igs': 'application/iges',
'.stl': 'application/sla',
'.brep': 'application/octet-stream', '.brp': 'application/octet-stream',
'.obj': 'text/plain', '.gltf': 'model/gltf+json', '.glb': 'model/gltf-binary',
}
import os
ext = os.path.splitext(filename)[1].lower()
content_type = mime_map.get(ext, 'application/octet-stream')
return request.make_response(raw, headers=[
('Content-Type', content_type),
('Content-Disposition', f'inline; filename="{filename}"'),
('Content-Length', str(len(raw))),
])
@http.route('/fp/configurator/calculate_surface_area', type='jsonrpc', auth='user')
def calculate_surface_area(self, attachment_id, **kw):
"""Calculate surface area from an uploaded STL file using trimesh."""

View File

@@ -59,43 +59,190 @@ class FpPartCatalog(models.Model):
notes = fields.Html(string='Notes')
active = fields.Boolean(string='Active', default=True)
sale_order_count = fields.Integer(
string='Sale Orders', compute='_compute_sale_order_count',
)
configurator_count = fields.Integer(
string='Quotes', compute='_compute_configurator_count',
)
_sql_constraints = [
('fp_part_catalog_partner_partnum_uniq', 'unique(partner_id, part_number)',
'Part number must be unique per customer.'),
]
def _compute_sale_order_count(self):
for part in self:
part.sale_order_count = self.env['sale.order'].search_count(
[('x_fc_part_catalog_id', '=', part.id)])
def _compute_configurator_count(self):
for part in self:
part.configurator_count = self.env['fp.quote.configurator'].search_count(
[('part_catalog_id', '=', part.id)])
def action_view_sale_orders(self):
self.ensure_one()
orders = self.env['sale.order'].search([('x_fc_part_catalog_id', '=', self.id)])
if len(orders) == 1:
return {
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'res_id': orders.id,
'view_mode': 'form',
'target': 'current',
}
return {
'type': 'ir.actions.act_window',
'name': _('Sale Orders'),
'res_model': 'sale.order',
'view_mode': 'list,form',
'domain': [('x_fc_part_catalog_id', '=', self.id)],
'target': 'current',
}
def action_view_configurators(self):
self.ensure_one()
cfgs = self.env['fp.quote.configurator'].search([('part_catalog_id', '=', self.id)])
if len(cfgs) == 1:
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.quote.configurator',
'res_id': cfgs.id,
'view_mode': 'form',
'target': 'current',
}
return {
'type': 'ir.actions.act_window',
'name': _('Configurator Quotes'),
'res_model': 'fp.quote.configurator',
'view_mode': 'list,form',
'domain': [('part_catalog_id', '=', self.id)],
'target': 'current',
}
@api.onchange('model_attachment_id')
def _onchange_model_attachment_id(self):
"""Auto-calculate surface area when a 3D model is attached."""
if self.model_attachment_id:
self._compute_surface_area_from_model()
def action_calculate_surface_area(self):
"""Calculate surface area from the uploaded 3D model file."""
"""Button: calculate surface area from the uploaded 3D model file."""
self.ensure_one()
if not self.model_attachment_id:
from odoo.exceptions import UserError
raise UserError(_('No 3D model file uploaded.'))
try:
import trimesh
except ImportError:
result = self._compute_surface_area_from_model()
if result.get('error'):
from odoo.exceptions import UserError
raise UserError(_('trimesh library not installed on the server. Contact your administrator.'))
import base64
import io
raw = base64.b64decode(self.model_attachment_id.datas)
mesh = trimesh.load(io.BytesIO(raw), file_type='stl')
area_mm2 = mesh.area
area_sqin = area_mm2 / 645.16
self.surface_area = round(area_sqin, 4)
self.surface_area_uom = 'sq_in'
self.geometry_source = '3d_model'
raise UserError(result['error'])
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Surface Area Calculated'),
'message': _('%.4f sq in (%.2f mm\u00b2) from %d faces') % (area_sqin, area_mm2, len(mesh.faces)),
'message': result.get('message', 'Done'),
'type': 'success',
'sticky': False,
},
}
def _compute_surface_area_from_model(self):
"""Calculate surface area from the 3D model attachment.
Uses OCC (OpenCASCADE) for STEP/IGES/BREP files (exact B-Rep area).
Falls back to trimesh for STL files (mesh-based area).
Returns dict with result or error.
"""
self.ensure_one()
if not self.model_attachment_id:
return {'error': 'No 3D model file attached.'}
import base64
import tempfile
import os
import logging
_logger = logging.getLogger(__name__)
raw = base64.b64decode(self.model_attachment_id.datas)
fname = (self.model_attachment_id.name or '').lower()
ext = os.path.splitext(fname)[1]
area_mm2 = 0.0
volume_mm3 = 0.0
bbox_dims = None
method = 'unknown'
if ext in ('.step', '.stp', '.iges', '.igs', '.brep', '.brp'):
# OCC (OpenCASCADE) for CAD formats -- exact B-Rep area
try:
from OCP.STEPControl import STEPControl_Reader
from OCP.IGESControl import IGESControl_Reader
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp
from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp:
tmp.write(raw)
tmp_path = tmp.name
try:
if ext in ('.step', '.stp'):
reader = STEPControl_Reader()
else:
reader = IGESControl_Reader()
reader.ReadFile(tmp_path)
reader.TransferRoots()
shape = reader.OneShape()
props = GProp_GProps()
BRepGProp.SurfaceProperties_s(shape, props)
area_mm2 = props.Mass()
vol_props = GProp_GProps()
BRepGProp.VolumeProperties_s(shape, vol_props)
volume_mm3 = vol_props.Mass()
bbox = Bnd_Box()
BRepBndLib.Add_s(shape, bbox)
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
bbox_dims = (xmax - xmin, ymax - ymin, zmax - zmin)
method = 'occ_brep'
finally:
os.unlink(tmp_path)
except ImportError:
return {'error': 'OCC (cadquery) not installed. Cannot process STEP/IGES files.'}
except Exception as e:
_logger.warning('OCC surface area calculation failed: %s', e)
return {'error': f'OCC error: {e}'}
elif ext == '.stl':
# trimesh for STL files
try:
import trimesh
import io
mesh = trimesh.load(io.BytesIO(raw), file_type='stl')
area_mm2 = mesh.area
volume_mm3 = mesh.volume
bbox_dims = tuple(float(x) for x in mesh.bounding_box.extents)
method = 'trimesh_mesh'
except ImportError:
return {'error': 'trimesh not installed. Cannot process STL files.'}
except Exception as e:
_logger.warning('trimesh surface area calculation failed: %s', e)
return {'error': f'trimesh error: {e}'}
else:
return {'error': f'Unsupported file format: {ext}'}
area_sqin = area_mm2 / 645.16
self.surface_area = round(area_sqin, 4)
self.surface_area_uom = 'sq_in'
self.geometry_source = '3d_model'
msg = '%.4f sq in (%.2f mm\u00b2) via %s' % (area_sqin, area_mm2, method)
_logger.info('Part %s: surface area = %s', self.name, msg)
return {'message': msg, 'area_sqin': area_sqin, 'area_mm2': area_mm2,
'volume_mm3': volume_mm3, 'bbox': bbox_dims}

View File

@@ -35,6 +35,25 @@ class FpQuoteConfigurator(models.Model):
domain="[('partner_id', '=', partner_id)]",
help="Select from this customer's part catalog, or leave blank for a one-off.",
)
model_attachment_id = fields.Many2one(
related='part_catalog_id.model_attachment_id',
string='3D Model',
readonly=True,
)
# -- Quick file upload (creates/updates part catalog automatically) --
upload_3d_file = fields.Binary(
string='Upload 3D File',
attachment=False,
help='Upload a STEP, IGES, or STL file. Auto-creates or updates the part catalog entry.',
)
upload_3d_filename = fields.Char(string='3D Filename')
upload_drawing = fields.Binary(
string='Upload Drawing',
attachment=False,
help='Upload a PDF drawing. Attaches to the part catalog entry.',
)
upload_drawing_filename = fields.Char(string='Drawing Filename')
coating_config_id = fields.Many2one(
'fp.coating.config', string='Coating Configuration', required=True,
)
@@ -350,5 +369,126 @@ class FpQuoteConfigurator(models.Model):
'target': 'current',
}
@api.onchange('upload_3d_file')
def _onchange_upload_3d_file(self):
"""When a 3D file is uploaded, auto-create/update part catalog entry."""
if not self.upload_3d_file or not self.partner_id:
return
import base64
import os
fname = self.upload_3d_filename or 'model.step'
raw = base64.b64decode(self.upload_3d_file)
# Create attachment
att = self.env['ir.attachment'].create({
'name': fname,
'datas': self.upload_3d_file,
'mimetype': 'application/octet-stream',
})
# Auto-create or update part catalog
part_name = os.path.splitext(fname)[0].replace('_', ' ').replace('-', ' ').title()
if self.part_catalog_id:
# Update existing part
self.part_catalog_id.model_attachment_id = att.id
self.part_catalog_id._compute_surface_area_from_model()
self.surface_area = self.part_catalog_id.surface_area
self.surface_area_uom = self.part_catalog_id.surface_area_uom
else:
# Create new part catalog entry
part = self.env['fp.part.catalog'].create({
'name': part_name,
'partner_id': self.partner_id.id,
'part_number': fname,
'model_attachment_id': att.id,
})
self.part_catalog_id = part.id
# Calculate surface area
part._compute_surface_area_from_model()
self.surface_area = part.surface_area
self.surface_area_uom = part.surface_area_uom
# Clear the upload field (data is now on the part catalog)
self.upload_3d_file = False
self.upload_3d_filename = False
@api.onchange('upload_drawing')
def _onchange_upload_drawing(self):
"""When a drawing is uploaded, attach to part catalog entry."""
if not self.upload_drawing or not self.partner_id:
return
fname = self.upload_drawing_filename or 'drawing.pdf'
att = self.env['ir.attachment'].create({
'name': fname,
'datas': self.upload_drawing,
'mimetype': 'application/pdf',
})
if self.part_catalog_id:
self.part_catalog_id.drawing_attachment_ids = [(4, att.id)]
else:
import os
part_name = os.path.splitext(fname)[0].replace('_', ' ').replace('-', ' ').title()
part = self.env['fp.part.catalog'].create({
'name': part_name,
'partner_id': self.partner_id.id,
'part_number': fname,
'drawing_attachment_ids': [(4, att.id)],
})
self.part_catalog_id = part.id
self.upload_drawing = False
self.upload_drawing_filename = False
def action_recalculate_price(self):
"""Recalculate surface area from 3D model and recompute price."""
self.ensure_one()
# Recalculate surface area from part catalog's 3D model
if self.part_catalog_id and self.part_catalog_id.model_attachment_id:
result = self.part_catalog_id._compute_surface_area_from_model()
if not result.get('error'):
self.surface_area = self.part_catalog_id.surface_area
self.surface_area_uom = self.part_catalog_id.surface_area_uom
# Price recomputes automatically via _compute_price dependency
def action_cancel(self):
self.write({'state': 'cancelled'})
def action_reset_draft(self):
self.write({'state': 'draft'})
def action_open_3d_fullscreen(self):
"""Open the 3D model viewer in a new browser tab (full screen)."""
self.ensure_one()
att = self.model_attachment_id
if not att:
return
url = f'/fp/3d-viewer?id={att.id}&name={att.name}'
return {
'type': 'ir.actions.act_url',
'url': url,
'target': 'new',
}
def action_view_sale_order(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'res_id': self.sale_order_id.id,
'view_mode': 'form',
'target': 'current',
}
def action_view_part_catalog(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.part.catalog',
'res_id': self.part_catalog_id.id,
'view_mode': 'form',
'target': 'current',
}

View File

@@ -1,411 +0,0 @@
import {
BufferAttribute,
BufferGeometry,
Color,
FileLoader,
Float32BufferAttribute,
Loader,
Vector3,
SRGBColorSpace
} from 'three';
/**
* Description: A THREE loader for STL ASCII files, as created by Solidworks and other CAD programs.
*
* Supports both binary and ASCII encoded files, with automatic detection of type.
*
* The loader returns a non-indexed buffer geometry.
*
* Limitations:
* Binary decoding supports "Magics" color format (http://en.wikipedia.org/wiki/STL_(file_format)#Color_in_binary_STL).
* There is perhaps some question as to how valid it is to always assume little-endian-ness.
* ASCII decoding assumes file is UTF-8.
*
* Usage:
* const loader = new STLLoader();
* loader.load( './models/stl/slotted_disk.stl', function ( geometry ) {
* scene.add( new THREE.Mesh( geometry ) );
* });
*
* For binary STLs geometry might contain colors for vertices. To use it:
* // use the same code to load STL as above
* if (geometry.hasColors) {
* material = new THREE.MeshPhongMaterial({ opacity: geometry.alpha, vertexColors: true });
* } else { .... }
* const mesh = new THREE.Mesh( geometry, material );
*
* For ASCII STLs containing multiple solids, each solid is assigned to a different group.
* Groups can be used to assign a different color by defining an array of materials with the same length of
* geometry.groups and passing it to the Mesh constructor:
*
* const mesh = new THREE.Mesh( geometry, material );
*
* For example:
*
* const materials = [];
* const nGeometryGroups = geometry.groups.length;
*
* const colorMap = ...; // Some logic to index colors.
*
* for (let i = 0; i < nGeometryGroups; i++) {
*
* const material = new THREE.MeshPhongMaterial({
* color: colorMap[i],
* wireframe: false
* });
*
* }
*
* materials.push(material);
* const mesh = new THREE.Mesh(geometry, materials);
*/
class STLLoader extends Loader {
constructor( manager ) {
super( manager );
}
load( url, onLoad, onProgress, onError ) {
const scope = this;
const loader = new FileLoader( this.manager );
loader.setPath( this.path );
loader.setResponseType( 'arraybuffer' );
loader.setRequestHeader( this.requestHeader );
loader.setWithCredentials( this.withCredentials );
loader.load( url, function ( text ) {
try {
onLoad( scope.parse( text ) );
} catch ( e ) {
if ( onError ) {
onError( e );
} else {
console.error( e );
}
scope.manager.itemError( url );
}
}, onProgress, onError );
}
parse( data ) {
function isBinary( data ) {
const reader = new DataView( data );
const face_size = ( 32 / 8 * 3 ) + ( ( 32 / 8 * 3 ) * 3 ) + ( 16 / 8 );
const n_faces = reader.getUint32( 80, true );
const expect = 80 + ( 32 / 8 ) + ( n_faces * face_size );
if ( expect === reader.byteLength ) {
return true;
}
// An ASCII STL data must begin with 'solid ' as the first six bytes.
// However, ASCII STLs lacking the SPACE after the 'd' are known to be
// plentiful. So, check the first 5 bytes for 'solid'.
// Several encodings, such as UTF-8, precede the text with up to 5 bytes:
// https://en.wikipedia.org/wiki/Byte_order_mark#Byte_order_marks_by_encoding
// Search for "solid" to start anywhere after those prefixes.
// US-ASCII ordinal values for 's', 'o', 'l', 'i', 'd'
const solid = [ 115, 111, 108, 105, 100 ];
for ( let off = 0; off < 5; off ++ ) {
// If "solid" text is matched to the current offset, declare it to be an ASCII STL.
if ( matchDataViewAt( solid, reader, off ) ) return false;
}
// Couldn't find "solid" text at the beginning; it is binary STL.
return true;
}
function matchDataViewAt( query, reader, offset ) {
// Check if each byte in query matches the corresponding byte from the current offset
for ( let i = 0, il = query.length; i < il; i ++ ) {
if ( query[ i ] !== reader.getUint8( offset + i ) ) return false;
}
return true;
}
function parseBinary( data ) {
const reader = new DataView( data );
const faces = reader.getUint32( 80, true );
let r, g, b, hasColors = false, colors;
let defaultR, defaultG, defaultB, alpha;
// process STL header
// check for default color in header ("COLOR=rgba" sequence).
for ( let index = 0; index < 80 - 10; index ++ ) {
if ( ( reader.getUint32( index, false ) == 0x434F4C4F /*COLO*/ ) &&
( reader.getUint8( index + 4 ) == 0x52 /*'R'*/ ) &&
( reader.getUint8( index + 5 ) == 0x3D /*'='*/ ) ) {
hasColors = true;
colors = new Float32Array( faces * 3 * 3 );
defaultR = reader.getUint8( index + 6 ) / 255;
defaultG = reader.getUint8( index + 7 ) / 255;
defaultB = reader.getUint8( index + 8 ) / 255;
alpha = reader.getUint8( index + 9 ) / 255;
}
}
const dataOffset = 84;
const faceLength = 12 * 4 + 2;
const geometry = new BufferGeometry();
const vertices = new Float32Array( faces * 3 * 3 );
const normals = new Float32Array( faces * 3 * 3 );
const color = new Color();
for ( let face = 0; face < faces; face ++ ) {
const start = dataOffset + face * faceLength;
const normalX = reader.getFloat32( start, true );
const normalY = reader.getFloat32( start + 4, true );
const normalZ = reader.getFloat32( start + 8, true );
if ( hasColors ) {
const packedColor = reader.getUint16( start + 48, true );
if ( ( packedColor & 0x8000 ) === 0 ) {
// facet has its own unique color
r = ( packedColor & 0x1F ) / 31;
g = ( ( packedColor >> 5 ) & 0x1F ) / 31;
b = ( ( packedColor >> 10 ) & 0x1F ) / 31;
} else {
r = defaultR;
g = defaultG;
b = defaultB;
}
}
for ( let i = 1; i <= 3; i ++ ) {
const vertexstart = start + i * 12;
const componentIdx = ( face * 3 * 3 ) + ( ( i - 1 ) * 3 );
vertices[ componentIdx ] = reader.getFloat32( vertexstart, true );
vertices[ componentIdx + 1 ] = reader.getFloat32( vertexstart + 4, true );
vertices[ componentIdx + 2 ] = reader.getFloat32( vertexstart + 8, true );
normals[ componentIdx ] = normalX;
normals[ componentIdx + 1 ] = normalY;
normals[ componentIdx + 2 ] = normalZ;
if ( hasColors ) {
color.setRGB( r, g, b, SRGBColorSpace );
colors[ componentIdx ] = color.r;
colors[ componentIdx + 1 ] = color.g;
colors[ componentIdx + 2 ] = color.b;
}
}
}
geometry.setAttribute( 'position', new BufferAttribute( vertices, 3 ) );
geometry.setAttribute( 'normal', new BufferAttribute( normals, 3 ) );
if ( hasColors ) {
geometry.setAttribute( 'color', new BufferAttribute( colors, 3 ) );
geometry.hasColors = true;
geometry.alpha = alpha;
}
return geometry;
}
function parseASCII( data ) {
const geometry = new BufferGeometry();
const patternSolid = /solid([\s\S]*?)endsolid/g;
const patternFace = /facet([\s\S]*?)endfacet/g;
const patternName = /solid\s(.+)/;
let faceCounter = 0;
const patternFloat = /[\s]+([+-]?(?:\d*)(?:\.\d*)?(?:[eE][+-]?\d+)?)/.source;
const patternVertex = new RegExp( 'vertex' + patternFloat + patternFloat + patternFloat, 'g' );
const patternNormal = new RegExp( 'normal' + patternFloat + patternFloat + patternFloat, 'g' );
const vertices = [];
const normals = [];
const groupNames = [];
const normal = new Vector3();
let result;
let groupCount = 0;
let startVertex = 0;
let endVertex = 0;
while ( ( result = patternSolid.exec( data ) ) !== null ) {
startVertex = endVertex;
const solid = result[ 0 ];
const name = ( result = patternName.exec( solid ) ) !== null ? result[ 1 ] : '';
groupNames.push( name );
while ( ( result = patternFace.exec( solid ) ) !== null ) {
let vertexCountPerFace = 0;
let normalCountPerFace = 0;
const text = result[ 0 ];
while ( ( result = patternNormal.exec( text ) ) !== null ) {
normal.x = parseFloat( result[ 1 ] );
normal.y = parseFloat( result[ 2 ] );
normal.z = parseFloat( result[ 3 ] );
normalCountPerFace ++;
}
while ( ( result = patternVertex.exec( text ) ) !== null ) {
vertices.push( parseFloat( result[ 1 ] ), parseFloat( result[ 2 ] ), parseFloat( result[ 3 ] ) );
normals.push( normal.x, normal.y, normal.z );
vertexCountPerFace ++;
endVertex ++;
}
// every face have to own ONE valid normal
if ( normalCountPerFace !== 1 ) {
console.error( 'THREE.STLLoader: Something isn\'t right with the normal of face number ' + faceCounter );
}
// each face have to own THREE valid vertices
if ( vertexCountPerFace !== 3 ) {
console.error( 'THREE.STLLoader: Something isn\'t right with the vertices of face number ' + faceCounter );
}
faceCounter ++;
}
const start = startVertex;
const count = endVertex - startVertex;
geometry.userData.groupNames = groupNames;
geometry.addGroup( start, count, groupCount );
groupCount ++;
}
geometry.setAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) );
geometry.setAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) );
return geometry;
}
function ensureString( buffer ) {
if ( typeof buffer !== 'string' ) {
return new TextDecoder().decode( buffer );
}
return buffer;
}
function ensureBinary( buffer ) {
if ( typeof buffer === 'string' ) {
const array_buffer = new Uint8Array( buffer.length );
for ( let i = 0; i < buffer.length; i ++ ) {
array_buffer[ i ] = buffer.charCodeAt( i ) & 0xff; // implicitly assumes little-endian
}
return array_buffer.buffer || array_buffer;
} else {
return buffer;
}
}
// start
const binData = ensureBinary( data );
return isBinary( binData ) ? parseBinary( binData ) : parseASCII( ensureString( data ) );
}
}
export { STLLoader };

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@@ -0,0 +1,13 @@
importScripts ('occt-import-js.js');
onmessage = async function (ev)
{
let modulOverrides = {
locateFile: function (path) {
return path;
}
};
let occt = await occtimportjs (modulOverrides);
let result = occt.ReadFile (ev.data.format, ev.data.buffer, ev.data.params);
postMessage (result);
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>3D Part Viewer</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;font-family:system-ui,-apple-system,sans-serif;background:#f0f2f5}
#viewer-container{width:100%;height:100%}
#loading{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;color:#6c757d;z-index:100}
#loading .spinner{width:44px;height:44px;border:3px solid #dee2e6;border-top-color:#0d6efd;border-radius:50%;animation:spin .8s linear infinite;margin:0 auto 12px}
@keyframes spin{to{transform:rotate(360deg)}}
#error{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:#fff3cd;border:1px solid #ffc107;border-radius:8px;padding:20px 28px;color:#664d03;max-width:80%;text-align:center;font-size:13px;z-index:100;display:none}
#format-badge{position:absolute;top:10px;right:10px;font-size:11px;font-weight:600;padding:4px 10px;border-radius:4px;z-index:100;backdrop-filter:blur(4px)}
.fmt-step{background:rgba(33,150,243,.15);color:#1565c0}
.fmt-iges{background:rgba(156,39,176,.15);color:#7b1fa2}
.fmt-stl{background:rgba(76,175,80,.15);color:#2e7d32}
.fmt-brep{background:rgba(255,152,0,.15);color:#e65100}
.fmt-other{background:rgba(158,158,158,.15);color:#616161}
</style>
</head>
<body>
<div id="viewer-container"></div>
<div id="format-badge"></div>
<div id="loading"><div class="spinner"></div><div id="loading-msg">Loading 3D model...</div></div>
<div id="error"></div>
<script src="/fusion_plating_configurator/static/lib/o3dv/o3dv.min.js"></script>
<script>
(function() {
const container = document.getElementById('viewer-container');
const loadingEl = document.getElementById('loading');
const loadingMsg = document.getElementById('loading-msg');
const errorEl = document.getElementById('error');
const fmtBadge = document.getElementById('format-badge');
const params = new URLSearchParams(window.location.search);
const attachmentId = params.get('id');
const fileName = params.get('name') || 'model.stl';
function detectFormat(name) {
if (!name) return 'other';
const n = name.toLowerCase();
if (n.match(/\.(step|stp)$/)) return 'step';
if (n.match(/\.(iges|igs)$/)) return 'iges';
if (n.match(/\.(brep|brp)$/)) return 'brep';
if (n.match(/\.stl$/)) return 'stl';
if (n.match(/\.(obj)$/)) return 'other';
if (n.match(/\.(gltf|glb)$/)) return 'other';
if (n.match(/\.(3ds|fbx|dae|3mf|ply|off|wrl|3dm)$/)) return 'other';
return 'other';
}
function showFormat(fmt) {
fmtBadge.className = 'fmt-' + fmt;
fmtBadge.textContent = fmt.toUpperCase();
}
function showError(msg) {
loadingEl.style.display = 'none';
errorEl.textContent = msg;
errorEl.style.display = 'block';
}
if (!attachmentId) {
showError('No model specified (missing ?id= parameter)');
return;
}
showFormat(detectFormat(fileName));
// Initialize the embedded viewer
// Note: v0.18.0 loads WASM (occt-import-js) from CDN automatically
const viewer = new OV.EmbeddedViewer(container, {
backgroundColor: new OV.RGBAColor(240, 242, 245, 255),
defaultColor: new OV.RGBColor(33, 150, 243),
edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1),
});
// Fetch the file ourselves (with session credentials) then load as blob
loadingMsg.textContent = 'Downloading ' + fileName + '...';
const modelUrl = '/fp/3d-model/' + attachmentId + '/' + encodeURIComponent(fileName);
fetch(modelUrl, { credentials: 'same-origin' })
.then(function(resp) {
if (!resp.ok) throw new Error('HTTP ' + resp.status + ': ' + resp.statusText);
return resp.arrayBuffer();
})
.then(function(buffer) {
loadingMsg.textContent = 'Parsing ' + fileName + '...';
// Create a File object so O3DV can detect format from the name
var file = new File([buffer], fileName, { type: 'application/octet-stream' });
viewer.LoadModelFromFileList([file]);
// Poll for completion
var checkCount = 0;
var checkInterval = setInterval(function() {
checkCount++;
try {
var model = viewer.GetModel();
if (model && model.MeshCount() > 0) {
loadingEl.style.display = 'none';
clearInterval(checkInterval);
}
} catch(e) {}
if (checkCount > 600) {
clearInterval(checkInterval);
if (loadingEl.style.display !== 'none') {
showError('Timeout parsing model. STEP files may take a minute on first load (WASM engine init).');
}
}
}, 100);
})
.catch(function(err) {
showError('Failed to load model: ' + err.message);
});
})();
</script>
</body>
</html>

View File

@@ -1,98 +1,29 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating -- 3D STL Viewer (OWL field widget)
// Fusion Plating -- 3D CAD Viewer (iframe wrapper)
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
//
// Renders STL files using Three.js inside an OWL field widget.
// Three.js (+ STLLoader + OrbitControls) are loaded lazily on first use
// via dynamic import() with a programmatic importmap so the vendored ESM
// addon files can resolve their bare `from 'three'` specifier.
//
// Registered as field widget `fp_3d_preview` for Many2one fields
// (ir.attachment).
// =============================================================================
// Simple OWL field widget that embeds the standalone 3D viewer page
// in an iframe. The viewer page uses Online3DViewer (o3dv) which
// supports STEP, IGES, BREP, STL, OBJ, glTF, and 20+ more formats.
import { Component, useRef, onMounted, onWillUnmount, useState } from "@odoo/owl";
import { Component, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
// ---------------------------------------------------------------------------
// Three.js lazy loader
// ---------------------------------------------------------------------------
let _threePromise = null;
/**
* Inject an importmap so `from 'three'` inside STLLoader / OrbitControls
* resolves to our vendored three.module.min.js. Then dynamically import
* all three files and return the combined namespace.
*/
async function loadThreeJs() {
if (_threePromise) return _threePromise;
_threePromise = (async () => {
// Inject importmap (idempotent -- only once)
if (!document.querySelector('script[type="importmap"][data-fp-three]')) {
const map = document.createElement("script");
map.type = "importmap";
map.setAttribute("data-fp-three", "1");
map.textContent = JSON.stringify({
imports: {
three: "/fusion_plating_configurator/static/lib/three.module.min.js",
},
});
document.head.appendChild(map);
}
// Dynamic imports -- browser resolves `from 'three'` via the importmap
const THREE = await import("/fusion_plating_configurator/static/lib/three.module.min.js");
const { STLLoader } = await import("/fusion_plating_configurator/static/lib/STLLoader.js");
const { OrbitControls } = await import("/fusion_plating_configurator/static/lib/OrbitControls.js");
// Attach for convenience
THREE.STLLoader = STLLoader;
THREE.OrbitControls = OrbitControls;
return THREE;
})();
return _threePromise;
}
// ---------------------------------------------------------------------------
// OWL Component
// ---------------------------------------------------------------------------
export class Fp3dViewer extends Component {
static template = "fusion_plating_configurator.Fp3dViewer";
static props = {
...standardFieldProps,
};
static props = { ...standardFieldProps };
setup() {
this.canvasRef = useRef("canvas3d");
this.state = useState({
loading: false,
error: null,
wireframe: false,
vertexCount: 0,
faceCount: 0,
hasAttachment: false,
});
this.scene = null;
this.camera = null;
this.renderer = null;
this.controls = null;
this.mesh = null;
this.animationId = null;
onMounted(() => this._onMounted());
onWillUnmount(() => this._cleanup());
this.state = useState({ hasAttachment: false, iframeSrc: "" });
this._updateState();
}
/** Return the raw value of the Many2one field (could be [id, name] or false). */
get rawValue() {
return this.props.record.data[this.props.name];
}
/** Return the attachment id (integer) or 0. */
get attachmentId() {
const v = this.rawValue;
if (!v) return 0;
@@ -101,190 +32,28 @@ export class Fp3dViewer extends Component {
return typeof v === "number" ? v : 0;
}
async _onMounted() {
get attachmentName() {
const v = this.rawValue;
if (!v) return "";
if (Array.isArray(v)) return v[1] || "";
if (typeof v === "object" && v.display_name) return v.display_name;
return "";
}
_updateState() {
const aid = this.attachmentId;
this.state.hasAttachment = !!aid;
if (!aid || !this.canvasRef.el) return;
await this._initViewer();
}
async _initViewer() {
this.state.loading = true;
this.state.error = null;
let THREE;
try {
THREE = await loadThreeJs();
} catch (e) {
// importmap injection may fail if the page already has one -- fall
// back to loading Three.js core alone and skip addons.
this.state.error = "Three.js failed to load: " + (e.message || e);
this.state.loading = false;
return;
}
const container = this.canvasRef.el;
const width = container.clientWidth || 500;
const height = 350;
// ---- Scene ----
this.scene = new THREE.Scene();
// Respect Odoo theme -- use a neutral slightly-warm grey
this.scene.background = new THREE.Color(0xf5f5f5);
// ---- Camera ----
this.camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000);
this.camera.position.set(0, 0, 100);
// ---- Renderer ----
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
this.renderer.setPixelRatio(window.devicePixelRatio || 1);
this.renderer.setSize(width, height);
container.appendChild(this.renderer.domElement);
// ---- Lights ----
const ambient = new THREE.AmbientLight(0x808080, 1.5);
this.scene.add(ambient);
const dir1 = new THREE.DirectionalLight(0xffffff, 1.0);
dir1.position.set(1, 1, 1);
this.scene.add(dir1);
const dir2 = new THREE.DirectionalLight(0xffffff, 0.4);
dir2.position.set(-1, -0.5, -1);
this.scene.add(dir2);
// ---- Orbit controls ----
if (THREE.OrbitControls) {
this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.12;
}
// ---- Load STL ----
try {
const url = `/web/content/${this.attachmentId}`;
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const buffer = await response.arrayBuffer();
let geometry;
if (THREE.STLLoader) {
const loader = new THREE.STLLoader();
geometry = loader.parse(buffer);
} else {
// Fallback: parse binary STL manually
geometry = this._parseSTLBinary(THREE, buffer);
}
geometry.computeVertexNormals();
const material = new THREE.MeshPhongMaterial({
color: 0x1a8cff,
specular: 0x333333,
shininess: 120,
wireframe: false,
});
this.mesh = new THREE.Mesh(geometry, material);
// Centre and auto-scale to fit viewport
geometry.computeBoundingBox();
const box = geometry.boundingBox;
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 60 / (maxDim || 1);
this.mesh.geometry.translate(-center.x, -center.y, -center.z);
this.mesh.scale.set(scale, scale, scale);
this.scene.add(this.mesh);
this.state.vertexCount = geometry.attributes.position.count;
this.state.faceCount = Math.floor(geometry.attributes.position.count / 3);
this.state.loading = false;
this._animate();
} catch (e) {
this.state.error = "Failed to load STL: " + (e.message || e);
this.state.loading = false;
if (aid) {
const name = encodeURIComponent(this.attachmentName);
this.state.iframeSrc = `/fp/3d-viewer?id=${aid}&name=${name}`;
}
}
/**
* Minimal binary STL parser (fallback when STLLoader is unavailable).
* Binary STL: 80-byte header, 4-byte uint32 triangle count, then
* 50 bytes per triangle (12 floats for normal + 3 vertices, 2-byte attr).
*/
_parseSTLBinary(THREE, buffer) {
const dv = new DataView(buffer);
const triangles = dv.getUint32(80, true);
const positions = new Float32Array(triangles * 9);
const normals = new Float32Array(triangles * 9);
let offset = 84;
for (let i = 0; i < triangles; i++) {
const nx = dv.getFloat32(offset, true);
const ny = dv.getFloat32(offset + 4, true);
const nz = dv.getFloat32(offset + 8, true);
offset += 12;
for (let v = 0; v < 3; v++) {
const idx = i * 9 + v * 3;
positions[idx] = dv.getFloat32(offset, true);
positions[idx + 1] = dv.getFloat32(offset + 4, true);
positions[idx + 2] = dv.getFloat32(offset + 8, true);
normals[idx] = nx;
normals[idx + 1] = ny;
normals[idx + 2] = nz;
offset += 12;
}
offset += 2; // attribute byte count
}
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geo.setAttribute("normal", new THREE.BufferAttribute(normals, 3));
return geo;
}
_animate() {
this.animationId = requestAnimationFrame(() => this._animate());
if (this.controls) this.controls.update();
if (this.renderer && this.scene && this.camera) {
this.renderer.render(this.scene, this.camera);
}
}
toggleWireframe() {
if (!this.mesh) return;
this.state.wireframe = !this.state.wireframe;
this.mesh.material.wireframe = this.state.wireframe;
}
resetView() {
if (!this.camera) return;
this.camera.position.set(0, 0, 100);
this.camera.lookAt(0, 0, 0);
if (this.controls) this.controls.reset();
}
_cleanup() {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
if (this.controls) {
this.controls.dispose();
this.controls = null;
}
if (this.renderer) {
this.renderer.dispose();
if (this.renderer.domElement && this.renderer.domElement.parentNode) {
this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
}
this.renderer = null;
}
this.scene = null;
this.camera = null;
this.mesh = null;
onPatched() {
this._updateState();
}
}
// Register as a field widget for Many2one (ir.attachment) fields
registry.category("fields").add("fp_3d_preview", {
component: Fp3dViewer,
supportedTypes: ["many2one"],

View File

@@ -1,62 +1,63 @@
// =============================================================================
// Fusion Plating -- 3D Viewer Widget Styles
// Fusion Plating -- 3D Viewer + Configurator Layout
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
// =============================================================================
// -- Configurator two-column layout: 3/4 fields + 1/4 preview --
.o_fp_cfg_layout {
display: grid;
grid-template-columns: 1fr 320px;
gap: 16px;
align-items: start;
}
.o_fp_cfg_fields {
min-width: 0;
}
.o_fp_cfg_preview {
position: sticky;
top: 16px;
}
// Responsive: stack on narrow screens
@media (max-width: 1200px) {
.o_fp_cfg_layout {
grid-template-columns: 1fr;
}
.o_fp_cfg_preview {
position: static;
}
}
// -- 3D viewer widget --
.o_fp_3d_viewer_root {
width: 100%;
}
.o_fp_3d_placeholder {
border: 2px dashed $border-color;
border-radius: 0.375rem;
min-height: 120px;
border-radius: 0.5rem;
min-height: 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--bs-secondary-color);
background-color: var(--bs-tertiary-bg);
}
.o_fp_3d_toolbar {
.btn {
font-size: 0.8125rem;
padding: 0.2rem 0.5rem;
}
}
.o_fp_3d_canvas_container {
.o_fp_3d_iframe {
width: 100%;
height: 350px;
height: 500px;
border: 1px solid $border-color;
border-radius: 0.375rem;
overflow: hidden;
position: relative;
background-color: var(--bs-body-bg);
canvas {
display: block;
width: 100% !important;
height: 100% !important;
}
border-radius: 0.5rem;
background-color: #f0f2f5;
display: block;
}
.o_fp_3d_loading {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: var(--bs-body-bg);
color: var(--bs-body-color);
z-index: 10;
}
.o_fp_3d_error {
font-size: 0.875rem;
// Inside the preview column, make iframe taller
.o_fp_cfg_preview .o_fp_3d_iframe {
height: 600px;
}

View File

@@ -1,57 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<templates xml:space="preserve">
<t t-name="fusion_plating_configurator.Fp3dViewer">
<div class="o_fp_3d_viewer_root">
<!-- No attachment uploaded yet -->
<t t-if="!state.hasAttachment">
<div class="o_fp_3d_placeholder text-center text-muted p-4">
<i class="fa fa-cube fa-3x mb-2 d-block"/>
<span>Upload a 3D model (STL) to preview it here.</span>
<span>Upload a 3D model (STL, STEP, IGES) to preview it here.</span>
</div>
</t>
<!-- Viewer -->
<t t-if="state.hasAttachment">
<!-- Toolbar -->
<div class="o_fp_3d_toolbar d-flex align-items-center gap-2 mb-1">
<button class="btn btn-sm btn-outline-secondary" t-on-click="toggleWireframe"
title="Toggle wireframe">
<i class="fa fa-th"/> <t t-if="state.wireframe">Solid</t><t t-else="">Wireframe</t>
</button>
<button class="btn btn-sm btn-outline-secondary" t-on-click="resetView"
title="Reset camera">
<i class="fa fa-crosshairs"/> Reset
</button>
<span class="ms-auto small text-muted" t-if="state.vertexCount">
<i class="fa fa-cubes"/>
<t t-esc="state.faceCount"/> faces
/
<t t-esc="state.vertexCount"/> verts
</span>
</div>
<!-- Canvas container -->
<div t-ref="canvas3d" class="o_fp_3d_canvas_container">
<!-- Three.js renderer appends here -->
</div>
<!-- Loading spinner -->
<div t-if="state.loading" class="o_fp_3d_loading text-center p-4">
<i class="fa fa-spinner fa-spin fa-2x"/>
<div class="mt-2">Loading 3D model...</div>
</div>
<!-- Error -->
<div t-if="state.error" class="o_fp_3d_error alert alert-warning mt-2 mb-0">
<i class="fa fa-exclamation-triangle"/>
<t t-esc="state.error"/>
</div>
<iframe t-att-src="state.iframeSrc"
class="o_fp_3d_iframe"
frameborder="0"
allowfullscreen="true"/>
</t>
</div>
</t>

View File

@@ -19,8 +19,8 @@
<menuitem id="menu_fp_sales"
name="Sales"
parent="fusion_plating.menu_fp_root"
sequence="1"
groups="group_fp_estimator"/>
sequence="5"
groups="group_fp_estimator,fusion_plating.group_fusion_plating_supervisor"/>
<menuitem id="menu_fp_quotations"
name="Quotations"
@@ -50,7 +50,7 @@
<menuitem id="menu_fp_configurator"
name="Configurator"
parent="fusion_plating.menu_fp_root"
sequence="2"
sequence="8"
groups="group_fp_estimator"/>
<menuitem id="menu_fp_new_quote"

View File

@@ -30,6 +30,22 @@
<field name="arch" type="xml">
<form string="Part Catalog">
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_sale_orders"
type="object"
class="oe_stat_button"
icon="fa-file-text-o"
invisible="sale_order_count == 0">
<field name="sale_order_count" widget="statinfo" string="Sale Orders"/>
</button>
<button name="action_view_configurators"
type="object"
class="oe_stat_button"
icon="fa-sliders"
invisible="configurator_count == 0">
<field name="configurator_count" widget="statinfo" string="Quotes"/>
</button>
</div>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
<div class="oe_title">
<label for="name"/>
@@ -78,20 +94,19 @@
</page>
<page string="Attachments" name="attachments">
<group>
<field name="model_attachment_id" widget="fp_3d_preview"/>
<field name="model_attachment_id"/>
<field name="drawing_attachment_ids" widget="many2many_binary"/>
</group>
<div invisible="not model_attachment_id" class="mt-3">
<field name="model_attachment_id" widget="fp_3d_preview" nolabel="1"/>
</div>
</page>
<page string="Notes" name="notes">
<field name="notes" placeholder="Additional notes about this part..."/>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
<chatter/>
</form>
</field>
</record>

View File

@@ -19,69 +19,112 @@
class="btn-primary"
confirm="This will create a Sale Order from this configurator session. Continue?"
invisible="state != 'draft'"/>
<button name="action_recalculate_price"
string="Recalculate"
type="object"
class="btn-secondary"/>
<button name="action_cancel"
string="Cancel"
type="object"
invisible="state != 'draft'"/>
<field name="state" widget="statusbar" statusbar_visible="draft,confirmed"/>
invisible="state == 'cancelled'"/>
<button name="action_reset_draft"
string="Reset to Draft"
type="object"
invisible="state == 'draft'"/>
<field name="state" widget="statusbar" statusbar_visible="draft,confirmed,cancelled"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_sale_order"
type="object"
class="oe_stat_button"
icon="fa-file-text-o"
invisible="not sale_order_id">
<field name="sale_order_id" widget="statinfo" string="Sale Order"/>
</button>
<button name="action_view_part_catalog"
type="object"
class="oe_stat_button"
icon="fa-cube"
invisible="not part_catalog_id">
<field name="part_catalog_id" widget="statinfo" string="Part"/>
</button>
</div>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<!-- Customer + Part / Coating + Quantity -->
<group>
<group string="Customer &amp; Part">
<field name="partner_id"/>
<field name="part_catalog_id"/>
</group>
<group string="Coating &amp; Quantity">
<field name="coating_config_id"/>
<field name="quantity"/>
<field name="batch_size"/>
</group>
</group>
<!-- Geometry / Options -->
<group>
<group string="Geometry">
<field name="surface_area"/>
<field name="surface_area_uom"/>
<field name="thickness_requested"/>
<field name="substrate_material"/>
</group>
<group string="Options">
<field name="complexity"/>
<field name="masking_zones"/>
<field name="rush_order"/>
<field name="turnaround_days"/>
</group>
</group>
<!-- Delivery / Fees -->
<group>
<group string="Delivery &amp; Fees">
<field name="delivery_method"/>
<field name="shipping_fee"/>
<field name="delivery_fee"/>
</group>
<group>
<field name="currency_id" invisible="1"/>
</group>
</group>
<separator string="Pricing"/>
<group>
<group>
<field name="calculated_price" widget="monetary" readonly="1"
class="fw-bold fs-4"/>
</group>
<group>
<field name="estimator_override_price" widget="monetary"/>
</group>
</group>
<group>
<field name="price_breakdown_html" readonly="1" nolabel="1" colspan="2"/>
</group>
<!-- Main layout: 3/4 fields (left) + 1/4 3D preview (right) -->
<div class="o_fp_cfg_layout">
<!-- LEFT COLUMN: all fields -->
<div class="o_fp_cfg_fields">
<group>
<group string="Customer &amp; Part">
<field name="partner_id"/>
<field name="part_catalog_id"/>
<field name="coating_config_id"/>
<field name="upload_3d_file" filename="upload_3d_filename"
invisible="state != 'draft'"
string="Attach 3D File"/>
<field name="upload_3d_filename" invisible="1"/>
<field name="upload_drawing" filename="upload_drawing_filename"
invisible="state != 'draft'"
string="Attach Drawing"/>
<field name="upload_drawing_filename" invisible="1"/>
</group>
<group string="Quantity &amp; Options">
<field name="quantity"/>
<field name="batch_size"/>
<field name="complexity"/>
<field name="rush_order"/>
</group>
</group>
<group>
<group string="Geometry">
<field name="surface_area"/>
<field name="surface_area_uom"/>
<field name="thickness_requested"/>
<field name="substrate_material"/>
<field name="masking_zones"/>
<field name="turnaround_days"/>
</group>
<group string="Delivery &amp; Fees">
<field name="delivery_method"/>
<field name="shipping_fee"/>
<field name="delivery_fee"/>
<field name="currency_id" invisible="1"/>
</group>
</group>
<separator string="Pricing"/>
<group>
<group>
<field name="calculated_price" widget="monetary" readonly="1"
class="fw-bold fs-4"/>
</group>
<group>
<field name="estimator_override_price" widget="monetary"/>
</group>
</group>
<group>
<field name="price_breakdown_html" readonly="1" nolabel="1" colspan="2"/>
</group>
</div>
<!-- RIGHT COLUMN: 3D preview (sticky) -->
<div class="o_fp_cfg_preview" invisible="not model_attachment_id">
<field name="model_attachment_id" widget="fp_3d_preview" nolabel="1"/>
<div class="text-center mt-2">
<button name="action_open_3d_fullscreen"
string="Full Screen"
type="object"
class="btn btn-sm btn-outline-primary"
icon="fa-expand"/>
</div>
</div>
</div>
<notebook>
<page string="Sale Order" name="sale_order">
<group>
@@ -93,10 +136,7 @@
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="message_ids"/>
</div>
<chatter/>
</form>
</field>
</record>
@@ -155,7 +195,7 @@
<field name="res_model">fp.quote.configurator</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_quote_configurator_search"/>
<field name="context">{'search_default_draft': 1}</field>
<field name="context">{}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new quote configurator session