From 3f3ddcbab4e7e389741e8f431e5df7779a2b185a Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 14 Apr 2026 10:17:55 -0400 Subject: [PATCH] changes --- .../models/fp_part_catalog.py | 69 ++++++++---- .../models/fp_quote_configurator.py | 103 ++++++++++++++++-- .../views/fp_quote_configurator_views.xml | 8 +- 3 files changed, 153 insertions(+), 27 deletions(-) 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 6a34b2b0..2a3e0cf0 100644 --- a/fusion-plating/fusion_plating_configurator/models/fp_part_catalog.py +++ b/fusion-plating/fusion_plating_configurator/models/fp_part_catalog.py @@ -97,14 +97,13 @@ class FpPartCatalog(models.Model): 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).', + string='Holes', + help='Real cylindrical holes/bosses detected (faces summing to ≥350° ' + 'around an axis). Fillets and rounded edges are excluded.', ) 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.', + string='Hole Diameters', + help='Holes grouped by diameter — e.g. "4× Ø10.2mm, 2× Ø7.9mm".', ) masking_area_sqin = fields.Float( string='Masking Area (sq in)', digits=(12, 4), @@ -440,12 +439,13 @@ class FpPartCatalog(models.Model): 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. + # Hole detection: group cylindrical faces by axis LINE + radius, + # sum U-parameter ranges. Faces summing to ≥350° form a full + # cylinder = real hole or boss. Partial cylinders are fillets/ + # rounded edges and are excluded. try: - diameters = [] + import math + groups = {} # key -> [total_u_radians, radius] explorer = TopExp_Explorer(shape, TopAbs_FACE) while explorer.More(): try: @@ -453,22 +453,53 @@ class FpPartCatalog(models.Model): 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) + axis = cyl.Axis() + loc = axis.Location() + d = axis.Direction() + u_range = abs( + surf.LastUParameter() - surf.FirstUParameter() + ) + r = cyl.Radius() + # Canonical axis point: project loc onto plane through origin + # perpendicular to direction (unique per axis line) + dx, dy, dz = d.X(), d.Y(), d.Z() + lx, ly, lz = loc.X(), loc.Y(), loc.Z() + t = lx * dx + ly * dy + lz * dz + cx = lx - t * dx + cy = ly - t * dy + cz = lz - t * dz + # Normalize direction sign (avoid +/- dedup) + if dz < 0 or (dz == 0 and dy < 0) or ( + dz == 0 and dy == 0 and dx < 0 + ): + dx, dy, dz = -dx, -dy, -dz + key = ( + round(r, 1), + round(cx, 1), round(cy, 1), round(cz, 1), + round(dx, 2), round(dy, 2), round(dz, 2), + ) + if key in groups: + groups[key][0] += u_range + else: + groups[key] = [u_range, r] except Exception: pass explorer.Next() - if diameters: - from collections import Counter - counts = Counter(diameters) + + # Keep only full cylinders (≥ 350° covered by grouped faces) + from collections import Counter + hole_diameters = [] + for (total_u, r) in groups.values(): + if math.degrees(total_u) >= 350: + hole_diameters.append(round(r * 2, 1)) + if hole_diameters: + counts = Counter(hole_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) + _logger.warning('Hole detection failed: %s', he) method = 'occ_brep' finally: 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 e03dbdd9..9e394705 100644 --- a/fusion-plating/fusion_plating_configurator/models/fp_quote_configurator.py +++ b/fusion-plating/fusion_plating_configurator/models/fp_quote_configurator.py @@ -45,13 +45,13 @@ class FpQuoteConfigurator(models.Model): string='Drawings', readonly=True, ) - # -- Auto-extracted geometry from part catalog (read-only on configurator) -- + # -- Physical part properties (intrinsic, related from part catalog) -- 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)', + volume_mm3 = fields.Float( + related='part_catalog_id.volume_mm3', string='Volume (mm³)', readonly=True, ) hole_count = fields.Integer( @@ -66,14 +66,67 @@ class FpQuoteConfigurator(models.Model): related='part_catalog_id.is_manifold', string='Watertight', readonly=True, ) + + # -- Quote-editable fields that drive weight / effective area -- + # These are independent from part catalog (working copy for this quote) masking_area_sqin = fields.Float( - related='part_catalog_id.masking_area_sqin', string='Masking Area (sq in)', - readonly=False, # allow editing via configurator + string='Masking Area (sq in)', + digits=(12, 4), + help='Surface area excluded from plating (masked surfaces).', ) + # Computed using CONFIGURATOR's substrate + part catalog's volume + # so changing substrate on the quote updates the weight live. + material_weight_kg = fields.Float( + string='Weight (kg)', + digits=(12, 4), + compute='_compute_material_weight_kg', + store=False, + help='Computed from part volume × this quote\'s substrate density. ' + 'Changing substrate on the quote updates weight immediately.', + ) + # Computed using CONFIGURATOR's surface_area and masking_area effective_area_sqin = fields.Float( - related='part_catalog_id.effective_area_sqin', string='Effective Area (sq in)', - readonly=True, + string='Effective Plating Area (sq in)', + digits=(12, 4), + compute='_compute_effective_area_sqin', + store=False, + help='Surface area minus masked area, using the values on this quote.', ) + + @api.depends('volume_mm3', 'substrate_material') + def _compute_material_weight_kg(self): + """Compute weight from part volume × THIS QUOTE'S substrate density.""" + density_map = { + 'aluminium': 2.70, + 'steel': 7.85, + 'stainless': 8.00, + 'copper': 8.96, + 'titanium': 4.51, + 'other': 7.85, + } + for rec in self: + if not rec.volume_mm3 or not rec.substrate_material: + rec.material_weight_kg = 0.0 + continue + density = density_map.get(rec.substrate_material, 7.85) + rec.material_weight_kg = round(rec.volume_mm3 * density * 1e-6, 4) + + @api.depends('surface_area', 'surface_area_uom', 'masking_area_sqin') + def _compute_effective_area_sqin(self): + """Surface area minus masking area, using THIS QUOTE'S values.""" + for rec in self: + uom = rec.surface_area_uom or 'sq_in' + if uom == 'sq_in': + area_sqin = rec.surface_area or 0.0 + elif uom == 'sq_ft': + area_sqin = (rec.surface_area or 0.0) * 144.0 + elif uom == 'sq_cm': + area_sqin = (rec.surface_area or 0.0) / 6.4516 + elif uom == 'sq_m': + area_sqin = (rec.surface_area or 0.0) * 1550.0 + else: + area_sqin = rec.surface_area or 0.0 + rec.effective_area_sqin = max(0.0, area_sqin - (rec.masking_area_sqin or 0.0)) drawing_count = fields.Integer( string='Drawings', compute='_compute_drawing_count', @@ -227,6 +280,8 @@ class FpQuoteConfigurator(models.Model): self.complexity = cat.complexity self.masking_zones = cat.masking_zones self.substrate_material = cat.substrate_material + # Copy masking area too (for effective-area calculation) + self.masking_area_sqin = cat.masking_area_sqin @api.onchange('coating_config_id') def _onchange_coating_config_id(self): @@ -714,6 +769,40 @@ class FpQuoteConfigurator(models.Model): 'target': 'current', } + def action_save_to_catalog(self): + """Push this quote's geometry/material edits back to the master part catalog. + + Writes: substrate_material, surface_area, surface_area_uom, + masking_area_sqin, masking_zones, complexity. + Only available when a part catalog entry is linked. + """ + self.ensure_one() + if not self.part_catalog_id: + raise UserError(_('No part catalog entry linked to this configurator.')) + self.part_catalog_id.write({ + 'substrate_material': self.substrate_material, + 'surface_area': self.surface_area, + 'surface_area_uom': self.surface_area_uom, + 'masking_area_sqin': self.masking_area_sqin, + 'masking_zones': self.masking_zones, + 'complexity': self.complexity, + }) + self.message_post( + body=_('Geometry and material saved back to part catalog %s.') % self.part_catalog_id.name, + message_type='notification', + subtype_xmlid='mail.mt_note', + ) + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Saved to Catalog'), + 'message': _('Part catalog updated with quote geometry and substrate.'), + 'type': 'success', + 'sticky': False, + }, + } + def action_view_drawings(self): """Open the first drawing in the PDF preview dialog (matches RFQ/PO behavior).""" self.ensure_one() diff --git a/fusion-plating/fusion_plating_configurator/views/fp_quote_configurator_views.xml b/fusion-plating/fusion_plating_configurator/views/fp_quote_configurator_views.xml index e07f607f..7a2b6c21 100644 --- a/fusion-plating/fusion_plating_configurator/views/fp_quote_configurator_views.xml +++ b/fusion-plating/fusion_plating_configurator/views/fp_quote_configurator_views.xml @@ -23,6 +23,12 @@ string="Recalculate" type="object" class="btn-secondary"/> +