diff --git a/fusion-plating/fusion_plating_configurator/__manifest__.py b/fusion-plating/fusion_plating_configurator/__manifest__.py index c78adbed..321ec1e2 100644 --- a/fusion-plating/fusion_plating_configurator/__manifest__.py +++ b/fusion-plating/fusion_plating_configurator/__manifest__.py @@ -32,6 +32,7 @@ Provides: 'depends': [ 'fusion_plating', 'sale_management', + 'fusion_pdf_preview', ], 'data': [ 'security/fp_configurator_security.xml', @@ -52,6 +53,10 @@ Provides: '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', + 'fusion_plating_configurator/static/src/xml/fp_drawing_preview.xml', + 'fusion_plating_configurator/static/src/js/fp_drawing_preview.js', + 'fusion_plating_configurator/static/src/xml/fp_pdf_inline_preview.xml', + 'fusion_plating_configurator/static/src/js/fp_pdf_inline_preview.js', ], }, 'installable': True, diff --git a/fusion-plating/fusion_plating_configurator/models/fp_part_catalog.py b/fusion-plating/fusion_plating_configurator/models/fp_part_catalog.py index 5d6e1a1d..6a34b2b0 100644 --- a/fusion-plating/fusion_plating_configurator/models/fp_part_catalog.py +++ b/fusion-plating/fusion_plating_configurator/models/fp_part_catalog.py @@ -46,7 +46,10 @@ class FpPartCatalog(models.Model): [('3d_model', '3D Model'), ('manual', 'Manual Measurements'), ('pdf_drawing', 'PDF Drawing')], string='Geometry Source', default='manual', ) - model_attachment_id = fields.Many2one('ir.attachment', string='3D Model File', help='STEP, STL, or IGES file.') + model_attachment_id = fields.Many2one( + 'ir.attachment', string='3D Model File', + help='STEP, STL, or IGES file.', tracking=True, + ) drawing_attachment_ids = fields.Many2many( 'ir.attachment', 'fp_part_catalog_drawing_rel', 'part_catalog_id', 'attachment_id', string='PDF Drawings', ) @@ -68,9 +71,104 @@ class FpPartCatalog(models.Model): has_blind_holes = fields.Boolean(string='Has Blind Holes') has_recesses = fields.Boolean(string='Has Recesses') has_threads = fields.Boolean(string='Has Threads') + + # ---- Auto-extracted from 3D model (OCC-computed) ---- + volume_mm3 = fields.Float( + string='Volume (mm³)', digits=(16, 2), + help='Auto-calculated from 3D model.', + ) + bbox_length_mm = fields.Float(string='BBox Length (mm)', digits=(12, 2)) + bbox_width_mm = fields.Float(string='BBox Width (mm)', digits=(12, 2)) + bbox_height_mm = fields.Float(string='BBox Height (mm)', digits=(12, 2)) + bbox_summary_in = fields.Char( + string='Dimensions (in)', + compute='_compute_bbox_summary_in', + store=True, + help='Bounding box L × W × H in inches.', + ) + material_weight_kg = fields.Float( + string='Material Weight (kg)', digits=(12, 4), + compute='_compute_material_weight', + store=True, + help='volume × substrate density.', + ) + is_manifold = fields.Boolean( + string='Watertight (Manifold)', + help='False indicates open/broken geometry — review before quoting.', + ) + hole_count = fields.Integer( + string='Cylindrical Features', + help='Total cylindrical surfaces detected (includes holes, bores, ' + 'and rounded fillets — see breakdown for diameter clustering).', + ) + hole_summary = fields.Char( + string='Feature Diameters', + help='Cylindrical features grouped by diameter (rounded to 0.5mm). ' + 'Small diameters are usually edge fillets; larger ones are typically holes.', + ) + masking_area_sqin = fields.Float( + string='Masking Area (sq in)', digits=(12, 4), + help='Total area excluded from plating (masked surfaces).', + ) + effective_area_sqin = fields.Float( + string='Effective Plating Area (sq in)', digits=(12, 4), + compute='_compute_effective_area', + store=True, + help='Surface area minus masked area — used for per-sq-in pricing.', + ) + notes = fields.Html(string='Notes') active = fields.Boolean(string='Active', default=True) + # Substrate density mapping (g/cm³) for material weight calculation + _SUBSTRATE_DENSITY = { + 'aluminium': 2.70, + 'steel': 7.85, + 'stainless': 8.00, + 'copper': 8.96, + 'titanium': 4.51, + 'other': 7.85, # default to steel + } + + @api.depends('volume_mm3', 'substrate_material') + def _compute_material_weight(self): + for rec in self: + if not rec.volume_mm3 or not rec.substrate_material: + rec.material_weight_kg = 0.0 + continue + density = self._SUBSTRATE_DENSITY.get(rec.substrate_material, 7.85) + # mm³ × g/cm³ × 1e-6 = kg + rec.material_weight_kg = round(rec.volume_mm3 * density * 1e-6, 4) + + @api.depends('bbox_length_mm', 'bbox_width_mm', 'bbox_height_mm') + def _compute_bbox_summary_in(self): + for rec in self: + if not (rec.bbox_length_mm or rec.bbox_width_mm or rec.bbox_height_mm): + rec.bbox_summary_in = False + continue + # mm to inches + l = rec.bbox_length_mm / 25.4 + w = rec.bbox_width_mm / 25.4 + h = rec.bbox_height_mm / 25.4 + rec.bbox_summary_in = '%.2f × %.2f × %.2f in' % (l, w, h) + + @api.depends('surface_area', 'surface_area_uom', 'masking_area_sqin') + def _compute_effective_area(self): + for rec in self: + # Convert surface_area to sq in + uom = rec.surface_area_uom or 'sq_in' + if uom == 'sq_in': + area_sqin = rec.surface_area + elif uom == 'sq_ft': + area_sqin = rec.surface_area * 144.0 + elif uom == 'sq_cm': + area_sqin = rec.surface_area / 6.4516 + elif uom == 'sq_m': + area_sqin = rec.surface_area * 1550.0 + else: + area_sqin = rec.surface_area + rec.effective_area_sqin = max(0.0, area_sqin - (rec.masking_area_sqin or 0.0)) + sale_order_count = fields.Integer( string='Sale Orders', compute='_compute_sale_order_count', ) @@ -83,6 +181,77 @@ class FpPartCatalog(models.Model): 'Part number must be unique per customer.'), ] + def write(self, vals): + """Track changes to attachments and propagate to linked configurators.""" + # Snapshot before write + snapshots = {} + track_3d = 'model_attachment_id' in vals + track_drawings = 'drawing_attachment_ids' in vals + if track_3d or track_drawings: + for rec in self: + snapshots[rec.id] = { + 'model': rec.model_attachment_id, + 'drawings': set(rec.drawing_attachment_ids.ids), + } + + result = super().write(vals) + + # Log changes after write + for rec in self: + snap = snapshots.get(rec.id) + if not snap: + continue + + messages = [] + + # 3D model change + if track_3d: + old = snap['model'] + new = rec.model_attachment_id + if not old and new: + messages.append(_('3D model attached: %s') % new.name) + elif old and not new: + messages.append(_('3D model removed: %s') % old.name) + elif old and new and old.id != new.id: + messages.append(_('3D model changed: %s → %s') % (old.name, new.name)) + + # Drawing changes (added or removed) + if track_drawings: + old_ids = snap['drawings'] + new_ids = set(rec.drawing_attachment_ids.ids) + added = new_ids - old_ids + removed = old_ids - new_ids + for att_id in added: + att = self.env['ir.attachment'].browse(att_id) + if att.exists(): + messages.append(_('Drawing attached: %s') % att.name) + for att_id in removed: + att = self.env['ir.attachment'].browse(att_id) + # Browse even if deleted — may still have name if not purged + name = att.exists() and att.name or f'#{att_id}' + messages.append(_('Drawing removed: %s') % name) + + if messages: + body = '
'.join(messages) + # Post to part catalog chatter + rec.message_post( + body=body, + message_type='notification', + subtype_xmlid='mail.mt_note', + ) + # Propagate to linked configurator quotes (draft + confirmed) + configurators = self.env['fp.quote.configurator'].search([ + ('part_catalog_id', '=', rec.id), + ]) + for cfg in configurators: + cfg.message_post( + body=_('Part %s: %s') % (rec.name, body), + message_type='notification', + subtype_xmlid='mail.mt_note', + ) + + return result + def _compute_sale_order_count(self): for part in self: part.sale_order_count = self.env['sale.order'].search_count( @@ -215,6 +384,10 @@ class FpPartCatalog(models.Model): bbox_dims = None method = 'unknown' + is_manifold = None + hole_count = 0 + hole_summary = '' + if ext in ('.step', '.stp', '.iges', '.igs', '.brep', '.brp'): # OCC (OpenCASCADE) for CAD formats -- exact B-Rep area try: @@ -224,6 +397,12 @@ class FpPartCatalog(models.Model): from OCP.BRepGProp import BRepGProp from OCP.Bnd import Bnd_Box from OCP.BRepBndLib import BRepBndLib + from OCP.BRepCheck import BRepCheck_Analyzer + from OCP.TopExp import TopExp_Explorer + from OCP.TopAbs import TopAbs_FACE + from OCP.TopoDS import TopoDS + from OCP.BRepAdaptor import BRepAdaptor_Surface + from OCP.GeomAbs import GeomAbs_Cylinder with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp: tmp.write(raw) @@ -238,18 +417,59 @@ class FpPartCatalog(models.Model): reader.TransferRoots() shape = reader.OneShape() + # Surface area (B-Rep exact) props = GProp_GProps() BRepGProp.SurfaceProperties_s(shape, props) area_mm2 = props.Mass() + # Volume vol_props = GProp_GProps() BRepGProp.VolumeProperties_s(shape, vol_props) volume_mm3 = vol_props.Mass() + # Bounding box bbox = Bnd_Box() BRepBndLib.Add_s(shape, bbox) xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get() bbox_dims = (xmax - xmin, ymax - ymin, zmax - zmin) + + # Manifold check (watertight) + try: + analyzer = BRepCheck_Analyzer(shape) + is_manifold = bool(analyzer.IsValid()) + except Exception: + is_manifold = None + + # Cylindrical feature detection — counts holes, fillets, bores. + # NOTE: fillets/rounded edges also appear as cylinders. We + # cluster by diameter (rounded to 0.5 mm) and report the + # breakdown so the estimator can identify real holes vs fillets. + try: + diameters = [] + explorer = TopExp_Explorer(shape, TopAbs_FACE) + while explorer.More(): + try: + face = TopoDS.Face_s(explorer.Current()) + surf = BRepAdaptor_Surface(face) + if surf.GetType() == GeomAbs_Cylinder: + cyl = surf.Cylinder() + diameter_mm = cyl.Radius() * 2.0 + # Round to 0.5 mm clusters (fillets ~0.5-3mm, holes ~3mm+) + diameters.append(round(diameter_mm * 2) / 2) + except Exception: + pass + explorer.Next() + if diameters: + from collections import Counter + counts = Counter(diameters) + hole_count = sum(counts.values()) + # Sort by diameter ascending, format + parts = ['%d× Ø%.1fmm' % (n, d) + for d, n in sorted(counts.items())] + hole_summary = ', '.join(parts) + except Exception as he: + _logger.warning('Cylindrical feature detection failed: %s', he) + method = 'occ_brep' finally: os.unlink(tmp_path) @@ -283,7 +503,28 @@ class FpPartCatalog(models.Model): self.surface_area_uom = 'sq_in' self.geometry_source = '3d_model' + # Store extracted geometry on part catalog (triggers computed fields) + self.volume_mm3 = round(volume_mm3, 2) + if bbox_dims: + self.bbox_length_mm = round(bbox_dims[0], 2) + self.bbox_width_mm = round(bbox_dims[1], 2) + self.bbox_height_mm = round(bbox_dims[2], 2) + if is_manifold is not None: + self.is_manifold = is_manifold + self.hole_count = hole_count + self.hole_summary = hole_summary + 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} + if hole_count: + msg += ' | %d holes' % hole_count + _logger.info('Part %s: %s', self.name, msg) + return { + 'message': msg, + 'area_sqin': area_sqin, + 'area_mm2': area_mm2, + 'volume_mm3': volume_mm3, + 'bbox': bbox_dims, + 'is_manifold': is_manifold, + 'hole_count': hole_count, + 'hole_summary': hole_summary, + } diff --git a/fusion-plating/fusion_plating_configurator/models/fp_quote_configurator.py b/fusion-plating/fusion_plating_configurator/models/fp_quote_configurator.py index 2e847ad6..e03dbdd9 100644 --- a/fusion-plating/fusion_plating_configurator/models/fp_quote_configurator.py +++ b/fusion-plating/fusion_plating_configurator/models/fp_quote_configurator.py @@ -40,6 +40,88 @@ class FpQuoteConfigurator(models.Model): string='3D Model', readonly=True, ) + drawing_attachment_ids = fields.Many2many( + related='part_catalog_id.drawing_attachment_ids', + string='Drawings', + readonly=True, + ) + # -- Auto-extracted geometry from part catalog (read-only on configurator) -- + bbox_summary_in = fields.Char( + related='part_catalog_id.bbox_summary_in', string='Dimensions (in)', + readonly=True, + ) + material_weight_kg = fields.Float( + related='part_catalog_id.material_weight_kg', string='Weight (kg)', + readonly=True, + ) + hole_count = fields.Integer( + related='part_catalog_id.hole_count', string='Holes', + readonly=True, + ) + hole_summary = fields.Char( + related='part_catalog_id.hole_summary', string='Hole Summary', + readonly=True, + ) + is_manifold = fields.Boolean( + related='part_catalog_id.is_manifold', string='Watertight', + readonly=True, + ) + masking_area_sqin = fields.Float( + related='part_catalog_id.masking_area_sqin', string='Masking Area (sq in)', + readonly=False, # allow editing via configurator + ) + effective_area_sqin = fields.Float( + related='part_catalog_id.effective_area_sqin', string='Effective Area (sq in)', + readonly=True, + ) + drawing_count = fields.Integer( + string='Drawings', + compute='_compute_drawing_count', + ) + first_drawing_id = fields.Many2one( + 'ir.attachment', string='First Drawing', + compute='_compute_first_drawing', + inverse='_inverse_first_drawing', + ) + + @api.depends('part_catalog_id.drawing_attachment_ids') + def _compute_drawing_count(self): + for rec in self: + rec.drawing_count = len(rec.part_catalog_id.drawing_attachment_ids) if rec.part_catalog_id else 0 + + @api.depends('part_catalog_id.drawing_attachment_ids') + def _compute_first_drawing(self): + for rec in self: + atts = rec.part_catalog_id.drawing_attachment_ids if rec.part_catalog_id else False + rec.first_drawing_id = atts[0] if atts else False + + def _inverse_first_drawing(self): + """When user clears or replaces the first drawing in the configurator, + propagate that change to the part catalog's drawing list.""" + for rec in self: + if not rec.part_catalog_id: + continue + atts = rec.part_catalog_id.drawing_attachment_ids + current_first = atts[0] if atts else False + new_first = rec.first_drawing_id + # Cleared + if current_first and not new_first: + rec.part_catalog_id.sudo().write({ + 'drawing_attachment_ids': [(3, current_first.id)], + }) + # Replaced + elif new_first and current_first and new_first.id != current_first.id: + rec.part_catalog_id.sudo().write({ + 'drawing_attachment_ids': [ + (3, current_first.id), (4, new_first.id), + ], + }) + # Added (no current first, new value set) + elif new_first and not current_first: + rec.part_catalog_id.sudo().write({ + 'drawing_attachment_ids': [(4, new_first.id)], + }) + # -- Quick file upload (creates/updates part catalog automatically) -- upload_3d_file = fields.Binary( string='Upload 3D File', @@ -54,6 +136,27 @@ class FpQuoteConfigurator(models.Model): ) upload_drawing_filename = fields.Char(string='Drawing Filename') + # -- RFQ / PO document tracking (from the beginning of the quote) -- + rfq_attachment_id = fields.Many2one( + 'ir.attachment', string='RFQ Document', copy=False, tracking=True, + help="Customer's original Request for Quote document (PDF). " + "Transferred to the sale order on quotation.", + ) + po_attachment_id = fields.Many2one( + 'ir.attachment', string='Customer PO', copy=False, tracking=True, + help='Customer PO document if already received. ' + 'Transferred to the sale order on quotation.', + ) + po_number_preliminary = fields.Char( + string='PO Number', copy=False, tracking=True, + help='Customer PO number if already known. ' + 'Transferred to the sale order on quotation.', + ) + upload_rfq_file = fields.Binary(string='Upload RFQ', attachment=False) + upload_rfq_filename = fields.Char(string='RFQ Filename') + upload_po_file = fields.Binary(string='Upload PO', attachment=False) + upload_po_filename = fields.Char(string='PO Filename') + coating_config_id = fields.Many2one( 'fp.coating.config', string='Coating Configuration', required=True, ) @@ -345,6 +448,11 @@ class FpQuoteConfigurator(models.Model): 'x_fc_coating_config_id': self.coating_config_id.id, 'x_fc_rush_order': self.rush_order, 'x_fc_delivery_method': self.delivery_method, + # Transfer RFQ / PO documents from configurator (if any) + 'x_fc_rfq_attachment_id': self.rfq_attachment_id.id if self.rfq_attachment_id else False, + 'x_fc_po_attachment_id': self.po_attachment_id.id if self.po_attachment_id else False, + 'x_fc_po_number': self.po_number_preliminary or False, + 'x_fc_po_received': bool(self.po_attachment_id), 'origin': self.name, 'order_line': [(0, 0, { 'product_id': product.id, @@ -430,6 +538,15 @@ class FpQuoteConfigurator(models.Model): self.surface_area = part.surface_area self.surface_area_uom = part.surface_area_uom + # Post to chatter so user sees confirmation (only if record is saved) + if self.id and not isinstance(self.id, models.NewId): + self.sudo().message_post( + body=_('3D model attached: %s — surface area: %.4f %s') % ( + fname, self.surface_area, self.surface_area_uom or ''), + message_type='notification', + subtype_xmlid='mail.mt_note', + ) + # Clear the upload field (data is now on the part catalog) self.upload_3d_file = False self.upload_3d_filename = False @@ -449,7 +566,10 @@ class FpQuoteConfigurator(models.Model): }) if self.part_catalog_id: - self.part_catalog_id.drawing_attachment_ids = [(4, att.id)] + self.part_catalog_id.sudo().write({ + 'drawing_attachment_ids': [(4, att.id)], + }) + part = self.part_catalog_id else: import os part_name = os.path.splitext(fname)[0].replace('_', ' ').replace('-', ' ').title() @@ -461,9 +581,74 @@ class FpQuoteConfigurator(models.Model): }) self.part_catalog_id = part.id + # Post to chatter so user sees confirmation (only if record is saved) + if self.id and not isinstance(self.id, models.NewId): + self.sudo().message_post( + body=_('Drawing attached: %s (linked to part %s)') % ( + fname, part.name), + message_type='notification', + subtype_xmlid='mail.mt_note', + ) + self.upload_drawing = False self.upload_drawing_filename = False + @api.onchange('upload_rfq_file') + def _onchange_upload_rfq_file(self): + """When an RFQ file is uploaded, create attachment + link it.""" + if not self.upload_rfq_file: + return + fname = self.upload_rfq_filename or 'rfq.pdf' + att = self.env['ir.attachment'].create({ + 'name': fname, + 'datas': self.upload_rfq_file, + 'mimetype': 'application/pdf', + }) + self.rfq_attachment_id = att.id + self.upload_rfq_file = False + self.upload_rfq_filename = False + + @api.onchange('upload_po_file') + def _onchange_upload_po_file(self): + """When a PO file is uploaded, create attachment + link it.""" + if not self.upload_po_file: + return + fname = self.upload_po_filename or 'po.pdf' + att = self.env['ir.attachment'].create({ + 'name': fname, + 'datas': self.upload_po_file, + 'mimetype': 'application/pdf', + }) + self.po_attachment_id = att.id + self.upload_po_file = False + self.upload_po_filename = False + + def action_view_rfq(self): + self.ensure_one() + if not self.rfq_attachment_id: + return + return { + 'type': 'ir.actions.client', + 'tag': 'fp_pdf_preview_open', + 'params': { + 'attachment_id': self.rfq_attachment_id.id, + 'title': _('RFQ — %s') % (self.rfq_attachment_id.name or ''), + }, + } + + def action_view_po(self): + self.ensure_one() + if not self.po_attachment_id: + return + return { + 'type': 'ir.actions.client', + 'tag': 'fp_pdf_preview_open', + 'params': { + 'attachment_id': self.po_attachment_id.id, + 'title': _('PO — %s') % (self.po_attachment_id.name or ''), + }, + } + def action_recalculate_price(self): """Recalculate surface area from 3D model and recompute price.""" self.ensure_one() @@ -495,16 +680,18 @@ class FpQuoteConfigurator(models.Model): self.write({'state': 'draft'}) def action_open_3d_fullscreen(self): - """Open the 3D model viewer in a new browser tab (full screen).""" + """Open the 3D model viewer in a full-screen dialog (same window).""" 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', + 'type': 'ir.actions.client', + 'tag': 'fp_3d_viewer_open', + 'params': { + 'attachment_id': att.id, + 'name': att.name or '', + }, } def action_view_sale_order(self): @@ -526,3 +713,27 @@ class FpQuoteConfigurator(models.Model): 'view_mode': 'form', 'target': 'current', } + + def action_view_drawings(self): + """Open the first drawing in the PDF preview dialog (matches RFQ/PO behavior).""" + self.ensure_one() + if self.first_drawing_id: + return { + 'type': 'ir.actions.client', + 'tag': 'fp_pdf_preview_open', + 'params': { + 'attachment_id': self.first_drawing_id.id, + 'title': _('Drawing — %s') % (self.first_drawing_id.name or ''), + }, + } + # No drawing: fall back to part catalog + if not self.part_catalog_id: + return + return { + 'type': 'ir.actions.act_window', + 'name': _('Drawings — %s') % self.part_catalog_id.name, + 'res_model': 'fp.part.catalog', + 'res_id': self.part_catalog_id.id, + 'view_mode': 'form', + 'target': 'current', + } diff --git a/fusion-plating/fusion_plating_configurator/models/sale_order.py b/fusion-plating/fusion_plating_configurator/models/sale_order.py index c8479a2e..89a67163 100644 --- a/fusion-plating/fusion_plating_configurator/models/sale_order.py +++ b/fusion-plating/fusion_plating_configurator/models/sale_order.py @@ -13,8 +13,18 @@ class SaleOrder(models.Model): x_fc_part_catalog_id = fields.Many2one('fp.part.catalog', string='Part') x_fc_coating_config_id = fields.Many2one('fp.coating.config', string='Coating Configuration') x_fc_po_number = fields.Char(string='Customer PO #', tracking=True) - x_fc_po_attachment_id = fields.Many2one('ir.attachment', string='PO Document') + x_fc_po_attachment_id = fields.Many2one( + 'ir.attachment', string='PO Document', tracking=True, + ) x_fc_po_received = fields.Boolean(string='PO Received', tracking=True) + x_fc_rfq_attachment_id = fields.Many2one( + 'ir.attachment', string='RFQ Document', tracking=True, + help="Customer's original Request for Quote document.", + ) + upload_rfq_file = fields.Binary(string='Upload RFQ', attachment=False) + upload_rfq_filename = fields.Char(string='RFQ Filename') + upload_po_file = fields.Binary(string='Upload PO', attachment=False) + upload_po_filename = fields.Char(string='PO Filename') x_fc_po_override = fields.Boolean(string='PO Override', help='Manager override — proceed without formal PO (handshake deal).') x_fc_po_override_reason = fields.Text(string='Override Reason') @@ -36,3 +46,61 @@ class SaleOrder(models.Model): ('received', 'Received'), ('inspected', 'Inspected')], string='Receiving Status', default='not_received', tracking=True, ) + + @api.onchange('upload_rfq_file') + def _onchange_upload_rfq_file(self): + """Create attachment from uploaded binary and link it.""" + if not self.upload_rfq_file: + return + fname = self.upload_rfq_filename or 'rfq.pdf' + att = self.env['ir.attachment'].create({ + 'name': fname, + 'datas': self.upload_rfq_file, + 'mimetype': 'application/pdf', + }) + self.x_fc_rfq_attachment_id = att.id + self.upload_rfq_file = False + self.upload_rfq_filename = False + + @api.onchange('upload_po_file') + def _onchange_upload_po_file(self): + """Create attachment from uploaded binary, link it, and mark PO received.""" + if not self.upload_po_file: + return + fname = self.upload_po_filename or 'po.pdf' + att = self.env['ir.attachment'].create({ + 'name': fname, + 'datas': self.upload_po_file, + 'mimetype': 'application/pdf', + }) + self.x_fc_po_attachment_id = att.id + if not self.x_fc_po_received: + self.x_fc_po_received = True + self.upload_po_file = False + self.upload_po_filename = False + + def action_view_rfq(self): + self.ensure_one() + if not self.x_fc_rfq_attachment_id: + return + return { + 'type': 'ir.actions.client', + 'tag': 'fp_pdf_preview_open', + 'params': { + 'attachment_id': self.x_fc_rfq_attachment_id.id, + 'title': 'RFQ — %s' % (self.x_fc_rfq_attachment_id.name or ''), + }, + } + + def action_view_po(self): + self.ensure_one() + if not self.x_fc_po_attachment_id: + return + return { + 'type': 'ir.actions.client', + 'tag': 'fp_pdf_preview_open', + 'params': { + 'attachment_id': self.x_fc_po_attachment_id.id, + 'title': 'PO — %s' % (self.x_fc_po_attachment_id.name or ''), + }, + } diff --git a/fusion-plating/fusion_plating_configurator/static/src/html/3d_viewer.html b/fusion-plating/fusion_plating_configurator/static/src/html/3d_viewer.html index 173b9aa4..ea98d3c4 100644 --- a/fusion-plating/fusion_plating_configurator/static/src/html/3d_viewer.html +++ b/fusion-plating/fusion_plating_configurator/static/src/html/3d_viewer.html @@ -18,10 +18,26 @@ html,body{width:100%;height:100%;overflow:hidden;font-family:system-ui,-apple-sy .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} +#toolbar{position:absolute;top:10px;left:10px;display:flex;gap:4px;z-index:100;flex-wrap:wrap;background:rgba(255,255,255,.85);padding:4px;border-radius:6px;backdrop-filter:blur(4px);box-shadow:0 1px 3px rgba(0,0,0,.1)} +#toolbar button{background:#fff;border:1px solid #ced4da;border-radius:4px;padding:4px 8px;font-size:11px;font-weight:500;cursor:pointer;color:#495057;transition:all .15s;min-width:40px} +#toolbar button:hover{background:#0d6efd;color:#fff;border-color:#0d6efd} +#toolbar .btn-divider{width:1px;background:#dee2e6;margin:2px 4px}
+
+ + + + + + + + + + +
Loading 3D model...
@@ -115,6 +131,106 @@ html,body{width:100%;height:100%;overflow:hidden;font-family:system-ui,-apple-sy .catch(function(err) { showError('Failed to load model: ' + err.message); }); + + // ---- View preset functions (Top/Front/Side/Iso) ---- + // Online3DViewer's internal viewer exposes a Camera object we can manipulate. + window.setView = function(view) { + try { + const v = viewer.GetViewer(); + if (!v) return; + const camera = v.GetCamera(); + if (!camera) return; + // Compute distance from current camera to keep zoom roughly consistent + const eye = camera.eye; + const center = camera.center; + const dx = eye.x - center.x, dy = eye.y - center.y, dz = eye.z - center.z; + const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) || 100; + let newEye, newUp; + switch (view) { + case 'top': + newEye = new OV.Coord3D(center.x, center.y, center.z + dist); + newUp = new OV.Coord3D(0, 1, 0); + break; + case 'bottom': + newEye = new OV.Coord3D(center.x, center.y, center.z - dist); + newUp = new OV.Coord3D(0, 1, 0); + break; + case 'front': + newEye = new OV.Coord3D(center.x, center.y - dist, center.z); + newUp = new OV.Coord3D(0, 0, 1); + break; + case 'back': + newEye = new OV.Coord3D(center.x, center.y + dist, center.z); + newUp = new OV.Coord3D(0, 0, 1); + break; + case 'left': + newEye = new OV.Coord3D(center.x - dist, center.y, center.z); + newUp = new OV.Coord3D(0, 0, 1); + break; + case 'right': + newEye = new OV.Coord3D(center.x + dist, center.y, center.z); + newUp = new OV.Coord3D(0, 0, 1); + break; + case 'iso': + default: + const d = dist / Math.sqrt(3); + newEye = new OV.Coord3D(center.x + d, center.y - d, center.z + d); + newUp = new OV.Coord3D(0, 0, 1); + break; + } + const newCam = new OV.Camera(newEye, center, newUp, camera.fov); + v.SetCamera(newCam); + v.Render(); + } catch(e) { + console.warn('setView failed:', e); + } + }; + + window.fitToView = function() { + try { + const v = viewer.GetViewer(); + if (v && v.FitSphereToWindow) { + // FitSphereToWindow uses the model's bounding sphere + v.FitSphereToWindow(v.GetBoundingSphere(() => true), false); + v.Render(); + } + } catch(e) { + console.warn('fitToView failed:', e); + } + }; + + window.takeScreenshot = function() { + try { + const v = viewer.GetViewer(); + if (!v) return; + // Get the renderer's canvas and convert to PNG + const canvas = v.GetCanvas ? v.GetCanvas() : null; + if (!canvas) { + // Fallback: find canvas inside container + const c = container.querySelector('canvas'); + if (!c) return; + downloadCanvas(c); + return; + } + downloadCanvas(canvas); + } catch(e) { + console.warn('screenshot failed:', e); + } + }; + + function downloadCanvas(canvas) { + canvas.toBlob(function(blob) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + a.download = (fileName.replace(/\.[^.]+$/, '') || 'model') + '-' + stamp + '.png'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, 'image/png'); + } })(); diff --git a/fusion-plating/fusion_plating_configurator/static/src/js/fp_3d_viewer.js b/fusion-plating/fusion_plating_configurator/static/src/js/fp_3d_viewer.js index 6aa5d370..913074da 100644 --- a/fusion-plating/fusion_plating_configurator/static/src/js/fp_3d_viewer.js +++ b/fusion-plating/fusion_plating_configurator/static/src/js/fp_3d_viewer.js @@ -10,6 +10,7 @@ import { Component, useState } from "@odoo/owl"; import { registry } from "@web/core/registry"; import { standardFieldProps } from "@web/views/fields/standard_field_props"; +import { Dialog } from "@web/core/dialog/dialog"; export class Fp3dViewer extends Component { static template = "fusion_plating_configurator.Fp3dViewer"; @@ -58,3 +59,60 @@ registry.category("fields").add("fp_3d_preview", { component: Fp3dViewer, supportedTypes: ["many2one"], }); + + +// ============================================================================= +// 3D Viewer Dialog component (full-screen embedded viewer) +// ============================================================================= +export class Fp3dViewerDialog extends Component { + static template = "fusion_plating_configurator.Fp3dViewerDialog"; + static components = { Dialog }; + static props = { + attachmentId: Number, + name: { type: String, optional: true }, + close: { type: Function, optional: true }, + }; + + setup() { + this.state = useState({ isMaximized: true }); + } + + get iframeSrc() { + const name = encodeURIComponent(this.props.name || ""); + return `/fp/3d-viewer?id=${this.props.attachmentId}&name=${name}`; + } + + get dialogSize() { + return this.state.isMaximized ? "fullscreen" : "xl"; + } + + get frameStyle() { + if (this.state.isMaximized) { + return "height: calc(98vh - 100px) !important;"; + } + return "height: calc(85vh - 100px) !important;"; + } + + toggleSize() { + this.state.isMaximized = !this.state.isMaximized; + } +} + +registry.category("dialog").add("Fp3dViewerDialog", Fp3dViewerDialog); + + +// Client action handler — opens the 3D viewer in a dialog within the same window. +// Triggered by Python returning: +// { type: 'ir.actions.client', tag: 'fp_3d_viewer_open', +// params: { attachment_id: N, name: "..." } } +function fp3dViewerOpenAction(env, action) { + const params = action.params || {}; + if (!params.attachment_id) return Promise.resolve(); + env.services.dialog.add(Fp3dViewerDialog, { + attachmentId: params.attachment_id, + name: params.name || "", + }); + return Promise.resolve(); +} + +registry.category("actions").add("fp_3d_viewer_open", fp3dViewerOpenAction); diff --git a/fusion-plating/fusion_plating_configurator/static/src/js/fp_drawing_preview.js b/fusion-plating/fusion_plating_configurator/static/src/js/fp_drawing_preview.js new file mode 100644 index 00000000..c30dcf19 --- /dev/null +++ b/fusion-plating/fusion_plating_configurator/static/src/js/fp_drawing_preview.js @@ -0,0 +1,81 @@ +/** @odoo-module **/ +// Fusion Plating -- PDF Drawing Preview Widget +// Copyright 2026 Nexa Systems Inc. +// License OPL-1 +// +// Custom many2many_binary widget that opens PDFs in the fusion_pdf_preview +// dialog instead of downloading them. + +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { + Many2ManyBinaryField, + many2ManyBinaryField, +} from "@web/views/fields/many2many_binary/many2many_binary_field"; + +export class FpPdfPreviewBinary extends Many2ManyBinaryField { + static template = "fusion_plating_configurator.FpPdfPreviewBinary"; + + setup() { + super.setup(); + this.dialogService = useService("dialog"); + } + + onFileClick(ev, file) { + const isPdf = (file.mimetype === "application/pdf") || + (file.name || "").toLowerCase().endsWith(".pdf"); + const dialogs = registry.category("dialog"); + + if (isPdf && dialogs.contains("PDFViewerDialog")) { + ev.preventDefault(); + ev.stopPropagation(); + const PDFViewerDialog = dialogs.get("PDFViewerDialog"); + const url = `/web/content/${file.id}?download=false`; + this.dialogService.add(PDFViewerDialog, { + url: url, + title: file.name || "Drawing", + reportName: "", + recordIds: "", + modelName: "ir.attachment", + }); + } + // For non-PDF or when preview not available, default browser behavior + // (the with download attribute) kicks in because we don't + // prevent default. + } +} + +export const fpPdfPreviewBinary = { + ...many2ManyBinaryField, + component: FpPdfPreviewBinary, +}; + +registry.category("fields").add("fp_pdf_preview_binary", fpPdfPreviewBinary); + + +// Client action handler: open a PDF attachment in the fusion_pdf_preview dialog. +// Triggered by Python methods returning: +// { type: 'ir.actions.client', tag: 'fp_pdf_preview_open', +// params: { attachment_id: N, title: "..." } } +function fpPdfPreviewOpenAction(env, action) { + const params = action.params || {}; + const attId = params.attachment_id; + if (!attId) return Promise.resolve(); + const dialogs = registry.category("dialog"); + const PDFViewerDialog = dialogs.contains("PDFViewerDialog") ? dialogs.get("PDFViewerDialog") : null; + if (!PDFViewerDialog) { + window.open(`/web/content/${attId}?download=false`, '_blank'); + return Promise.resolve(); + } + const url = `/web/content/${attId}?download=false`; + env.services.dialog.add(PDFViewerDialog, { + url: url, + title: params.title || 'Document', + reportName: '', + recordIds: '', + modelName: 'ir.attachment', + }); + return Promise.resolve(); +} + +registry.category("actions").add("fp_pdf_preview_open", fpPdfPreviewOpenAction); diff --git a/fusion-plating/fusion_plating_configurator/static/src/js/fp_pdf_inline_preview.js b/fusion-plating/fusion_plating_configurator/static/src/js/fp_pdf_inline_preview.js new file mode 100644 index 00000000..3f525f66 --- /dev/null +++ b/fusion-plating/fusion_plating_configurator/static/src/js/fp_pdf_inline_preview.js @@ -0,0 +1,80 @@ +/** @odoo-module **/ +// Fusion Plating -- Inline PDF Preview field widget +// Copyright 2026 Nexa Systems Inc. +// License OPL-1 +// +// Field widget for Many2one(ir.attachment) fields that embeds the +// PDF.js viewer inline at a fixed height (one page at a time). +// A "Full Screen" button below opens the fusion_pdf_preview dialog. + +import { Component, useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { standardFieldProps } from "@web/views/fields/standard_field_props"; + +export class FpPdfInlinePreview extends Component { + static template = "fusion_plating_configurator.FpPdfInlinePreview"; + static props = { ...standardFieldProps }; + + setup() { + this.dialogService = useService("dialog"); + this.state = useState({ hasAttachment: false, iframeSrc: "", attId: 0, name: "" }); + this._updateState(); + } + + get rawValue() { + return this.props.record.data[this.props.name]; + } + + _updateState() { + const v = this.rawValue; + let attId = 0; + let name = ""; + if (v) { + if (Array.isArray(v)) { + attId = v[0] || 0; + name = v[1] || ""; + } else if (typeof v === "object" && v.id) { + attId = v.id; + name = v.display_name || ""; + } else if (typeof v === "number") { + attId = v; + } + } + this.state.hasAttachment = !!attId; + this.state.attId = attId; + this.state.name = name; + if (attId) { + const fileUrl = `/web/content/${attId}?download=false`; + // PDF.js URL params: zoom=page-fit, no thumbs sidebar, single-page mode + this.state.iframeSrc = + `/fusion_pdf_preview/static/lib/pdfjs/web/viewer.html` + + `?file=${encodeURIComponent(fileUrl)}` + + `#zoom=page-fit&pagemode=none&scrollmode=3`; + } + } + + onPatched() { + this._updateState(); + } + + openFullScreen() { + if (!this.state.attId) return; + const dialogs = registry.category("dialog"); + if (!dialogs.contains("PDFViewerDialog")) return; + const PDFViewerDialog = dialogs.get("PDFViewerDialog"); + const url = `/web/content/${this.state.attId}?download=false`; + this.dialogService.add(PDFViewerDialog, { + url: url, + title: this.state.name || "Drawing", + reportName: "", + recordIds: "", + modelName: "ir.attachment", + }); + } +} + +registry.category("fields").add("fp_pdf_inline_preview", { + component: FpPdfInlinePreview, + supportedTypes: ["many2one"], +}); diff --git a/fusion-plating/fusion_plating_configurator/static/src/scss/fp_3d_viewer.scss b/fusion-plating/fusion_plating_configurator/static/src/scss/fp_3d_viewer.scss index 3d67416b..11a539c8 100644 --- a/fusion-plating/fusion_plating_configurator/static/src/scss/fp_3d_viewer.scss +++ b/fusion-plating/fusion_plating_configurator/static/src/scss/fp_3d_viewer.scss @@ -5,13 +5,22 @@ // ============================================================================= // -- Configurator two-column layout: 3/4 fields + 1/4 preview -- +// When the preview column is hidden (no 3D model AND no drawings), the +// fields column expands to full width via the :has() selector below. .o_fp_cfg_layout { display: grid; - grid-template-columns: 1fr 320px; + grid-template-columns: 1fr 380px; gap: 16px; align-items: start; } +// Full width when right column has no visible content +.o_fp_cfg_layout:has(> .o_fp_cfg_preview.o_invisible_modifier), +.o_fp_cfg_layout:has(> .o_fp_cfg_preview[style*="display: none"]), +.o_fp_cfg_layout:has(> .o_fp_cfg_preview[style*="display:none"]) { + grid-template-columns: 1fr; +} + .o_fp_cfg_fields { min-width: 0; } @@ -19,6 +28,18 @@ .o_fp_cfg_preview { position: sticky; top: 16px; + + // Force all field widgets (3D viewer, Html drawing preview) to be + // block-level + full width so the 3D and PDF iframes match exactly. + .o_field_widget, + > div > .o_field_widget { + display: block; + width: 100%; + } + + iframe { + display: block; + } } // Responsive: stack on narrow screens @@ -50,14 +71,73 @@ .o_fp_3d_iframe { width: 100%; - height: 500px; + height: 450px; border: 1px solid $border-color; border-radius: 0.5rem; background-color: #f0f2f5; display: block; } -// Inside the preview column, make iframe taller +// Inside the preview column: same height as the PDF preview iframe .o_fp_cfg_preview .o_fp_3d_iframe { - height: 600px; + height: 450px; +} + +// -- 3D Viewer Dialog (full-screen modal) -- +.o_fp_3d_dialog { + .modal-body { + padding: 0; + } +} + +.o_fp_3d_dialog_body { + width: 100%; + background-color: #f0f2f5; + overflow: hidden; +} + +.o_fp_3d_dialog_iframe { + width: 100%; + border: 0; + display: block; + background-color: #f0f2f5; +} + +.o_fp_3d_dialog_actions { + padding: 8px 12px; + text-align: right; + border-top: 1px solid var(--bs-border-color, #dee2e6); + background-color: var(--bs-body-bg); +} + +// -- Inline PDF preview widget (fp_pdf_inline_preview) -- +.o_fp_pdf_inline_root { + width: 100%; +} + +.o_fp_pdf_inline_frame_wrap { + width: 100%; + height: 450px; + border: 1px solid $border-color; + border-radius: 0.5rem; + overflow: hidden; + background-color: #f0f2f5; +} + +.o_fp_pdf_inline_iframe { + width: 100%; + height: 100%; + border: 0; + display: block; +} + +.o_fp_pdf_inline_placeholder { + border: 2px dashed $border-color; + border-radius: 0.5rem; + min-height: 200px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: var(--bs-tertiary-bg); } diff --git a/fusion-plating/fusion_plating_configurator/static/src/xml/fp_3d_viewer.xml b/fusion-plating/fusion_plating_configurator/static/src/xml/fp_3d_viewer.xml index bdf66b62..cf984f8e 100644 --- a/fusion-plating/fusion_plating_configurator/static/src/xml/fp_3d_viewer.xml +++ b/fusion-plating/fusion_plating_configurator/static/src/xml/fp_3d_viewer.xml @@ -18,4 +18,33 @@ + + +
+