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.',
)
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:

View File

@@ -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 <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):
"""Open the first drawing in the PDF preview dialog (matches RFQ/PO behavior)."""
self.ensure_one()

View File

@@ -23,6 +23,12 @@
string="Recalculate"
type="object"
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"
string="Cancel"
type="object"
@@ -162,7 +168,7 @@
string="Weight (kg)"
readonly="1"/>
<field name="hole_count"
string="Holes Detected"
string="Holes"
readonly="1"/>
<field name="hole_summary"
readonly="1"