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"/>
+