In my last few articles, we set out Phasio-compatible pricing for CNC, SLA, and MJF individually. In practice, buyers often don’t care how you make the part—they care about lead time and price. So here’s a simple idea:
What if we generate a valid unit price for each process, then pick the cheapest—automatically?
That’s what the equation below does. It calculates CNC / SLA / MJF side-by-side, filters out infeasible options, enforces margin and minimum order value, and returns the winner. It’s intentionally blunt: a fast, explainable decision that your BD team can run with.
Obviously the choice of processes here is arbitrary; I am thinking to write an Injection Molding version next, as I’d like to see the impact that it has on quote conversion at large quantity values. Anyhow, onwards!
Why pick-the-cheapest works
- Feasibility first. If the part doesn’t fit the bed/stock, that process is out. No false optimism.
- Comparable units. Each process computes its own cost (not a blended franken-model), so you compare apples to apples.
- Commercial guardrails. Profit floor, margin %, and a minimum order value (MOV) protect you from “clever” geometry that tries to sneak under cost.
The core idea in 30 seconds
- Compute a unit price for CNC, SLA, and MJF:
- CNC → removal-profile proxy (coarse / medium / fine volumes).
- SLA → layer-count time + resin + light post-proc.
- MJF → max(model-parameters price, build-share price capped by packing density).
-
Reject processes that fail a quick feasibility check (bed too small / no stock fits).
-
Pick the lowest valid unit price, then enforce MOV on the total.
A working example
- Part: 60 × 40 × 30 mm, volume 30,000 mm³, surface area 9,000 mm²
- Derived geometry: convex hull 36,000 mm³, shrink wrap 32,000 mm³
- Quantity: Q = 20
- Defaults: (from the equation below) margin 20%, profit floor $2/part, MOV $100
SLA (layer-driven)
-
Support = (36,000 − 30,000) × 0.10 = 600 mm³
-
Waste = 9,000 × 0.05 = 450 mm³
-
Resin liters = (30,000 + 600 + 450) / 1,000,000 = 0.03105 L
-
Material cost @ $150/L = $4.66
-
Height at 45° = 60 × cos 45° = 42.43 mm
-
Layers = ceil(42.43 / 0.06) = 708
-
Print hours = 708 × 25 s / 3600 = 4.92 h
-
Print cost @ $15/h = $73.75
-
Post-proc @ 6 min × $25/h = $2.50
Subtotal = 4.66 + 73.75 + 2.50 = $80.91 → margin 20% = $97.09 per part
MJF (model-params vs build-share)
-
Model-params: (30,000/1000 × $0.16) + (9,000/100 × $0.30) + excess(≈$0.42) = $32.22
-
Build-share: Best parts-per-build ≈ 396; packing ≈ 29.4% → cap to 161 effective ppb @ 12% max density $180 / 161 = $1.12
-
Take the higher: $32.22 → margin 20% = $38.66 per part
CNC (removal profile)
- Block: 75×75×75 mm (smallest that fits) → 421,875 mm³
- Coarse = 421,875 − 36,000 = 385,875 mm³ @ $0.00005 → $19.29
- Medium = 36,000 − 32,000 = 4,000 mm³ @ $0.00030 → $1.20
- Fine = 32,000 − 30,000 = 2,000 mm³ @ $0.00100 → $2.00
- Subtotal = $22.49 → margin 20% = $26.99 per part
Result
- CNC $26.99 vs MJF $38.66 vs SLA $97.09 → CNC wins.
- MOV check: $26.99 × 20 = $539.80 ≥ $100 → no uplift.
That’s exactly how the picker behaves: quick feasibility, three honest costs, lowest valid price, MOV as the last gate.
The complete algorithm (paste-ready, try it in Phasio)
const { material, width, height, length, volume, area, convexHullVolume, shrinkWrapVolume, precision } = specification
const { quantity } = requisition
const Q = Math.max(1, quantity)
// ---- Guardrails (numbers only) ----
const minOrderValue = variable('Min order value $', 100)
const profitFloorPerPart = variable('Profit floor $/part', 2)
const marginPct = variable('Margin %', 20)
const riskMultiplier = variable('Risk multiplier', precision?.value ?? 1.0)
// ---- Machine envelopes (constants) ----
const SLA_BED = { X: 145, Y: 145, Z: 185 }
const MJF_BED = { X: 380, Y: 280, Z: 380 }
// ---- SLA tunables ----
const sla_resinCostPerL = variable('SLA resin $/L', 150)
const sla_layerHeightMM = variable('SLA layer height mm', 0.06)
const sla_secPerLayer = variable('SLA sec/layer', 25)
const sla_machineRateH = variable('SLA $/machine-hour', 15)
const sla_laborRateH = variable('SLA $/labor-hour', 25)
const sla_postMinsPerPart = variable('SLA post-proc mins/part', 6)
// ---- MJF tunables ----
const mjf_modelOffsetMM = variable('MJF model offset mm', 3)
const mjf_maxPackingDensity = variable('MJF max packing density', 0.12)
const mjf_fullBuildPrice = variable('MJF $/full build', 180)
const mjf_volumePricePerCM3 = variable('MJF $/cm3', 0.16)
const mjf_areaPricePerDM2 = variable('MJF $/dm2', 0.30)
// ---- CNC tunables ----
const cnc_coarsePerMM3 = variable('CNC coarse $/mm3', 0.00005)
const cnc_mediumPerMM3 = variable('CNC medium $/mm3', 0.00030)
const cnc_finePerMM3 = variable('CNC fine $/mm3', 0.00100)
// Fixed stock catalog (edit here or branch by material)
const CNC_BLOCKS: [number,number,number][] =
[[50,50,50],[75,75,75],[100,100,100],[150,125,125],[150,150,150]]
function fits(part:[number,number,number], box:[number,number,number]){
const p=[...part].sort((a,b)=>a-b), b=[...box].sort((a,b)=>a-b)
return p[0]<=b[0] && p[1]<=b[1] && p[2]<=b[2]
}
const orientations: [number,number,number][] = [
[width,height,length],[width,length,height],
[height,length,width],[height,width,length],
[length,width,height],[length,height,width]
]
// ---------- SLA ----------
function priceSLA(){
const bed = [SLA_BED.X,SLA_BED.Y,SLA_BED.Z] as [number,number,number]
const feasible = orientations.some(o=>fits(o, bed))
if(!feasible) return { ok:false, unit:Infinity }
const supportVol = Math.max(0, (specification.convexHullVolume - volume)) * 0.10
const wasteVol = area * 0.05
const liters = (volume + supportVol + wasteVol) / 1_000_000
const matCost = liters * sla_resinCostPerL
const maxDim = Math.max(width, Math.max(height, length))
const h45 = maxDim * Math.cos(Math.PI/4)
const layers = Math.ceil(h45 / sla_layerHeightMM)
const hours = (layers * sla_secPerLayer) / 3600
const printCost = hours * sla_machineRateH
const postCost = (sla_postMinsPerPart/60) * sla_laborRateH
let unit = (matCost + printCost + postCost) * riskMultiplier
unit = Math.max(unit * (1 + marginPct/100), profitFloorPerPart)
return { ok:true, unit }
}
// ---------- MJF ----------
function priceMJF(){
const bed = [MJF_BED.X,MJF_BED.Y,MJF_BED.Z] as [number,number,number]
const bedOK = orientations.some(o=>fits(o, bed))
if(!bedOK) return { ok:false, unit:Infinity }
const modelParams =
(volume/1000)*mjf_volumePricePerCM3 +
(area/100)*mjf_areaPricePerDM2 +
Math.max(0, ((specification.minBoundingBoxVolume - volume)/1000)*0.01)
function partsPerBuild(){
let m=0
for(const [w,h,l] of orientations){
const px = Math.floor(MJF_BED.X / (w + mjf_modelOffsetMM))
const py = Math.floor(MJF_BED.Y / (h + mjf_modelOffsetMM))
const pz = Math.floor(MJF_BED.Z / (l + mjf_modelOffsetMM))
m = Math.max(m, px*py*pz)
}
return Math.max(1, m)
}
const ppb = partsPerBuild()
const printerVolCM3 = (MJF_BED.X*MJF_BED.Y*MJF_BED.Z)/1000
const packing = (ppb * (volume/1000)) / printerVolCM3
const effPPB = packing > mjf_maxPackingDensity
? Math.max(1, Math.floor(ppb / (packing / mjf_maxPackingDensity)))
: ppb
let unit = Math.max(modelParams, mjf_fullBuildPrice / effPPB) * riskMultiplier
unit = Math.max(unit * (1 + marginPct/100), profitFloorPerPart)
return { ok:true, unit }
}
// ---------- CNC ----------
function priceCNC(){
const dims:[number,number,number] = [width,height,length]
const block = CNC_BLOCKS
.filter(b=>fits(dims, b))
.sort((a,b)=>(a[0]*a[1]*a[2])-(b[0]*b[1]*b[2]))[0]
if(!block) return { ok:false, unit:Infinity }
const blockVol = block[0]*block[1]*block[2]
const coarse = Math.max(0, blockVol - convexHullVolume)
const medium = Math.max(0, convexHullVolume - shrinkWrapVolume)
const fine = Math.max(0, shrinkWrapVolume - volume)
let unit = (
cnc_coarsePerMM3*coarse +
cnc_mediumPerMM3*medium +
cnc_finePerMM3 *fine
) * riskMultiplier
unit = Math.max(unit * (1 + marginPct/100), profitFloorPerPart)
return { ok:true, unit }
}
// ---------- Evaluate ----------
const cncOpt = priceCNC()
const slaOpt = priceSLA()
const mjfOpt = priceMJF()
const options = [
{ id:1, name:'CNC', unit: cncOpt.unit, ok: cncOpt.ok },
{ id:2, name:'SLA', unit: slaOpt.unit, ok: slaOpt.ok },
{ id:3, name:'MJF', unit: mjfOpt.unit, ok: mjfOpt.ok },
].filter(o=>o.ok)
if(!options.length){ done(0) }
options.sort((a,b)=>a.unit - b.unit)
const cheapest = options[0]
// Enforce MOV on total
let unitPrice = cheapest.unit
if (unitPrice * Q < minOrderValue){
unitPrice = minOrderValue / Q
}
variable('Chosen process id', cheapest.id) // 1=CNC, 2=SLA, 3=MJF
variable('CNC unit $', cncOpt.ok ? cncOpt.unit : -1)
variable('SLA unit $', slaOpt.ok ? slaOpt.unit : -1)
variable('MJF unit $', mjfOpt.ok ? mjfOpt.unit : -1)
done(variable('Unit Price $', unitPrice))
Tips for tuning (fast)
- CNC removal rates (the three $/mm³) are your main lever for alloy/tooling difficulty. Start with aluminum → set baselines → clone to stainless and Ti with multipliers.
- SLA realism climbs quickly with
sec/layer
and a small post-proc add. Don’t go to zero on post-proc; reality bites there. - MJF density cap is your “no regrets” setting. Keep it between 10–15% unless you’ve proven higher packing with stable thermals.
FAQ
Isn’t SLA too expensive in the example? At a 0.06 mm layer with 25 s/layer and $15/h, it should be. Drop the layer height or exposure for speed and watch the curve move. That’s the point—you can see the effect.
Where do margins and floors live?
Globally, so you don’t accidentally have different commercial rules per process. If you want per-material tweaks, add numeric overrides to material.variables
.
Can I add setup fees? Yes: bake a per-job setup number into each process unit (or apply MOV as shown). For SLA/MJF, some shops add a fixed handling fee—do it as a small uplift before the margin.
Summary
You don’t need a perfect physics model to win quotes. You need a fast, defendable quote that gets the deal in with minimal downside risk. This picker does exactly that: three real prices in seconds, then the cheapest valid one. No more spreadsheets!
If you want me to tune the defaults to your machine envelopes and materials, send a couple of recent jobs (geometry + target unit price) and I’ll calibrate the knobs so this matches your floor reality.
What topics would you like me to cover in future posts? Feel free to reach out on LinkedIn with your suggestions!