This commit is contained in:
gsinghpal
2026-04-14 10:17:55 -04:00
parent e0e2c6cfda
commit 3f3ddcbab4
3 changed files with 153 additions and 27 deletions

View File

@@ -97,14 +97,13 @@ class FpPartCatalog(models.Model):
help='False indicates open/broken geometry — review before quoting.', help='False indicates open/broken geometry — review before quoting.',
) )
hole_count = fields.Integer( hole_count = fields.Integer(
string='Cylindrical Features', string='Holes',
help='Total cylindrical surfaces detected (includes holes, bores, ' help='Real cylindrical holes/bosses detected (faces summing to ≥350° '
'and rounded fillets — see breakdown for diameter clustering).', 'around an axis). Fillets and rounded edges are excluded.',
) )
hole_summary = fields.Char( hole_summary = fields.Char(
string='Feature Diameters', string='Hole Diameters',
help='Cylindrical features grouped by diameter (rounded to 0.5mm). ' help='Holes grouped by diameter — e.g. "4× Ø10.2mm, 2× Ø7.9mm".',
'Small diameters are usually edge fillets; larger ones are typically holes.',
) )
masking_area_sqin = fields.Float( masking_area_sqin = fields.Float(
string='Masking Area (sq in)', digits=(12, 4), string='Masking Area (sq in)', digits=(12, 4),
@@ -440,12 +439,13 @@ class FpPartCatalog(models.Model):
except Exception: except Exception:
is_manifold = None is_manifold = None
# Cylindrical feature detection — counts holes, fillets, bores. # Hole detection: group cylindrical faces by axis LINE + radius,
# NOTE: fillets/rounded edges also appear as cylinders. We # sum U-parameter ranges. Faces summing to ≥350° form a full
# cluster by diameter (rounded to 0.5 mm) and report the # cylinder = real hole or boss. Partial cylinders are fillets/
# breakdown so the estimator can identify real holes vs fillets. # rounded edges and are excluded.
try: try:
diameters = [] import math
groups = {} # key -> [total_u_radians, radius]
explorer = TopExp_Explorer(shape, TopAbs_FACE) explorer = TopExp_Explorer(shape, TopAbs_FACE)
while explorer.More(): while explorer.More():
try: try:
@@ -453,22 +453,53 @@ class FpPartCatalog(models.Model):
surf = BRepAdaptor_Surface(face) surf = BRepAdaptor_Surface(face)
if surf.GetType() == GeomAbs_Cylinder: if surf.GetType() == GeomAbs_Cylinder:
cyl = surf.Cylinder() cyl = surf.Cylinder()
diameter_mm = cyl.Radius() * 2.0 axis = cyl.Axis()
# Round to 0.5 mm clusters (fillets ~0.5-3mm, holes ~3mm+) loc = axis.Location()
diameters.append(round(diameter_mm * 2) / 2) 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: except Exception:
pass pass
explorer.Next() explorer.Next()
if diameters:
from collections import Counter # Keep only full cylinders (≥ 350° covered by grouped faces)
counts = Counter(diameters) 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()) hole_count = sum(counts.values())
# Sort by diameter ascending, format
parts = ['%d× Ø%.1fmm' % (n, d) parts = ['%d× Ø%.1fmm' % (n, d)
for d, n in sorted(counts.items())] for d, n in sorted(counts.items())]
hole_summary = ', '.join(parts) hole_summary = ', '.join(parts)
except Exception as he: except Exception as he:
_logger.warning('Cylindrical feature detection failed: %s', he) _logger.warning('Hole detection failed: %s', he)
method = 'occ_brep' method = 'occ_brep'
finally: finally:

View File

@@ -45,13 +45,13 @@ class FpQuoteConfigurator(models.Model):
string='Drawings', string='Drawings',
readonly=True, 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( bbox_summary_in = fields.Char(
related='part_catalog_id.bbox_summary_in', string='Dimensions (in)', related='part_catalog_id.bbox_summary_in', string='Dimensions (in)',
readonly=True, readonly=True,
) )
material_weight_kg = fields.Float( volume_mm3 = fields.Float(
related='part_catalog_id.material_weight_kg', string='Weight (kg)', related='part_catalog_id.volume_mm3', string='Volume (mm³)',
readonly=True, readonly=True,
) )
hole_count = fields.Integer( hole_count = fields.Integer(
@@ -66,14 +66,67 @@ class FpQuoteConfigurator(models.Model):
related='part_catalog_id.is_manifold', string='Watertight', related='part_catalog_id.is_manifold', string='Watertight',
readonly=True, 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( masking_area_sqin = fields.Float(
related='part_catalog_id.masking_area_sqin', string='Masking Area (sq in)', string='Masking Area (sq in)',
readonly=False, # allow editing via configurator 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( effective_area_sqin = fields.Float(
related='part_catalog_id.effective_area_sqin', string='Effective Area (sq in)', string='Effective Plating Area (sq in)',
readonly=True, 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( drawing_count = fields.Integer(
string='Drawings', string='Drawings',
compute='_compute_drawing_count', compute='_compute_drawing_count',
@@ -227,6 +280,8 @@ class FpQuoteConfigurator(models.Model):
self.complexity = cat.complexity self.complexity = cat.complexity
self.masking_zones = cat.masking_zones self.masking_zones = cat.masking_zones
self.substrate_material = cat.substrate_material 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') @api.onchange('coating_config_id')
def _onchange_coating_config_id(self): def _onchange_coating_config_id(self):
@@ -714,6 +769,40 @@ class FpQuoteConfigurator(models.Model):
'target': 'current', '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 <b>%s</b>.') % 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): def action_view_drawings(self):
"""Open the first drawing in the PDF preview dialog (matches RFQ/PO behavior).""" """Open the first drawing in the PDF preview dialog (matches RFQ/PO behavior)."""
self.ensure_one() self.ensure_one()

View File

@@ -23,6 +23,12 @@
string="Recalculate" string="Recalculate"
type="object" type="object"
class="btn-secondary"/> class="btn-secondary"/>
<button name="action_save_to_catalog"
string="Save to Catalog"
type="object"
class="btn-secondary"
confirm="This will overwrite the part catalog's geometry, substrate, masking area, and complexity with values from this quote. Continue?"
invisible="not part_catalog_id"/>
<button name="action_cancel" <button name="action_cancel"
string="Cancel" string="Cancel"
type="object" type="object"
@@ -162,7 +168,7 @@
string="Weight (kg)" string="Weight (kg)"
readonly="1"/> readonly="1"/>
<field name="hole_count" <field name="hole_count"
string="Holes Detected" string="Holes"
readonly="1"/> readonly="1"/>
<field name="hole_summary" <field name="hole_summary"
readonly="1" readonly="1"