import logging import math from dataclasses import dataclass from app.services.file_parser import FileInfo logger = logging.getLogger("app.services.price_engine") TIME_RATE_PER_HOUR = 200.0 # руб/час SETUP_TIME_MIN = 10.0 # подготовка стола, прогрев POST_PROCESSING_COSTS = { "sanding": 300.0, "painting": 500.0, "threading": 200.0, "acetone_smoothing": 400.0, } MULTICOLOR_SURCHARGE_PERCENT = 30 QUANTITY_DISCOUNTS = [ (1, 0), (2, 5), (6, 10), (21, 15), (101, 20), ] # ─── Slicer-like defaults (Bambu Lab / OrcaSlicer profile) ─── WALL_COUNT = 3 # количество стенок (периметров) LINE_WIDTH_MM = 0.4 # ширина линии TOP_SOLID_LAYERS = 4 # верхние сплошные слои BOTTOM_SOLID_LAYERS = 4 # нижние сплошные слои # Скорости печати (мм/с) — Bambu Lab X1C типичный профиль SPEED_OUTER_WALL = 80 SPEED_INNER_WALL = 120 SPEED_INFILL = 200 SPEED_TOP_BOTTOM = 80 SPEED_TRAVEL = 300 SPEED_FIRST_LAYER = 50 # Коэффициент учёта retraction, acceleration, non-print moves # Bambu быстрый, но всё равно ~25-35% времени — непечатные перемещения NON_PRINT_OVERHEAD = 1.30 @dataclass class PriceResult: weight_grams: float material_cost_rub: float print_time_hours: float time_cost_rub: float post_processing_cost_rub: float subtotal_rub: float quantity: int quantity_discount_percent: int total_rub: float estimated_days: int def get_quantity_discount(quantity: int) -> int: discount = 0 for min_qty, disc in QUANTITY_DISCOUNTS: if quantity >= min_qty: discount = disc return discount def _estimate_weight(file_info: FileInfo, density_g_cm3: float, infill_percent: int, layer_height_mm: float) -> float: """ Estimate weight using a slicer-like model: - Walls: perimeter_length * wall_count * line_width * z_height * density - Top/bottom: footprint_area * solid_layers * layer_height * density - Infill: remaining interior volume * infill% * density """ bbox = file_info.bounding_box_mm x = bbox.get("x", 10.0) y = bbox.get("y", 10.0) z = bbox.get("z", 10.0) volume_mm3 = file_info.volume_cm3 * 1000.0 surface_mm2 = file_info.surface_area_cm2 * 100.0 # Approximate the perimeter length per layer from surface area: # surface_area ≈ perimeter_per_layer * z + 2 * footprint # So perimeter_per_layer ≈ (surface_area - 2 * footprint) / z footprint_mm2 = x * y * 0.65 # ~65% fill of bounding box for typical parts # Clamp: footprint can't be more than half of surface footprint_mm2 = min(footprint_mm2, surface_mm2 * 0.4) perimeter_per_layer_mm = max((surface_mm2 - 2 * footprint_mm2) / max(z, 1.0), 10.0) num_layers = max(z / layer_height_mm, 1) # Wall volume wall_thickness_total = WALL_COUNT * LINE_WIDTH_MM wall_volume_mm3 = perimeter_per_layer_mm * wall_thickness_total * layer_height_mm * num_layers logger.debug("Walls: perimeter=%.1f mm/layer, thickness=%.1f mm, volume=%.1f mm3", perimeter_per_layer_mm, wall_thickness_total, wall_volume_mm3) # Top + bottom solid layers volume solid_layers = TOP_SOLID_LAYERS + BOTTOM_SOLID_LAYERS solid_volume_mm3 = footprint_mm2 * layer_height_mm * solid_layers logger.debug("Solid top/bottom: footprint=%.1f mm2, layers=%d, volume=%.1f mm3", footprint_mm2, solid_layers, solid_volume_mm3) # Interior volume for infill (total volume minus walls minus top/bottom) interior_volume_mm3 = max(volume_mm3 - wall_volume_mm3 - solid_volume_mm3, 0) infill_volume_mm3 = interior_volume_mm3 * (infill_percent / 100.0) logger.debug("Infill: interior=%.1f mm3, infill%%=%d, infill_volume=%.1f mm3", interior_volume_mm3, infill_percent, infill_volume_mm3) total_volume_mm3 = wall_volume_mm3 + solid_volume_mm3 + infill_volume_mm3 # Sanity check: total filament volume shouldn't exceed full solid object total_volume_mm3 = min(total_volume_mm3, volume_mm3 * 1.05) weight_g = total_volume_mm3 / 1000.0 * density_g_cm3 logger.debug("Total filament volume: %.1f mm3, weight: %.1f g", total_volume_mm3, weight_g) return round(weight_g, 1) def _estimate_print_time(file_info: FileInfo, layer_height_mm: float, infill_percent: int, weight_grams: float, density_g_cm3: float) -> float: """ Estimate print time using toolpath-like model: - Wall time: perimeter_length * wall_count * layers / wall_speed - Infill time: infill_volume / (line_width * layer_height * infill_speed) - Top/bottom time: solid_area * solid_layers / solid_speed - First layer penalty - Non-print overhead (travel, retraction, acceleration) """ bbox = file_info.bounding_box_mm x = bbox.get("x", 10.0) y = bbox.get("y", 10.0) z = bbox.get("z", 10.0) surface_mm2 = file_info.surface_area_cm2 * 100.0 volume_mm3 = file_info.volume_cm3 * 1000.0 footprint_mm2 = x * y * 0.65 footprint_mm2 = min(footprint_mm2, surface_mm2 * 0.4) perimeter_per_layer_mm = max((surface_mm2 - 2 * footprint_mm2) / max(z, 1.0), 10.0) num_layers = max(z / layer_height_mm, 1) # Wall time: outer wall slower, inner walls faster outer_wall_time_s = (perimeter_per_layer_mm * num_layers) / SPEED_OUTER_WALL inner_wall_time_s = (perimeter_per_layer_mm * (WALL_COUNT - 1) * num_layers) / SPEED_INNER_WALL wall_time_s = outer_wall_time_s + inner_wall_time_s logger.debug("Wall time: outer=%.0fs, inner=%.0fs, total=%.0fs", outer_wall_time_s, inner_wall_time_s, wall_time_s) # Infill time: total infill length / speed # infill_length = infill_volume / (line_width * layer_height) total_filament_mm3 = weight_grams / density_g_cm3 * 1000.0 wall_volume_mm3 = perimeter_per_layer_mm * WALL_COUNT * LINE_WIDTH_MM * layer_height_mm * num_layers solid_volume_mm3 = footprint_mm2 * layer_height_mm * (TOP_SOLID_LAYERS + BOTTOM_SOLID_LAYERS) infill_volume_mm3 = max(total_filament_mm3 - wall_volume_mm3 - solid_volume_mm3, 0) infill_length_mm = infill_volume_mm3 / (LINE_WIDTH_MM * layer_height_mm) infill_time_s = infill_length_mm / SPEED_INFILL logger.debug("Infill time: length=%.0f mm, time=%.0fs", infill_length_mm, infill_time_s) # Top/bottom solid time solid_layers = TOP_SOLID_LAYERS + BOTTOM_SOLID_LAYERS solid_length_mm = (footprint_mm2 / LINE_WIDTH_MM) * solid_layers solid_time_s = solid_length_mm / SPEED_TOP_BOTTOM logger.debug("Solid top/bottom time: length=%.0f mm, time=%.0fs", solid_length_mm, solid_time_s) # First layer is slower first_layer_penalty_s = perimeter_per_layer_mm * WALL_COUNT / SPEED_FIRST_LAYER logger.debug("First layer penalty: %.0fs", first_layer_penalty_s) # Sum and apply overhead raw_time_s = wall_time_s + infill_time_s + solid_time_s + first_layer_penalty_s total_time_s = raw_time_s * NON_PRINT_OVERHEAD + SETUP_TIME_MIN * 60 hours = round(total_time_s / 3600.0, 1) logger.debug("Print time: raw=%.0fs, with overhead=%.0fs (%.1fh)", raw_time_s, total_time_s, hours) return max(hours, 0.1) def calculate_price( file_info: FileInfo, density_g_cm3: float, price_per_gram: float, flow_rate_mm3_s: float, infill_percent: int = 30, layer_height_mm: float = 0.2, quantity: int = 1, post_processing: list[str] | None = None, multicolor: bool = False, ) -> PriceResult: post_processing = post_processing or [] logger.info("=== Price calculation start ===") logger.info("Input: volume=%.2f cm3, area=%.2f cm2, density=%.2f g/cm3, price=%.1f RUB/g", file_info.volume_cm3, file_info.surface_area_cm2, density_g_cm3, price_per_gram) logger.info("Params: infill=%d%%, layer=%.2fmm, qty=%d, multicolor=%s", infill_percent, layer_height_mm, quantity, multicolor) # Weight (slicer-like) weight_g = _estimate_weight(file_info, density_g_cm3, infill_percent, layer_height_mm) material_cost = round(weight_g * price_per_gram, 2) logger.info("Weight: %.1f g, material cost: %.2f RUB", weight_g, material_cost) # Time (toolpath-like) print_time_h = _estimate_print_time(file_info, layer_height_mm, infill_percent, weight_g, density_g_cm3) time_cost = round(print_time_h * TIME_RATE_PER_HOUR, 2) logger.info("Print time: %.1f h, time cost: %.2f RUB", print_time_h, time_cost) # Post-processing pp_cost = 0.0 for pp in post_processing: cost = POST_PROCESSING_COSTS.get(pp, 0) pp_cost += cost pp_cost = round(pp_cost, 2) subtotal = round(material_cost + time_cost + pp_cost, 2) if multicolor: multicolor_surcharge = round(subtotal * MULTICOLOR_SURCHARGE_PERCENT / 100.0, 2) subtotal = round(subtotal + multicolor_surcharge, 2) logger.debug("Multicolor surcharge: +%.2f RUB", multicolor_surcharge) discount_pct = get_quantity_discount(quantity) total = round(subtotal * quantity * (1 - discount_pct / 100.0), 2) logger.info("Total: %.2f RUB (qty=%d, discount=%d%%)", total, quantity, discount_pct) if print_time_h <= 2: estimated_days = 2 elif print_time_h <= 8: estimated_days = 3 else: estimated_days = 5 if quantity > 10: estimated_days += 2 if quantity > 50: estimated_days += 3 logger.info("=== Price calculation complete ===") return PriceResult( weight_grams=weight_g, material_cost_rub=material_cost, print_time_hours=print_time_h, time_cost_rub=time_cost, post_processing_cost_rub=pp_cost, subtotal_rub=subtotal, quantity=quantity, quantity_discount_percent=discount_pct, total_rub=total, estimated_days=estimated_days, )